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

+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