feat(discord): codeman notify, startup hint for every CLI, and file send/receive

Adds the codeman notify command, a startup hint (Claude via --append-system-prompt, other CLIs once on first idle), files saved from thread attachments into the tab folder, and --file uploads limited to the session folder.
This commit is contained in:
Devvyn committed 2026-10-29 21:27:23 +08:00
1 parent 61bf26cfdf
commit 1c4338e8b5
16 files changed
+946 -244

No files matched your search

+83
View File
@@ -92,6 +92,89 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin
});
}
/** POST JSON to the Codeman API with the same credentials `attach` uses. Resolves to the HTTP status, or 0. */
async function postJson(
apiUrl: string,
apiPath: string,
payload: unknown
): Promise<{ status: number; error?: string }> {
const { username, password } = readCodemanCredentials();
const url = new URL(apiPath, apiUrl);
const body = JSON.stringify(payload);
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
const headers: Record<string, string | number> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
};
if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: 'POST',
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
},
(res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('end', () => {
let error: string | undefined;
try {
error = (JSON.parse(text) as { error?: string }).error;
} catch {
/* not JSON */
}
resolve({ status: res.statusCode ?? 0, error });
});
}
);
req.on('error', () => resolve({ status: 0 }));
req.write(body);
req.end();
});
}
program
.command('notify <message>')
.description(
"Post a message to this tab's Discord thread (needs the user: a physical step, a blocker, a timed-out wait)"
)
.option('--urgent', 'Ping the user')
.option('--file <path>', 'Send a file from the session folder with the message')
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL)')
.action(async (message: string, options) => {
const sessionId = options.session || process.env.CODEMAN_SESSION_ID;
const apiUrl = options.url || process.env.CODEMAN_API_URL;
if (!sessionId || !apiUrl) {
console.error(palette.err('✗ notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)'));
process.exit(1);
}
const payload: { message: string; urgent?: boolean; file?: string } = { message };
if (options.urgent) payload.urgent = true;
if (options.file) payload.file = isAbsolute(options.file) ? options.file : join(process.cwd(), options.file);
const { status, error } = await postJson(
apiUrl,
`/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
payload
);
if (status >= 200 && status < 300) {
console.log(palette.ok('✓ Posted to Discord'));
} else if (status === 409) {
console.log('The Discord bridge is off. Say it in the terminal instead.');
} else {
console.error(
palette.err(`✗ Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}`)
);
process.exit(1);
}
});
program
.command('attach <path>')
.description('Show an attachment card for a local file')
+2
View File
@@ -80,6 +80,7 @@ const paramSpecSchema = z.union([
'muxName',
'effortLevel',
'effortSettingsJson',
'appendSystemPrompt',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
@@ -224,6 +225,7 @@ const envExportSchema = z
'muxName',
'effortLevel',
'effortSettingsJson',
'appendSystemPrompt',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
+13
View File
@@ -152,6 +152,7 @@ const CLAUDE: CliEntry = {
// from the EFFORT_LEVELS allowlist upstream, same as every other engine value).
effortLevel: { type: 'engine', source: 'effortLevel' },
effortJson: { type: 'engine', source: 'effortSettingsJson' },
discordHint: { type: 'engine', source: 'appendSystemPrompt' },
sessionId: { type: 'engine', source: 'sessionId' },
sessionName: { type: 'engine', source: 'sessionName' },
},
@@ -179,6 +180,12 @@ const CLAUDE: CliEntry = {
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
{
flag: '--append-system-prompt',
valueFrom: 'discordHint',
quote: 'single',
when: { param: 'discordHint', state: 'set' },
},
],
},
{
@@ -203,6 +210,12 @@ const CLAUDE: CliEntry = {
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
{
flag: '--append-system-prompt',
valueFrom: 'discordHint',
quote: 'single',
when: { param: 'discordHint', state: 'set' },
},
],
},
],
+2
View File
@@ -29,6 +29,8 @@ export type EngineValue =
| 'muxName'
| 'effortLevel'
| 'effortSettingsJson'
/** The Discord bridge's startup hint, passed to Claude as `--append-system-prompt`. */
| 'appendSystemPrompt'
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
| 'codemanPrefixedSessionId'
/**
+148 -9
View File
@@ -60,6 +60,8 @@ export interface DiscordConfig {
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;
}
export const DEFAULT_DISCORD_CONFIG: DiscordConfig = {
@@ -72,6 +74,7 @@ export const DEFAULT_DISCORD_CONFIG: DiscordConfig = {
onSessionDelete: 'archive',
pingMode: 'critical',
scope: 'attention',
startupHint: true,
};
export interface DiscordMessage {
@@ -84,6 +87,13 @@ export interface DiscordMessage {
/** The tail of what the CLI last printed, so the reader can see what it is asking. */
tail?: string;
host?: string;
/** A file to upload with the post (already read and size-checked by the caller). */
file?: DiscordFile;
}
export interface DiscordFile {
name: string;
data: Uint8Array;
}
export interface DiscordResult {
@@ -122,11 +132,15 @@ export function maskToken(token: string): string {
const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
/** A Discord reply as one printable line plus Enter, or null when nothing printable is left. */
export function sanitiseReply(raw: string): string | null {
export function sanitiseReply(raw: string, files: string[] = []): string | null {
// eslint-disable-next-line no-control-regex
const line = raw.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
const trimmed = clip(line.trim(), MAX_REPLY_CHARS);
return trimmed ? `${trimmed}\r` : null;
// Paths are ours (a working folder plus a sanitised name), appended after the cap so they are never cut.
const attached = files.length ? `${trimmed ? ' ' : ''}[attached: ${files.join(', ')}]` : '';
// eslint-disable-next-line no-control-regex
const out = `${trimmed}${attached}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
return out ? `${out}\r` : null;
}
export function threadNameFor(sessionName: string | undefined, sessionId: string): string {
@@ -145,6 +159,47 @@ export function shouldSendDiscord(cfg: DiscordConfig, urgency: WebhookUrgency):
return cfg.scope === 'all' || urgency !== 'info';
}
/** Largest single file taken from Discord, or sent to it. Discord's own free limit is higher. */
export const MAX_FILE_BYTES = 8 * 1024 * 1024;
const MAX_FILES_PER_REPLY = 5;
const DOWNLOAD_TIMEOUT_MS = 30_000;
/** Where a session's received files go, relative to its working folder. */
export const RECEIVED_DIR = '.codeman-discord';
/**
* What a session is told about the bridge. Claude gets it as `--append-system-prompt`; every other
* CLI is sent it once as its first message (see DiscordBridge.sessionIdle). No apostrophes: it is
* rendered into a shell command.
*/
export const DISCORD_STARTUP_HINT =
'Codeman note: if you are blocked on something only the user can do (a physical step such as ' +
'pressing a button, an answer you cannot work out, or a wait that timed out), run: ' +
'codeman notify "message that leads with the action" --urgent . The user reads a Discord thread for ' +
'this tab and their reply is typed to you as one line; a file they attach is saved and its path is ' +
'given in that line. Add --file <path> to send a file from your working folder. Not for routine progress.';
let activeStartupHint: string | undefined;
/** The hint to launch Claude with, or undefined while the bridge or the setting is off. */
export function getDiscordStartupHint(): string | undefined {
return activeStartupHint;
}
/** 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(/^[._]+/, '');
return clip(base, 80) || 'file';
}
/** Discord's own CDN over https: the only place an attachment URL may point. */
export function isDiscordCdnUrl(raw: string): boolean {
try {
const u = new URL(raw);
return u.protocol === 'https:' && /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/.test(u.hostname);
} catch {
return false;
}
}
export interface DiscordPost {
content: string;
allowed_mentions: { parse: string[]; users: string[] };
@@ -187,6 +242,7 @@ function coerce(raw: unknown): DiscordConfig {
onSessionDelete: oneOf(DISCORD_ON_DELETE, r.onSessionDelete, d.onSessionDelete),
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,
};
}
@@ -256,14 +312,16 @@ export class DiscordApi {
) {}
async request<T = unknown>(method: string, path: string, body?: unknown, retried = false): Promise<T | null> {
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
const res = await this.fetchImpl(new URL(`${API}${path}`), {
method,
headers: {
Authorization: `Bot ${this.token()}`,
'Content-Type': 'application/json',
// A form body sets its own multipart Content-Type (with the boundary).
...(isForm ? {} : { 'Content-Type': 'application/json' }),
'User-Agent': 'DiscordBot (https://github.com/Ark0N/Codeman, 1)',
},
body: body === undefined ? undefined : JSON.stringify(body),
body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body),
redirect: 'manual',
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
@@ -287,6 +345,12 @@ export class DiscordApi {
post(channelId: string, p: DiscordPost): Promise<{ id: string } | null> {
return this.request('POST', `/channels/${channelId}/messages`, p);
}
postWithFile(channelId: string, p: DiscordPost, file: DiscordFile): Promise<{ id: string } | null> {
const form = new FormData();
form.append('payload_json', JSON.stringify(p));
form.append('files[0]', new Blob([Buffer.from(file.data)]), safeAttachmentName(file.name));
return this.request('POST', `/channels/${channelId}/messages`, form);
}
rename(threadId: string, name: string): Promise<unknown> {
return this.request('PATCH', `/channels/${threadId}`, { name });
}
@@ -314,6 +378,7 @@ export interface GatewayMessage {
guild_id?: string;
content: string;
author: { id: string; bot?: boolean };
attachments?: Array<{ id: string; filename: string; size: number; url: string }>;
}
/** The subset of `ws` the gateway uses, so tests can drive it without a socket. */
@@ -501,6 +566,8 @@ export interface DiscordBridgeDeps {
fetchImpl: DiscordFetch;
/** Types `text` (already ending in Enter) into the session. False when the session is gone. */
sendInput: (sessionId: string, text: string) => Promise<boolean>;
/** A session's working folder, where received files are saved. Undefined when it is gone or remote. */
sessionDir?: (sessionId: string) => string | undefined;
socketFactory?: SocketFactory;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
@@ -516,6 +583,8 @@ export class DiscordBridge {
private lastReply = new Map<string, number>();
/** The last name seen per session, so only a real rename touches the thread. */
private names = new Map<string, string>();
/** Brand-new sessions that have not yet been sent the startup hint (non-Claude CLIs). */
private fresh = new Set<string>();
private pending = new Map<string, Promise<string | null>>();
private last: DiscordResult | null = null;
private readonly api: DiscordApi;
@@ -545,6 +614,7 @@ export class DiscordBridge {
}
this.gateway?.stop();
this.gateway = null;
activeStartupHint = this.cfg.enabled && this.cfg.token && this.cfg.startupHint ? DISCORD_STARTUP_HINT : undefined;
if (this.cfg.enabled && this.cfg.token) {
this.gateway = new DiscordGateway(
() => this.cfg.token,
@@ -559,6 +629,7 @@ export class DiscordBridge {
stop(): void {
this.gateway?.stop();
this.gateway = null;
activeStartupHint = undefined;
}
private fail(err: unknown): DiscordResult {
@@ -623,15 +694,16 @@ export class DiscordBridge {
if (!thread) return this.last;
target = thread;
}
const send = (to: string) => (msg.file ? this.api.postWithFile(to, post, msg.file) : this.api.post(to, post));
try {
await this.api.post(target, post);
await send(target);
} catch (err) {
// The thread was deleted by hand: forget it, make a new one, once.
if (msg.sessionId && err instanceof DiscordHttpError && err.status === 404) {
delete this.threads[msg.sessionId];
const fresh = await this.threadFor(msg.sessionId, msg.sessionName);
if (!fresh) return this.last;
await this.api.post(fresh, post);
await send(fresh);
} else {
throw err;
}
@@ -669,12 +741,25 @@ export class DiscordBridge {
}
/** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */
async sessionCreated(sessionId: string, name: string | undefined): Promise<void> {
async sessionCreated(sessionId: string, name: string | undefined, createdAt?: number): Promise<void> {
if (!this.loaded) await this.reload();
// Only a tab made just now: the same event also fires for sessions restored at server start.
if (createdAt !== undefined && this.now() - createdAt < 60_000) this.fresh.add(sessionId);
if (!this.cfg.enabled || this.cfg.threadCreation !== 'onSessionStart') return;
await this.threadFor(sessionId, name);
}
/**
* First idle of a brand-new non-Claude session means its prompt is up: type the startup hint once.
* Claude gets the same text as a launch flag instead, and a shell must never receive it.
*/
async sessionIdle(sessionId: string, external: boolean): Promise<void> {
if (!this.fresh.delete(sessionId)) return;
if (!external || !this.cfg.enabled || !this.cfg.startupHint) return;
const text = sanitiseReply(`${DISCORD_STARTUP_HINT} Reply with just: ok`);
if (text) await this.deps.sendInput(sessionId, text).catch(() => false);
}
async sessionRenamed(sessionId: string, name: string | undefined): Promise<void> {
if (this.names.get(sessionId) === (name ?? '')) return;
this.names.set(sessionId, name ?? '');
@@ -696,6 +781,55 @@ export class DiscordBridge {
else if (this.cfg.onSessionDelete === 'archive') await this.api.archive(thread).catch(() => undefined);
}
private say(channelId: string, content: string): Promise<unknown> {
return this.api.post(channelId, { content, allowed_mentions: { parse: [], users: [] } }).catch(() => undefined);
}
/**
* Save a reply's attachments under the session's working folder and return the paths. Only Discord
* CDN URLs, at most 5 files of 8 MB each, names reduced to safe characters. Anything skipped is
* named in `refused`, so the person is not left guessing why a file did not arrive.
*/
private async saveAttachments(sessionId: string, m: GatewayMessage): Promise<{ paths: string[]; refused?: string }> {
const list = m.attachments ?? [];
if (list.length === 0) return { paths: [] };
const dir = this.deps.sessionDir?.(sessionId);
if (!dir) return { paths: [], refused: 'That tab has no local folder to save files in, so no file was saved.' };
const target = join(dir, RECEIVED_DIR);
const paths: string[] = [];
const skipped: string[] = [];
for (const [i, a] of list.entries()) {
const name = safeAttachmentName(a.filename ?? 'file');
if (i >= MAX_FILES_PER_REPLY || !isDiscordCdnUrl(a.url) || !(a.size <= MAX_FILE_BYTES)) {
skipped.push(name);
continue;
}
try {
const res = await this.deps.fetchImpl(new URL(a.url), {
redirect: 'manual',
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
const buf = res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
if (!buf || buf.byteLength > MAX_FILE_BYTES) {
skipped.push(name);
continue;
}
await fs.mkdir(target, { recursive: true });
const dest = join(target, `${m.id}-${name}`);
await fs.writeFile(dest, buf, { mode: 0o600 });
paths.push(dest);
} catch {
skipped.push(name);
}
}
return {
paths,
refused: skipped.length
? `Not saved (over 8 MB, more than ${MAX_FILES_PER_REPLY} files, or not downloadable): ${skipped.join(', ')}`
: undefined,
};
}
/** Inbound reply. Every gate here drops silently: a stranger learns nothing. */
async onGatewayMessage(m: GatewayMessage): Promise<void> {
if (!this.cfg.enabled || m.author?.bot) return;
@@ -707,8 +841,13 @@ export class DiscordBridge {
const prev = this.lastReply.get(sessionId);
if (prev !== undefined && t - prev < REPLY_MIN_INTERVAL_MS) return;
this.lastReply.set(sessionId, t);
const text = sanitiseReply(m.content ?? '');
if (!text) return;
const saved = await this.saveAttachments(sessionId, m);
const text = sanitiseReply(m.content ?? '', saved.paths);
if (!text) {
if (saved.refused) await this.say(m.channel_id, saved.refused);
return;
}
if (saved.refused) await this.say(m.channel_id, saved.refused);
const ok = await this.deps.sendInput(sessionId, text).catch(() => false);
const reply = ok
? this.api.react(m.channel_id, m.id, '✅')
+5
View File
@@ -17,6 +17,7 @@
* @module session-cli-registry-bridge
*/
import { getDiscordStartupHint } from './discord-bridge.js';
import type { CliEntry } from './config/cli-registry/types.js';
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
@@ -248,6 +249,10 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
}
// The Discord bridge's startup hint: undefined (so the flag is omitted) unless the bridge and its
// `startupHint` setting are on. Claude-only in practice, like the settings object above.
engineValues.appendSystemPrompt = getDiscordStartupHint();
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
// "known to be unresolvable" and must not probe. The probe only ever runs from
+7
View File
@@ -3286,6 +3286,13 @@
<option value="all">Every post</option>
</select>
</div>
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Tell each CLI how to reach you</span>
<span class="set-row-desc">Claude is told at launch. Other CLIs are sent one short message when a new tab first becomes idle. It explains the codeman notify command, for when a CLI is blocked on something only you can do. Never sent to shell tabs.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="discordStartupHint"><span class="slider"></span></label>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Which events</span>
+2
View File
@@ -1769,6 +1769,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('discordOnSessionDelete').value = d.onSessionDelete;
document.getElementById('discordPingMode').value = d.pingMode;
document.getElementById('discordScope').value = d.scope;
document.getElementById('discordStartupHint').checked = d.startupHint !== false;
const clearBtn = document.getElementById('discordClearBtn');
if (clearBtn) clearBtn.style.display = d.hasToken ? '' : 'none';
const gw = { off: 'Not connected (disabled).', connecting: 'Connecting to Discord…', connected: 'Connected: replies are being read.', error: `Not connected: ${d.gatewayError || 'error'}` };
@@ -1792,6 +1793,7 @@ Object.assign(CodemanApp.prototype, {
onSessionDelete: document.getElementById('discordOnSessionDelete').value,
pingMode: document.getElementById('discordPingMode').value,
scope: document.getElementById('discordScope').value,
startupHint: document.getElementById('discordStartupHint').checked,
};
const token = document.getElementById('discordToken').value.trim();
if (token) payload.token = token; // blank = keep the saved one (Remove token is the way to clear it)
+40 -1
View File
@@ -11,6 +11,8 @@
* events. `discord-notify` follows the session's own ownership rules instead.
*/
import { readFile, realpath, stat } from 'node:fs/promises';
import { basename, isAbsolute, relative, sep } from 'node:path';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { findSessionOrFail, isAdmin, parseBody } from '../route-helpers.js';
@@ -19,6 +21,7 @@ import { isMultiUserMode } from '../../config/multiuser.js';
import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js';
import {
discordConfigProblem,
MAX_FILE_BYTES,
maskToken,
readDiscordConfig,
writeDiscordConfig,
@@ -43,6 +46,32 @@ export type DiscordStatus = Omit<DiscordConfig, 'token'> & {
lastResult: DiscordResult | null;
};
/**
* A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks
* followed) lies inside the session's working folder. Anything else is refused, so a CLI cannot be
* talked into posting ~/.codeman/discord.json or an ssh key. Returns the bytes, or the reason.
*/
async function fileProblem(
workingDir: string,
remote: boolean,
file: string
): Promise<{ name: string; data: Uint8Array } | string> {
if (remote) return 'Files can only be sent from a local session';
if (!isAbsolute(file)) return 'The file path must be absolute';
try {
const [root, real] = await Promise.all([realpath(workingDir), realpath(file)]);
const rel = relative(root, real);
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
return 'The file must be inside the session folder';
const info = await stat(real);
if (!info.isFile()) return 'Not a regular file';
if (info.size > MAX_FILE_BYTES) return `The file is over ${MAX_FILE_BYTES / 1024 / 1024} MB`;
return { name: basename(real), data: new Uint8Array(await readFile(real)) };
} catch {
return 'The file could not be read';
}
}
export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
@@ -109,8 +138,17 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe
app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => {
const { id } = req.params as { id: string };
const { message, urgent } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message');
const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message');
const session = findSessionOrFail(deps.ctx, id, req);
let upload: { name: string; data: Uint8Array } | undefined;
if (file !== undefined) {
const problem = await fileProblem(session.workingDir, !!session.remote, file);
if (typeof problem === 'string') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
upload = problem;
}
const result = await deps.bridge.notify(
{
event: 'session:discord-notify',
@@ -120,6 +158,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe
sessionId: session.id,
sessionName: session.name,
host: deps.hostTitle(),
file: upload,
},
urgent === true
);
+3
View File
@@ -2231,6 +2231,7 @@ export const DiscordUpdateSchema = z
onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(),
pingMode: z.enum(DISCORD_PING_MODES).optional(),
scope: z.enum(WEBHOOK_SCOPES).optional(),
startupHint: z.boolean().optional(),
})
.strict();
@@ -2239,6 +2240,8 @@ export const DiscordNotifySchema = z
.object({
message: z.string().min(1).max(1500),
urgent: z.boolean().optional(),
/** Absolute path of a file inside the session's working folder, uploaded with the post. */
file: z.string().min(1).max(1024).optional(),
})
.strict();
+32 -6
View File
@@ -519,6 +519,11 @@ export class WebServer extends EventEmitter {
if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true;
return session.write(text, { fromUser: true });
},
// Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go.
sessionDir: (sessionId) => {
const session = this.sessions.get(sessionId);
return session && !session.remote ? session.workingDir : undefined;
},
});
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
@@ -2723,20 +2728,41 @@ export class WebServer extends EventEmitter {
this.bridgeSessionEvent(event, data);
}
private isExternalSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
return !!session && isExternalCliMode(session.mode);
}
/** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */
private bridgeSessionEvent(event: string, data: unknown): void {
if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated && event !== SseEvent.SessionDeleted) {
if (
event !== SseEvent.SessionCreated &&
event !== SseEvent.SessionUpdated &&
event !== SseEvent.SessionDeleted &&
event !== SseEvent.SessionIdle
) {
return;
}
const d = (data ?? {}) as { id?: string; name?: string; session?: { id?: string; name?: string } };
const d = (data ?? {}) as {
id?: string;
name?: string;
createdAt?: number;
session?: { id?: string; name?: string; createdAt?: number };
};
const s = d.session ?? d;
if (!s.id) return;
const id = s.id;
const run =
event === SseEvent.SessionDeleted
? this.discordBridge.sessionDeleted(s.id)
: event === SseEvent.SessionCreated
? this.discordBridge.sessionCreated(s.id, s.name).then(() => this.discordBridge.sessionRenamed(s.id!, s.name))
: this.discordBridge.sessionRenamed(s.id, s.name);
? this.discordBridge.sessionDeleted(id)
: event === SseEvent.SessionIdle
? // Claude is told at launch; a shell must never be typed at.
this.discordBridge.sessionIdle(id, this.isExternalSession(id))
: event === SseEvent.SessionCreated
? this.discordBridge
.sessionCreated(id, s.name, s.createdAt)
.then(() => this.discordBridge.sessionRenamed(id, s.name))
: this.discordBridge.sessionRenamed(id, s.name);
void run.catch(() => undefined);
}