mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
1 parent
d6c7d30bd4
commit
093f242662
18 files changed
+779
-97
No files matched your search
+195
-41
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user