feat(discord): post a finished turn as the CLI's whole last message, stale thread cleanup, types moved to src/types

This commit is contained in:
Devvyn committed 2026-10-30 18:26:35 +08:00
1 parent d6c7d30bd4
commit 093f242662
18 files changed
+779 -97

No files matched your search

+1 -1
View File
@@ -131,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<tab folder>/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and, only with the separate `typedHint` setting (default OFF), is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "<message>" [--urgent] [--file <path>]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md`
**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); a finished turn (idle or stop) posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and never posted twice, while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<tab folder>/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and, only with the separate `typedHint` setting (default OFF), is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "<message>" [--urgent] [--file <path>]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md`
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
+1 -1
View File
@@ -1493,7 +1493,7 @@ One thread per tab in a private Discord channel, alerts out and replies typed ba
| Method | Path | Body | Notes |
| ------ | ------ | ------ | ------ |
| `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. |
| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, pingMode?, scope?, startupHint?, typedHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. |
| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, staleThreads?, pingMode?, scope?, startupHint?, typedHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. |
| `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. |
| `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent?, file? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `file` is an absolute path inside the tab's working folder (real path, regular file, at most 8 MB, local sessions only) uploaded with the post; `400` otherwise. A `200` carries the delivery result: `data.ok` is `false` (with `data.error`) when Discord refused or failed the post. `409` while the bridge is disabled, `429` past 6 posts a minute per tab. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. |
+11 -9
View File
@@ -7,7 +7,8 @@ Codeman can post to a private Discord server when a session needs you, give ever
## What it does
- Posts when a session asks for permission, asks a question, goes idle, stops or errors.
- Posts when a session asks for permission, asks a question, goes idle or errors. A finished turn ("stops") is an `info` event, so it posts only when **Which events** is set to Everything.
- Posts a finished turn as the CLI's whole last message, with its bold, lists and numbered questions kept, split over several posts if it is long. This reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them.
- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session.
- Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress.
- Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end.
@@ -88,14 +89,15 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push
## Options
| Option | Values | Default | Effect |
| ----------------- | --------------------------- | ----------- | --------------------------------------------------------------------------------------- |
| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab |
| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads |
| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts |
| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too |
| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch |
| `typedHint` | on, off | off | Also send the note once, as a first message, to brand-new non-Claude CLI tabs |
| Option | Values | Default | Effect |
| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab |
| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads |
| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman |
| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts |
| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too |
| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch |
| `typedHint` | on, off | off | Also send the note once, as a first message, to brand-new non-Claude CLI tabs |
## Telling the CLIs
+13 -9
View File
@@ -38,14 +38,18 @@ only), never in the shared settings, and never shown again once saved.
## Options
| Option | Choices | Default |
| ---------------------------------- | ---------------------------------------- | --------------- |
| Create threads | On the first alert, or for every new tab | First alert |
| When a tab is deleted | Keep, archive or delete the thread | Archive |
| @ ping | Never, critical events only, every post | Critical only |
| Which events | Needs attention, or everything | Needs attention |
| Tell Claude how to reach you | On or off | On |
| Also tell other CLIs, as a message | On or off | Off |
| Option | Choices | Default |
| ---------------------------------------- | ------------------------------------------- | --------------- |
| Create threads | On the first alert, or for every new tab | First alert |
| When a tab is deleted | Keep, archive or delete the thread | Archive |
| Stale threads | Keep, archive or delete threads no tab uses | Keep |
| @ ping | Never, critical events only, every post | Critical only |
| Which events | Needs attention, or everything | Needs attention |
| Tell Claude how to reach you | On or off | On |
| Also tell other CLIs, as a first message | On or off | Off |
A finished turn is posted as the CLI's whole last message, with its formatting and questions kept, split over
several posts if it is long. A shell, and permission or question dialogs, show the last lines of the screen instead.
Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a
permission prompt, a question, an error, or a post an agent marked urgent.
@@ -55,7 +59,7 @@ permission prompt, a question, an error, or a post an agent marked urgent.
An agent only posts deliberately if it knows it can:
- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command).
- **Every other CLI** is told only if you turn on **Also tell other CLIs, as a message** (off by default: it costs one model turn per tab, and for a spawned worker or a scheduled run it arrives with the first real prompt). Then the same text is sent once, as a message, the first time a brand-new tab goes idle.
- **Every other CLI** is told only if you turn on **Also tell other CLIs, as a first message** (off by default: it costs one model turn per tab, and for a spawned worker or a scheduled run it arrives with the first real prompt). Then the same text is sent once, as a message, the first time a brand-new tab goes idle.
Restored tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it.
Tabs that were already open when you turned the bridge on are not told. The alerts still reach
+195 -41
View File
@@ -26,7 +26,16 @@ import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { probePathKind } from './utils/bounded-path-probe.js';
import WebSocket from 'ws';
import { WEBHOOK_SCOPES, type WebhookScope, type WebhookUrgency } from './types/push.js';
import { WEBHOOK_SCOPES, type WebhookUrgency } from './types/push.js';
import {
DISCORD_ON_DELETE,
DISCORD_PING_MODES,
DISCORD_THREAD_CREATION,
type DiscordConfig,
type DiscordGatewayState,
type DiscordPingMode,
type DiscordResult,
} from './types/discord.js';
const CONFIG_FILE = 'discord.json';
const THREADS_FILE = 'discord-bridge.json';
@@ -44,33 +53,14 @@ const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15);
/** Gateway close codes that retrying cannot fix: bad token, bad intents, bad shard. */
const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
const SNOWFLAKE = /^\d{15,25}$/;
export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const;
export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number];
export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const;
export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number];
export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const;
export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number];
export interface DiscordConfig {
enabled: boolean;
token: string;
guildId: string;
parentChannelId: string;
allowedUserIds: string[];
threadCreation: DiscordThreadCreation;
onSessionDelete: DiscordOnDelete;
pingMode: DiscordPingMode;
/** `attention` skips info-level events (a finished response); `all` sends them too. */
scope: WebhookScope;
/** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */
startupHint: boolean;
/**
* Also type the hint, once, as the first message to a brand-new non-Claude CLI tab. Off by default: it spends a
* model turn per tab, and for a spawned worker or a cron session it lands on the same first idle as the real prompt.
*/
typedHint: boolean;
}
/** Stale-thread sweep: a thread younger than this may still be between creation and its map entry. */
const STALE_MIN_AGE_MS = 60 * 60 * 1000;
const STALE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** At most this many threads closed in one sweep, so a damaged map cannot empty the channel in one go. */
const STALE_MAX_PER_SWEEP = 25;
const DISCORD_EPOCH_MS = 1420070400000;
/** Reading a CLI's transcript must not hold an alert back for long. */
const FULL_TEXT_TIMEOUT_MS = 4000;
export const DEFAULT_DISCORD_CONFIG: DiscordConfig = {
enabled: false,
@@ -80,6 +70,7 @@ export const DEFAULT_DISCORD_CONFIG: DiscordConfig = {
allowedUserIds: [],
threadCreation: 'lazy',
onSessionDelete: 'archive',
staleThreads: 'keep',
pingMode: 'critical',
scope: 'attention',
startupHint: true,
@@ -95,6 +86,11 @@ export interface DiscordMessage {
sessionName?: string;
/** The tail of what the CLI last printed, so the reader can see what it is asking. */
tail?: string | (() => string);
/**
* The CLI's whole last message, read from its transcript. When it yields text the post carries that
* (split over as many posts as it needs, markdown kept) instead of the screen tail.
*/
fullText?: () => Promise<string | undefined>;
host?: string;
/** A file to upload with the post (already read and size-checked by the caller). */
file?: DiscordFile;
@@ -105,17 +101,6 @@ export interface DiscordFile {
data: Uint8Array;
}
export interface DiscordResult {
ok: boolean;
/** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */
deduped?: boolean;
status?: number;
error?: string;
at: number;
}
export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error';
// ---------------------------------------------------------------------------
// Pure
// ---------------------------------------------------------------------------
@@ -201,9 +186,12 @@ export function getDiscordStartupHint(): string | undefined {
/**
* The last lines of a rendered screen for a post: colour and charset codes removed, blank lines and
* pure rule lines (box drawing, dashes) dropped, the last 14 lines, at most 900 characters, cut on a line.
* With `composerGlyph` (the CLI's own prompt mark), the composer row and everything under it (the status
* and mode hints) is cut first, but only within the last 8 rows and never a dialog's selected option
* (`❯ 1. Yes`), so a question's choices stay.
*/
export function screenTail(screen: string, maxLines = 14, maxChars = 900): string {
const lines = screen
export function screenTail(screen: string, maxLines = 14, maxChars = 900, composerGlyph?: string): string {
let lines = screen
// eslint-disable-next-line no-control-regex
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[()][A-Za-z0-9]|\u001b[@-Z\\-_]/g, '')
// eslint-disable-next-line no-control-regex
@@ -211,11 +199,60 @@ export function screenTail(screen: string, maxLines = 14, maxChars = 900): strin
.split(/\r?\n/)
.map((l) => l.trimEnd())
.filter((l) => l.trim() !== '' && !/^[\s\-_=\u2500-\u257f]+$/.test(l));
if (composerGlyph) {
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 8); i--) {
const row = lines[i].trimStart();
if (row.startsWith(composerGlyph) && !/^\S+\s+\d+[.)]/.test(row)) {
lines = lines.slice(0, i);
break;
}
}
}
let out = lines.slice(-maxLines);
while (out.length > 1 && out.join('\n').length > maxChars) out = out.slice(1);
return out.join('\n').slice(-maxChars);
}
/**
* Split a long message into posts of at most `max` characters, on blank lines first, then lines, then
* (for one huge line) by length. A code fence the cut lands inside is closed and reopened, so each post
* renders on its own. At most `maxPosts`; the last carries a note when text is left over.
*/
export function splitForDiscord(text: string, max = 1900, maxPosts = 8): string[] {
const posts: string[] = [];
let cur = '';
let fence = false;
let cut = false;
const flush = () => {
if (cur.trim()) posts.push(fence ? `${cur.trimEnd()}\n\`\`\`` : cur.trimEnd());
cur = fence ? '```\n' : '';
};
const lines = text.replace(/\r\n/g, '\n').split('\n');
for (let line of lines) {
if (posts.length >= maxPosts) {
cut = true;
break;
}
while (line.length > max - 8) {
if (cur.trim()) flush();
cur += line.slice(0, max - 8) + '\n';
line = line.slice(max - 8);
flush();
if (posts.length >= maxPosts) {
cut = true;
break;
}
}
if (cut) break;
if (cur.length + line.length + 1 > max - 8) flush();
cur += `${line}\n`;
if (/^\s*```/.test(line)) fence = !fence;
}
if (!cut && cur.trim()) posts.push(cur.trimEnd());
if (cut && posts.length > 0) posts[posts.length - 1] += '\n*(The message continues in the tab.)*';
return posts.slice(0, maxPosts);
}
/** Final path segment only, `[A-Za-z0-9._-]`, no leading dot, at most 80 characters. */
export function safeAttachmentName(name: string): string {
const base = (name.split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '');
@@ -279,6 +316,7 @@ function coerce(raw: unknown): DiscordConfig {
: [],
threadCreation: oneOf(DISCORD_THREAD_CREATION, r.threadCreation, d.threadCreation),
onSessionDelete: oneOf(DISCORD_ON_DELETE, r.onSessionDelete, d.onSessionDelete),
staleThreads: oneOf(DISCORD_ON_DELETE, r.staleThreads, d.staleThreads),
pingMode: oneOf(DISCORD_PING_MODES, r.pingMode, d.pingMode),
scope: oneOf(WEBHOOK_SCOPES, r.scope, d.scope),
startupHint: r.startupHint === undefined ? d.startupHint : r.startupHint === true,
@@ -286,6 +324,17 @@ function coerce(raw: unknown): DiscordConfig {
};
}
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
return Promise.race([
p,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('timed out')), ms);
timer.unref?.();
}),
]).finally(() => clearTimeout(timer));
}
/** A failure with a reason worth showing as it is (unlike a network error, whose text is not). */
class BridgeProblem extends Error {}
@@ -345,6 +394,13 @@ async function writeThreadMap(
// IO: REST
// ---------------------------------------------------------------------------
export interface DiscordThreadInfo {
id: string;
parent_id?: string;
owner_id?: string;
thread_metadata?: { archived?: boolean };
}
export type DiscordFetch = (target: URL, init: RequestInit) => Promise<Response>;
export class DiscordHttpError extends Error {
@@ -414,6 +470,17 @@ export class DiscordApi {
remove(threadId: string): Promise<unknown> {
return this.request('DELETE', `/channels/${threadId}`);
}
botUserId(): Promise<{ id: string } | null> {
return this.request('GET', '/users/@me');
}
/** Active threads of the guild (all channels: the caller filters by parent). */
activeThreads(guildId: string): Promise<{ threads: DiscordThreadInfo[] } | null> {
return this.request('GET', `/guilds/${guildId}/threads/active`);
}
/** The most recent archived public threads of a channel (one page). */
archivedThreads(parentId: string): Promise<{ threads: DiscordThreadInfo[] } | null> {
return this.request('GET', `/channels/${parentId}/threads/archived/public?limit=100`);
}
react(channelId: string, messageId: string, emoji: string): Promise<unknown> {
return this.request(
'PUT',
@@ -690,6 +757,8 @@ export class DiscordBridge {
private pending = new Map<string, Promise<string | DiscordResult>>();
/** Tabs closed while their thread was still being created: the creation applies `onSessionDelete` to it. */
private closedWhilePending = new Set<string>();
/** The last whole message posted per tab, so the same text is not posted twice (idle, then stop). */
private lastFull = new Map<string, string>();
/** The parent channel the thread map was built under. */
private threadsParent = '';
/** True from boot until the restored tabs exist (see hold). */
@@ -699,6 +768,7 @@ export class DiscordBridge {
/** Per-thread chain of inbound messages (see onGatewayMessage). */
private inbound = new Map<string, Promise<void>>();
private mapLoad?: Promise<void>;
private sweepTimer?: ReturnType<typeof setInterval>;
/** The first full load (config, thread map, gateway), shared by every caller that arrives before it ends. */
private firstLoad?: Promise<void>;
private last: DiscordResult | null = null;
@@ -737,6 +807,63 @@ export class DiscordBridge {
await this.persistThreads();
}
/**
* Close threads in the parent channel that this bot made and no tab maps to (`staleThreads`). Runs after
* the boot prune, then daily. Never touches a thread someone else made (owner is the bot), a young one
* (its map entry may not be written yet), or anything while the map is empty (a lost map would make
* every thread look stale). Returns how many it archived or deleted. Never throws.
*/
async sweepStaleThreads(): Promise<number> {
try {
await this.ensureLoaded();
const c = this.cfg;
if (c.staleThreads === 'keep' || !c.enabled || !c.token || !c.guildId || !c.parentChannelId) return 0;
if (Object.keys(this.threads).length === 0 || this.pending.size > 0) return 0;
const me = (await this.api.botUserId())?.id;
if (!me) return 0;
const known = new Set(Object.values(this.threads));
const cutoff = this.now() - STALE_MIN_AGE_MS;
const stale = (list: DiscordThreadInfo[] | undefined, archived: boolean) =>
(list ?? [])
.filter(
(t) =>
t.parent_id === c.parentChannelId &&
t.owner_id === me &&
SNOWFLAKE.test(t.id) &&
!known.has(t.id) &&
Number((BigInt(t.id) >> 22n) + BigInt(DISCORD_EPOCH_MS)) < cutoff &&
// An archived thread has nothing left to archive.
!(archived && c.staleThreads === 'archive')
)
.map((t) => t.id);
const ids = [
...stale((await this.api.activeThreads(c.guildId))?.threads, false),
...(c.staleThreads === 'delete'
? stale((await this.api.archivedThreads(c.parentChannelId))?.threads, true)
: []),
].slice(0, STALE_MAX_PER_SWEEP);
let done = 0;
for (const id of ids) {
const ok = await (c.staleThreads === 'delete' ? this.api.remove(id) : this.api.archive(id)).then(
() => true,
() => false
);
if (ok) done++;
}
return done;
} catch {
return 0;
}
}
/** Sweep now, then daily. The timer does not keep the process alive and `stop()` clears it. */
startStaleSweep(): void {
clearInterval(this.sweepTimer);
void this.sweepStaleThreads();
this.sweepTimer = setInterval(() => void this.sweepStaleThreads(), STALE_SWEEP_INTERVAL_MS);
this.sweepTimer.unref?.();
}
/** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */
hold(): void {
this.held = true;
@@ -790,6 +917,8 @@ export class DiscordBridge {
}
stop(): void {
clearInterval(this.sweepTimer);
this.sweepTimer = undefined;
this.gateway?.stop();
this.gateway = null;
this.gatewayToken = '';
@@ -798,6 +927,7 @@ export class DiscordBridge {
this.lastReply.clear();
this.names.clear();
this.fresh.clear();
this.lastFull.clear();
this.inbound.clear();
}
@@ -886,7 +1016,29 @@ export class DiscordBridge {
if (this.lastSent.size > 256) {
for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k);
}
let full: string | undefined;
if (msg.fullText) {
try {
full = (await withTimeout(msg.fullText(), FULL_TEXT_TIMEOUT_MS))?.trim() || undefined;
} catch {
full = undefined; // no transcript to read: the screen tail below stands in
}
}
let chunks: string[] = [];
if (full && msg.sessionId) {
if (this.lastFull.get(msg.sessionId) === full)
msg = { ...msg, tail: undefined }; // said already
else {
this.lastFull.set(msg.sessionId, full);
chunks = splitForDiscord(full);
msg = { ...msg, tail: undefined };
}
}
const post = buildPost(msg, this.cfg, explicitUrgent);
// One post when the head and the first part fit together, the rest follow without a ping.
if (chunks.length > 0 && post.content.length + 1 + chunks[0].length <= MAX_CONTENT) {
post.content += `\n${chunks.shift()}`;
}
try {
let target = this.cfg.parentChannelId;
if (msg.sessionId) {
@@ -908,6 +1060,7 @@ export class DiscordBridge {
throw err;
}
}
for (const part of chunks) await this.say(this.threads[msg.sessionId ?? ''] ?? target, part);
return (this.last = { ok: true, at: this.now() });
} catch (err) {
return (this.last = this.fail(err));
@@ -985,6 +1138,7 @@ export class DiscordBridge {
this.lastReply.delete(sessionId);
this.names.delete(sessionId);
this.fresh.delete(sessionId);
this.lastFull.delete(sessionId);
}
/** Session tab deleted: apply `onSessionDelete` and forget the mapping. */
+65
View File
@@ -0,0 +1,65 @@
/**
* @fileoverview Discord bridge type definitions.
*
* The option lists and the config, result and status shapes of the two-way Discord bridge, kept apart
* from the IO module (`src/discord-bridge.ts`, which pulls in `ws`) so the request schemas and the
* route can import them alone, as the webhook's equivalents live in `push.ts`.
*
* Key exports:
* - DISCORD_THREAD_CREATION, DISCORD_ON_DELETE, DISCORD_PING_MODES (+ their types): the option lists
* - DiscordConfig: `~/.codeman/discord.json` (mode 0600; the bot token is a secret)
* - DiscordResult, DiscordGatewayState, DiscordStatus: what `GET /api/discord` reports
*
* No dependencies on other domain modules but `push.ts` (the shared scope list).
*/
import type { WebhookScope } from './push.js';
export const DISCORD_THREAD_CREATION = ['lazy', 'onSessionStart'] as const;
export type DiscordThreadCreation = (typeof DISCORD_THREAD_CREATION)[number];
export const DISCORD_ON_DELETE = ['keep', 'archive', 'delete'] as const;
export type DiscordOnDelete = (typeof DISCORD_ON_DELETE)[number];
export const DISCORD_PING_MODES = ['off', 'critical', 'all'] as const;
export type DiscordPingMode = (typeof DISCORD_PING_MODES)[number];
export interface DiscordConfig {
enabled: boolean;
token: string;
guildId: string;
parentChannelId: string;
allowedUserIds: string[];
threadCreation: DiscordThreadCreation;
onSessionDelete: DiscordOnDelete;
/** What to do with threads the bridge made that no tab maps to any more: checked after boot, then daily. */
staleThreads: DiscordOnDelete;
pingMode: DiscordPingMode;
/** `attention` skips info-level events (a finished response); `all` sends them too. */
scope: WebhookScope;
/** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */
startupHint: boolean;
/**
* Also type the hint, once, as the first message to a brand-new non-Claude CLI tab. Off by default: it spends a
* model turn per tab, and for a spawned worker or a cron session it lands on the same first idle as the real prompt.
*/
typedHint: boolean;
}
export interface DiscordResult {
ok: boolean;
/** True when the same event for the same tab was posted within the last few seconds, so nothing was sent. */
deduped?: boolean;
status?: number;
error?: string;
at: number;
}
export type DiscordGatewayState = 'off' | 'connecting' | 'connected' | 'error';
/** `GET /api/discord`: the config without its token, plus what the bridge is doing. */
export type DiscordStatus = Omit<DiscordConfig, 'token'> & {
hasToken: boolean;
tokenMasked: string;
gateway: DiscordGatewayState;
gatewayError: string | null;
lastResult: DiscordResult | null;
};
+2
View File
@@ -25,6 +25,7 @@
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
* | discord | DiscordConfig, DiscordResult, DiscordStatus, DiscordGatewayState | `~/.codeman/discord.json` → `GET`/`PUT /api/discord` |
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
@@ -65,6 +66,7 @@ export * from './run-summary.js';
export * from './tools.js';
export * from './teams.js';
export * from './push.js';
export * from './discord.js';
export * from './plan.js';
export * from './orchestrator.js';
export * from './update.js';
+5
View File
@@ -10,6 +10,11 @@ import type { SessionPlacementHint } from '../../tab-layout-service.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
/**
* The last-response reader (the body of `GET /api/sessions/:id/last-response`), filled in when the
* session routes register, so other server code reads the same per-CLI transcript the viewer does.
*/
lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>;
/**
* Register a created session with the owner's tab layout. `placement.tabGroupId`
* puts it at the end of that group in the same layout write (ignored when the
+8
View File
@@ -3278,6 +3278,14 @@
<option value="delete">Delete the thread</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Stale threads</span><span class="set-row-desc">Threads this bot made that no tab uses any more, checked after startup and then daily. Threads under an hour old are skipped. Use one channel per Codeman.</span></div>
<select id="discordStaleThreads" class="set-select">
<option value="keep">Leave them</option>
<option value="archive">Archive them</option>
<option value="delete">Delete them</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">@ ping</span></div>
<select id="discordPingMode" class="set-select">
+2
View File
@@ -1767,6 +1767,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', ');
document.getElementById('discordThreadCreation').value = d.threadCreation;
document.getElementById('discordOnSessionDelete').value = d.onSessionDelete;
document.getElementById('discordStaleThreads').value = d.staleThreads || 'keep';
document.getElementById('discordPingMode').value = d.pingMode;
document.getElementById('discordScope').value = d.scope;
document.getElementById('discordStartupHint').checked = d.startupHint !== false;
@@ -1792,6 +1793,7 @@ Object.assign(CodemanApp.prototype, {
allowedUserIds: ids,
threadCreation: document.getElementById('discordThreadCreation').value,
onSessionDelete: document.getElementById('discordOnSessionDelete').value,
staleThreads: document.getElementById('discordStaleThreads').value,
pingMode: document.getElementById('discordPingMode').value,
scope: document.getElementById('discordScope').value,
startupHint: document.getElementById('discordStartupHint').checked,
+1 -11
View File
@@ -27,10 +27,8 @@ import {
readDiscordConfig,
writeDiscordConfig,
type DiscordBridge,
type DiscordConfig,
type DiscordGatewayState,
type DiscordResult,
} from '../../discord-bridge.js';
import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js';
export interface DiscordRouteDeps {
bridge: DiscordBridge;
@@ -39,14 +37,6 @@ export interface DiscordRouteDeps {
hostTitle: () => string;
}
export type DiscordStatus = Omit<DiscordConfig, 'token'> & {
hasToken: boolean;
tokenMasked: string;
gateway: DiscordGatewayState;
gatewayError: string | null;
lastResult: DiscordResult | null;
};
/**
* A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at
* least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id.
+15 -13
View File
@@ -2874,16 +2874,14 @@ export function registerSessionRoutes(
return candidates[0] ?? null;
}
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
// The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript
// the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic.
const lastResponseFor = async (session: Session, context?: string) => {
// Codex sessions don't write to ~/.claude/projects — their transcripts
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
// response-viewer works for Codex panes too.
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
const codexQuery = req.query as { context?: string };
return await readCodexLastResponse(session, codexQuery.context === 'full');
return await readCodexLastResponse(session, context === 'full');
}
// DeepSeek Harness writes a real structured transcript under
@@ -2902,8 +2900,7 @@ export function registerSessionRoutes(
// would starve on an answer that exists. Those configurations keep the
// pane segmenter below: coarse, but the real conversation.
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
const deepSeekQuery = req.query as { context?: string };
const full = deepSeekQuery.context === 'full';
const full = context === 'full';
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
if (transcript) {
return {
@@ -2921,7 +2918,6 @@ export function registerSessionRoutes(
// the pane IS the transcript for these CLIs. Codex is already handled above,
// where a real rollout file is the better source.
if (isExternalCliTranscriptMode(session.mode)) {
const externalQuery = req.query as { context?: string };
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
// buffer when there is no tmux pane to ask (direct PTY, tests).
@@ -2934,7 +2930,7 @@ export function registerSessionRoutes(
text: getLastTranscriptResponse(blocks),
timestamp: '',
hasContext: blocks.length > 0,
messages: externalQuery.context === 'full' ? blocks : undefined,
messages: context === 'full' ? blocks : undefined,
};
}
@@ -2960,11 +2956,10 @@ export function registerSessionRoutes(
}
}
const query = req.query as { context?: string };
// `turn` is the brief view's context: the last ANSWERED turn's assistant
// messages, so a multi-row answer is not reduced to its final row. `text`
// stays the last assistant row in every mode (agent pollers hash it).
const wantsMessages = query.context === 'full' || query.context === 'turn';
const wantsMessages = context === 'full' || context === 'turn';
const claudeSessionId = session.claudeSessionId || session.id;
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
if (!transcript) {
@@ -2985,7 +2980,7 @@ export function registerSessionRoutes(
try {
const content = await fs.readFile(transcript.path, 'utf8');
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
if (query.context === 'turn') {
if (context === 'turn') {
return {
text: parsed.text,
timestamp: parsed.timestamp,
@@ -2996,6 +2991,13 @@ export function registerSessionRoutes(
} catch {
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
}
};
ctx.lastResponseFor = lastResponseFor;
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
return lastResponseFor(session, (req.query as { context?: string }).context);
});
function isCodexInjectedContext(text: string): boolean {
+2 -1
View File
@@ -20,7 +20,7 @@ import {
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../discord-bridge.js';
import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../types/discord.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
@@ -2229,6 +2229,7 @@ export const DiscordUpdateSchema = z
allowedUserIds: z.array(z.string().max(25)).max(20).optional(),
threadCreation: z.enum(DISCORD_THREAD_CREATION).optional(),
onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(),
staleThreads: z.enum(DISCORD_ON_DELETE).optional(),
pingMode: z.enum(DISCORD_PING_MODES).optional(),
scope: z.enum(WEBHOOK_SCOPES).optional(),
startupHint: z.boolean().optional(),
+36 -8
View File
@@ -46,6 +46,7 @@ import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
import { DiscordBridge, screenTail } from '../discord-bridge.js';
import { isExternalCliTranscriptMode } from './response-viewer-transcript.js';
import { stopHostSensors } from '../host-sensors.js';
import type { WebhookUrgency } from '../types/push.js';
import { webviewFetch } from './webview-egress.js';
@@ -509,6 +510,8 @@ export class WebServer extends EventEmitter {
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
/** Two-way Discord bridge (thread per tab); config in discord.json (0600). Idle until enabled in Settings. */
/** Set by the session routes when they register (see SessionPort). */
lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>;
private discordBridge = new DiscordBridge({
configDir: getDataDir(),
fetchImpl: webviewFetch,
@@ -1334,6 +1337,8 @@ export class WebServer extends EventEmitter {
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
// The routes' ctx is a separate object: take the last-response reader they registered on it (Discord alerts).
this.lastResponseFor = (ctx as { lastResponseFor?: WebServer['lastResponseFor'] }).lastResponseFor;
registerAgentSessionRoutes(this.app, ctx);
registerRespawnRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx);
@@ -3013,6 +3018,9 @@ export class WebServer extends EventEmitter {
// Discord: same event, same independence from Web Push. It adds the tail of what the CLI printed,
// so the person reading the thread can see what is being asked without opening the tab.
const dcSession = sessionId ? this.sessions.get(sessionId) : undefined;
// A question or permission dialog lives on the screen, so it keeps the screen tail (its options are
// there); a finished turn posts the CLI's whole last message instead, from its transcript.
const dialogEvent = event === SseEvent.HookPermissionPrompt || event === SseEvent.HookElicitationDialog;
void this.discordBridge
.notify({
event,
@@ -3022,7 +3030,8 @@ export class WebServer extends EventEmitter {
sessionId: sessionId || undefined,
sessionName: sessionName || undefined,
// A function, so the screen is only read (a synchronous tmux exec) once the bridge has decided to post.
tail: dcSession ? () => this.discordTail(dcSession) : undefined,
tail: dcSession ? () => this.discordTail(dcSession, !dialogEvent) : undefined,
fullText: dcSession && !dialogEvent ? () => this.discordFullText(dcSession) : undefined,
host: this.windowTitle,
})
.catch(() => undefined);
@@ -3113,9 +3122,25 @@ export class WebServer extends EventEmitter {
* (`capture-pane -p`), never the raw output stream, which a full-screen UI repaints in place and
* reads as garbage. A session with no pane falls back to its plain text output, else nothing.
*/
private discordTail(session: { muxName?: string | null; textOutput: string }): string {
private discordTail(session: Session, cutComposer: boolean): string {
const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null;
return screenTail(pane ?? session.textOutput ?? '');
// The composer row and the status lines under it are screen chrome, not what the CLI said.
const glyph = cutComposer ? getCli(session.mode)?.capabilities.workDetect?.promptGlyph : undefined;
return screenTail(pane ?? session.textOutput ?? '', 14, 900, glyph);
}
/**
* The CLI's whole last message from the response viewer's reader (the CLI's own transcript, or the pane
* segmented into turns for the CLIs that keep none); undefined for a shell, which has no turns, and the
* screen tail stands in whenever the reader finds nothing.
*/
private async discordFullText(session: Session): Promise<string | undefined> {
if (!this.lastResponseFor) return undefined;
// A CLI with a transcript reader, or whose pane the viewer segments into turns; a shell has neither.
if (getCli(session.mode)?.capabilities.transcript === 'none' && !isExternalCliTranscriptMode(session.mode)) {
return undefined;
}
return (await this.lastResponseFor(session)).text;
}
/** The notification body for an event (shared by Web Push and the webhook channel). */
@@ -3261,14 +3286,17 @@ export class WebServer extends EventEmitter {
await this.finalizeRestoredState(restored);
void this.discordBridge
.release()
.then(() =>
// Same keep set as the agent inbox prune: a reboot-restore rebuild reuses the old id.
this.discordBridge.prune([
.then(async () => {
// Only after a successful restore, like the agent inbox prune and the tab layout reconciliation, and
// with the same keep set: a reboot-restore rebuild reuses the old id.
if (!restored) return;
await this.discordBridge.prune([
...this.sessions.keys(),
...Object.keys(this.store.getSessions()),
...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId),
])
)
]);
})
.then(() => this.discordBridge.startStaleSweep())
.catch(() => undefined);
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
+179 -1
View File
@@ -16,6 +16,7 @@ import {
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DiscordConfig } from '../src/types/discord.js';
import {
buildPost,
DISCORD_STARTUP_HINT,
@@ -23,6 +24,7 @@ import {
isDiscordCdnUrl,
safeAttachmentName,
screenTail,
splitForDiscord,
DEFAULT_DISCORD_CONFIG,
DiscordApi,
DiscordBridge,
@@ -36,7 +38,6 @@ import {
shouldPing,
threadNameFor,
writeDiscordConfig,
type DiscordConfig,
type DiscordFetch,
type DiscordMessage,
type GatewaySocket,
@@ -557,6 +558,129 @@ describe('DiscordBridge', () => {
b.stop();
});
describe('full last message', () => {
const LONG = Array.from({ length: 60 }, (_, i) => `**Point ${i}**: ${'detail '.repeat(10)}\nIs this right?`).join(
'\n\n'
);
it('posts the whole message as markdown: head and first part together, the rest without a ping', async () => {
const b = bridge();
await b.notify({ ...MSG, tail: 'screen chrome', fullText: async () => LONG });
const sent = posts.filter((p) => p.path.endsWith('/messages'));
expect(sent.length).toBeGreaterThan(1);
const first = String(sent[0].body?.content);
expect(first).toContain(MSG.title);
expect(first).toContain('**Point 0**');
expect(first).not.toContain('screen chrome');
expect(first).not.toContain('```'); // markdown, not a code box
expect(sent[1].body?.allowed_mentions).toEqual({ parse: [], users: [] });
const all = sent.map((p) => String(p.body?.content)).join('\n');
expect(all.match(/Is this right\?/g)).toHaveLength(60);
b.stop();
});
it('does not post the same message twice (idle, then stop), and falls back to the tail without a transcript', async () => {
const b = bridge();
await b.notify({ ...MSG, fullText: async () => 'Short answer.' });
t += 10_000;
posts.length = 0;
await b.notify({ ...MSG, event: 'hook:stop', tail: 'chrome', fullText: async () => 'Short answer.' });
const again = posts.filter((p) => p.path.endsWith('/messages')).map((p) => String(p.body?.content));
expect(again).toHaveLength(1);
expect(again[0]).not.toContain('Short answer.');
expect(again[0]).not.toContain('chrome');
t += 10_000;
posts.length = 0;
await b.notify({ ...MSG, event: 'hook:idle_prompt', tail: 'the screen', fullText: async () => undefined });
expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen');
b.stop();
});
it('falls back to the tail when reading the transcript throws or hangs', async () => {
const b = bridge();
await b.notify({
...MSG,
tail: 'the screen',
fullText: async () => {
throw new Error('no transcript');
},
});
expect(String(posts.find((p) => p.path.endsWith('/messages'))?.body?.content)).toContain('the screen');
b.stop();
});
});
describe('sweepStaleThreads', () => {
const ME = '555555555555555555';
const OTHER = '666666666666666666';
const OLD = (n: number) => `30000000000000000${n}`; // a snowflake from 2015, long before `now`
let young: string;
let calls: Array<{ method: string; path: string; body?: unknown }>;
async function swept(staleThreads: 'keep' | 'archive' | 'delete') {
await writeDiscordConfig(dir, { ...CFG, staleThreads });
t = Date.now();
young = String((BigInt(t - 60_000 - 1420070400000) << 22n) + 5n);
calls = [];
const th = (id: string, owner = ME, parent = PARENT) => ({ id, owner_id: owner, parent_id: parent });
const b = bridge({}, async (url, init) => {
const path = new URL(String(url)).pathname.replace('/api/v10', '');
const method = String(init.method);
calls.push({ method, path, body: init.body ? JSON.parse(String(init.body)) : undefined });
if (path === '/users/@me') return jsonRes({ id: ME });
if (path.endsWith('/threads/active'))
return jsonRes({
threads: [
th(THREAD), // mapped
th(OLD(1)), // stale
th(OLD(2), OTHER), // someone else's thread
th(OLD(3), ME, '999999999999999999'), // another channel
th(young), // too new to judge
],
});
if (path.includes('/threads/archived')) return jsonRes({ threads: [th(OLD(4))] });
if (path.endsWith('/threads')) return jsonRes({ id: THREAD });
return method === 'PUT' ? new Response(null, { status: 204 }) : jsonRes({ id: 'msg' });
});
return b.notify(MSG).then(() => {
calls.length = 0;
return b;
});
}
const touched = () => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path.split('/').pop()}`);
it('does nothing while set to keep', async () => {
const b = await swept('keep');
expect(await b.sweepStaleThreads()).toBe(0);
expect(calls).toHaveLength(0);
b.stop();
});
it("archives only this bot's old, unmapped threads in the parent channel, and skips archived ones", async () => {
const b = await swept('archive');
expect(await b.sweepStaleThreads()).toBe(1);
expect(touched()).toEqual([`PATCH ${OLD(1)}`]);
expect(calls.some((c) => c.path.includes('/threads/archived'))).toBe(false);
b.stop();
});
it('deletes stale active and archived threads, and still spares the rest', async () => {
const b = await swept('delete');
expect(await b.sweepStaleThreads()).toBe(2);
expect(touched()).toEqual([`DELETE ${OLD(1)}`, `DELETE ${OLD(4)}`]);
b.stop();
});
it('does nothing when the map is empty, since a lost map would make every thread look stale', async () => {
const b = await swept('delete');
await b.sessionDeleted('s1'); // empties the map
calls.length = 0;
expect(await b.sweepStaleThreads()).toBe(0);
expect(calls).toHaveLength(0);
b.stop();
});
});
it('keeps the gateway off while held (boot) and starts it on release', async () => {
const b = bridge();
b.hold();
@@ -1028,7 +1152,61 @@ describe('DiscordBridge files and startup hint', () => {
});
});
describe('splitForDiscord', () => {
it('keeps a short message whole', () => {
expect(splitForDiscord('Line one\n\n**Two**\n- a\n- b')).toEqual(['Line one\n\n**Two**\n- a\n- b']);
});
it('splits a long message into posts under the limit and loses no line', () => {
const lines = Array.from({ length: 120 }, (_, i) => `- item ${i} ${'x'.repeat(40)}`);
const posts = splitForDiscord(lines.join('\n'));
expect(posts.length).toBeGreaterThan(1);
expect(posts.every((p) => p.length <= 1900)).toBe(true);
expect(
posts
.join('\n')
.split('\n')
.filter((l) => l.startsWith('- item'))
).toEqual(lines);
});
it('closes a code fence the cut lands inside and reopens it in the next post', () => {
const body = ['```', ...Array.from({ length: 100 }, (_, i) => `row ${i} ${'y'.repeat(40)}`), '```'].join('\n');
const posts = splitForDiscord(body);
expect(posts.length).toBeGreaterThan(1);
for (const p of posts) expect((p.match(/^```/gm) ?? []).length % 2).toBe(0);
});
it('cuts one enormous line by length, and caps the number of posts with a note', () => {
const posts = splitForDiscord('z'.repeat(50_000), 1900, 3);
expect(posts).toHaveLength(3);
expect(posts.every((p) => p.length <= 2000)).toBe(true); // Discord's own limit, with the note included
expect(posts[2]).toMatch(/continues in the tab/);
});
});
describe('screenTail', () => {
it("cuts the composer row and the status lines under it, but keeps a dialog's selected option", () => {
const idle = [
" I'll sync the results and update the docs when the run finishes.",
'✻ Crunched for 7m 55s · done 4:22 PM',
'✔ Update installed · /restart to apply',
'❯',
' Sonnet 5.5 in:169,629 out:751 ctx:17% cache:until 17:22',
' ⏵⏵ bypass permissions on (shift+tab to cycle)',
].join('\n');
expect(screenTail(idle, 14, 900, '❯')).toBe(
[
" I'll sync the results and update the docs when the run finishes.",
'✻ Crunched for 7m 55s · done 4:22 PM',
'✔ Update installed · /restart to apply',
].join('\n')
);
const dialog = ['Do you want to proceed?', '❯ 1. Yes', ' 2. No', 'Esc to cancel'].join('\n');
expect(screenTail(dialog, 14, 900, '❯')).toBe(dialog);
expect(screenTail(idle)).toContain('❯'); // no glyph given: nothing is cut
});
it('keeps the last lines of a rendered screen and drops rules, blanks and escape codes', () => {
const screen = [
'old line',
+41
View File
@@ -22,6 +22,7 @@ type Internals = {
discordBridge: Record<string, ReturnType<typeof vi.fn>>;
sessions: Map<string, unknown>;
mux: { capturePaneText?: (name: string) => string | null };
lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>;
sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>;
broadcast: (event: string, data: unknown) => void;
cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>;
@@ -88,6 +89,46 @@ describe('sendPushNotifications -> Discord', () => {
expect(sendNotification).not.toHaveBeenCalled();
});
it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => {
const s = server();
s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' });
const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two' }));
s.lastResponseFor = reader;
s.mux = { capturePaneText: () => 'screen\n❯\n status' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' });
const idle = s.discordBridge.notify.mock.calls[0][0];
expect(await idle.fullText()).toBe('The full answer.\n\n- one\n- two');
expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut
await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' });
const dialog = s.discordBridge.notify.mock.calls[1][0];
expect(dialog.fullText).toBeUndefined();
expect(dialog.tail()).toBe('screen\n❯\n status');
});
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])(
'a finished turn in a %s tab posts the whole last message',
async (mode) => {
const s = server();
s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode });
s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' }));
s.mux = { capturePaneText: () => 'screen' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(await msg.fullText()).toBe('1. First?\n2. Second?\n3. Third?');
}
);
it('a shell tab has no turns, so it keeps the screen tail', async () => {
const s = server();
s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' });
s.lastResponseFor = vi.fn(async () => ({ text: 'x' }));
s.mux = { capturePaneText: () => 'prompt$' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(await msg.fullText()).toBeUndefined();
expect(msg.tail()).toBe('prompt$');
});
it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => {
const s = server();
s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' });
@@ -0,0 +1,196 @@
/**
* @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the
* Discord bridge posts from), with a message that asks SEVERAL questions: every question must come
* back whole, in order, on its own line, and survive the split into Discord posts.
*
* Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts.
* Port: N/A
*
* Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode,
* gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own
* transcript formats and are covered by their reader's tests.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { splitForDiscord } from '../../src/discord-bridge.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
const QUESTIONS = [
'Should the sweep run daily or hourly?',
'Do you want archived threads deleted as well?',
'Is one channel per Codeman acceptable?',
];
const INTRO = 'I have three questions before I continue:';
const OUTRO = 'Once you answer, I will apply the change.';
/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */
const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n');
/** Every question present, in order, each on a line of its own. */
function expectWholeQuestions(text: string): void {
let from = 0;
for (const q of QUESTIONS) {
const at = text.indexOf(q, from);
expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from);
from = at + q.length;
}
const lines = text.split('\n').map((l) => l.trim());
for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true);
expect(text).toContain(INTRO);
expect(text).toContain(OUTRO);
// What goes to Discord: the same questions after the split, joined back.
expectWholeQuestionsAfterSplit(text);
}
function expectWholeQuestionsAfterSplit(text: string): void {
const joined = splitForDiscord(text).join('\n');
for (const q of QUESTIONS) expect(joined).toContain(q);
}
async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
app.addHook('preSerialization', (req, _reply, payload: unknown, done) => {
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown };
return done(null, p.success === undefined ? { success: true, data: payload } : payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('the last message with several questions, per CLI', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
let home: string;
let prevHome: string | undefined;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any;
async function lastText(): Promise<string> {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` });
return JSON.parse(res.body).data.text as string;
}
beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-'));
prevHome = process.env.HOME;
prevCodexHome = process.env.CODEX_HOME;
process.env.HOME = home;
process.env.CODEX_HOME = join(home, '.codex');
harness = await createHarness();
session = harness.ctx._session;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
rmSync(home, { recursive: true, force: true });
await harness.app.close();
});
it('claude: reads the whole message from the JSONL transcript', async () => {
const id = '40568a29-d4eb-4eb6-b671-8401428e4f39';
session.mode = 'claude';
session.claudeSessionId = id;
session.adoptClaudeSessionId = () => undefined;
const dir = join(home, '.claude', 'projects', '-workspace');
mkdirSync(dir, { recursive: true });
const row = (type: string, text: string, ts: string) => ({
type,
timestamp: ts,
message: { content: [{ type: 'text', text }] },
});
writeFileSync(
join(dir, `${id}.jsonl`),
[row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')]
.map((e) => JSON.stringify(e))
.join('\n')
);
const text = await lastText();
expect(text).toBe(MESSAGE);
expectWholeQuestions(text);
});
it('codex: reads the whole message from the rollout', async () => {
const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const workdir = join(home, 'work');
mkdirSync(workdir, { recursive: true });
session.mode = 'codex';
session.workingDir = workdir;
session.codexConfig = { resumeSessionId: uuid };
const dir = join(home, '.codex', 'sessions', '2026', '10', '30');
mkdirSync(dir, { recursive: true });
const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`);
const entries = [
{
timestamp: '2026-10-30T00:00:00Z',
type: 'session_meta',
payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' },
},
{
timestamp: '2026-10-30T00:00:01Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'plan the cleanup' },
},
{
timestamp: '2026-10-30T00:00:02Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] },
},
];
writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
utimesSync(file, 1_750_000_000, 1_750_000_000);
const text = await lastText();
expectWholeQuestions(text);
});
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`${mode}: reads the whole message from the pane`, async () => {
session.mode = mode;
session.terminalBuffer = [
'› plan the cleanup',
'',
'model · project · main · Ready · Context 100% left',
'',
'• Called write_file',
'',
MESSAGE,
].join('\n');
expectWholeQuestions(await lastText());
});
}
it('copilot: reads the whole message from the pane screen', async () => {
session.mode = 'copilot';
const screen = [
' ❯ plan the cleanup 10:01',
'',
` ● ${INTRO}`,
'',
...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`),
'',
` ${OUTRO}`,
'',
' ~/project Session: 1 AIC used',
'────────────────────────────────',
' ❯ ',
'────────────────────────────────',
' ← open sidebar · / commands · ? help',
].join('\n');
harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux;
expectWholeQuestions(await lastText());
});
});
+6 -2
View File
@@ -72,9 +72,13 @@ describe('/api/discord', () => {
it('keeps the token when only an option changes, and clears it on an empty string', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
await app.inject({ method: 'PUT', url: '/api/discord', payload: { pingMode: 'all', onSessionDelete: 'delete' } });
await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' },
});
const cfg = await readDiscordConfig(dir);
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete' });
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' });
const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } });
expect(cleared.json().data.hasToken).toBe(false);
bridge.stop();