mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
1 parent
61bf26cfdf
commit
1c4338e8b5
16 files changed
+946
-244
No files matched your search
+83
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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, '✅')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user