mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge pull request #741: feat(discord): two-way Discord bridge with a thread per tab
This commit is contained in:
commit
7c8d440928
39 files changed
+4940
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -3252,6 +3252,104 @@
|
||||
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-group" id="discordGroup" style="display:none">
|
||||
<div class="set-group-head"><h4>Discord (two-way, one thread per tab)</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="discord bot thread reply phone notification blocked">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Talk to your sessions from Discord</span>
|
||||
<span class="set-row-desc">Posts alerts into a thread per tab and types your reply in that thread into the tab. Only the user IDs listed below can reply. Setup (bot, least-privilege permissions) is in docs/discord-bridge.md. This group has its own Save button.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Bot token</span>
|
||||
<span class="set-row-desc" id="discordTokenHint">Nothing saved yet.</span>
|
||||
</div>
|
||||
<input type="password" id="discordToken" class="set-input" autocomplete="off" spellcheck="false" placeholder="Bot token from the Developer Portal">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Guild (server) ID</span></div>
|
||||
<input type="text" id="discordGuildId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Parent channel ID</span><span class="set-row-desc">Threads are created inside this channel.</span></div>
|
||||
<input type="text" id="discordParentChannelId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Allowed user IDs</span><span class="set-row-desc">Comma separated. Only these Discord users can reply into a session.</span></div>
|
||||
<input type="text" id="discordAllowedUserIds" class="set-input" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Create threads</span></div>
|
||||
<select id="discordThreadCreation" class="set-select">
|
||||
<option value="lazy">On the first alert</option>
|
||||
<option value="onSessionStart">For every new tab</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">When a tab is deleted</span><span class="set-row-desc">Deleting a thread needs the Manage Threads permission on the channel.</span></div>
|
||||
<select id="discordOnSessionDelete" class="set-select">
|
||||
<option value="keep">Keep the thread</option>
|
||||
<option value="archive">Archive the thread</option>
|
||||
<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. This includes threads that "When a tab is deleted" kept or archived. 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">
|
||||
<option value="off">Never</option>
|
||||
<option value="critical">Critical events only</option>
|
||||
<option value="all">Every post</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tell Claude how to reach you</span>
|
||||
<span class="set-row-desc">Adds a short note about the codeman notify command to Claude's system prompt at launch, for when it is blocked on something only you can do. Nothing appears in the conversation. Local tabs only: remote and Docker tabs have no codeman command.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordStartupHint"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Also tell other CLIs, as a first message</span>
|
||||
<span class="set-row-desc">Sends the same note once as a message to a brand-new Codex, OpenCode or similar tab when it first becomes idle. Costs one model turn per tab, and for a spawned worker or a scheduled run it arrives with the first real prompt. Never sent to shell, restored, remote or Docker tabs. Off by default.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordTypedHint"><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>
|
||||
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
|
||||
</div>
|
||||
<select id="discordScope" class="set-select">
|
||||
<option value="attention">Needs attention</option>
|
||||
<option value="all">Everything</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Save and test</span>
|
||||
<span class="set-row-desc">Send test saves first, then posts to the parent channel.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="discordSaveBtn" onclick="app.saveDiscord()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordTestBtn" onclick="app.testDiscord()">Send test</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordClearBtn" onclick="app.clearDiscord()" style="display:none">Remove token</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="discordResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Voice ════════════════════════════════════════════════════ -->
|
||||
|
||||
@@ -570,6 +570,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.loadConfigBackups();
|
||||
this._applyDoctorAdminGate();
|
||||
this.loadWebhook();
|
||||
this.loadDiscord();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -1733,6 +1734,117 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Discord bridge (Settings → Notifications). Server-side config behind /api/discord, not a
|
||||
* settings-payload field: the bot token is a secret, so it never round-trips through settings.json
|
||||
* or this page. The token box is write-only. Unlike the webhook group it has its own Save button
|
||||
* only (the modal's main Save does not touch it), so a half-typed token is never sent by accident.
|
||||
*/
|
||||
_discordSay(text, bad = false) {
|
||||
const out = document.getElementById('discordResult');
|
||||
if (!out) return;
|
||||
out.textContent = text;
|
||||
out.style.display = text ? 'block' : 'none';
|
||||
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
|
||||
},
|
||||
|
||||
async loadDiscord() {
|
||||
const group = document.getElementById('discordGroup');
|
||||
if (!group) return;
|
||||
const res = await this._api('/api/discord');
|
||||
let body = null;
|
||||
try { body = res && res.ok ? await res.json() : null; } catch { /* leave hidden */ }
|
||||
if (!body || body.success === false) { group.style.display = 'none'; return; } // not an admin, or an older server
|
||||
const d = body.data;
|
||||
group.style.display = '';
|
||||
document.getElementById('discordEnabled').checked = d.enabled === true;
|
||||
const token = document.getElementById('discordToken');
|
||||
token.value = '';
|
||||
token.placeholder = d.hasToken ? 'Saved. Paste a new token to replace it' : 'Bot token from the Developer Portal';
|
||||
document.getElementById('discordTokenHint').textContent = d.hasToken ? `Saved: ${d.tokenMasked}` : 'Nothing saved yet.';
|
||||
document.getElementById('discordGuildId').value = d.guildId || '';
|
||||
document.getElementById('discordParentChannelId').value = d.parentChannelId || '';
|
||||
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;
|
||||
document.getElementById('discordTypedHint').checked = d.typedHint === true;
|
||||
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'}` };
|
||||
let line = gw[d.gateway] || '';
|
||||
if (d.lastResult) {
|
||||
const when = new Date(d.lastResult.at).toLocaleString();
|
||||
line += d.lastResult.ok ? ` Last post succeeded (${when}).` : ` Last post failed (${when}): ${d.lastResult.error}`;
|
||||
}
|
||||
this._discordSay(line.trim(), d.gateway === 'error' || (d.lastResult && !d.lastResult.ok));
|
||||
},
|
||||
|
||||
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
|
||||
async saveDiscord() {
|
||||
const ids = document.getElementById('discordAllowedUserIds').value.split(/[\s,]+/).filter(Boolean);
|
||||
const payload = {
|
||||
enabled: document.getElementById('discordEnabled').checked,
|
||||
guildId: document.getElementById('discordGuildId').value.trim(),
|
||||
parentChannelId: document.getElementById('discordParentChannelId').value.trim(),
|
||||
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,
|
||||
typedHint: document.getElementById('discordTypedHint').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)
|
||||
const res = await this._api('/api/discord', { method: 'PUT', body: payload });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
const error = body?.error || 'Could not save the Discord settings.';
|
||||
this._discordSay(error, true);
|
||||
return error;
|
||||
}
|
||||
await this.loadDiscord();
|
||||
return '';
|
||||
},
|
||||
|
||||
async clearDiscord() {
|
||||
if (!confirm('Remove the saved bot token from the server? The Discord bridge stops until you save a new one.')) return;
|
||||
const res = await this._api('/api/discord', { method: 'PUT', body: { token: '', enabled: false } });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._discordSay(body?.error || 'Could not remove the token.', true);
|
||||
return;
|
||||
}
|
||||
await this.loadDiscord();
|
||||
},
|
||||
|
||||
async testDiscord() {
|
||||
const btn = document.getElementById('discordTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (await this.saveDiscord()) return; // saves first, so it never tests the old values; the error is shown
|
||||
this._discordSay('Sending…');
|
||||
const res = await this._apiPost('/api/discord/test', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._discordSay(body?.error || 'Could not send the test.', true);
|
||||
return;
|
||||
}
|
||||
const r = body.data;
|
||||
this._discordSay(r.ok ? 'Test sent. Check the channel.' : `Delivery failed: ${r.error}`, !r.ok);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
|
||||
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
|
||||
|
||||
@@ -150,7 +150,12 @@ export async function runConfigBackup(
|
||||
});
|
||||
}
|
||||
|
||||
export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
export interface ConfigBackupRouteDeps {
|
||||
/** Re-read discord.json into the running bridge (it caches its config and would keep the pre-restore token). */
|
||||
reloadDiscord?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBackupRouteDeps = {}): void {
|
||||
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
@@ -236,6 +241,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
if (restored.includes('intents.json')) intentStore.reload();
|
||||
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
|
||||
if (restored.includes('clis.json')) reloadCliRegistry();
|
||||
// The Discord bridge caches discord.json and rewrites nothing from it, so a restore must reach the running bridge.
|
||||
if (restored.includes('discord.json')) void deps.reloadDiscord?.().catch(() => undefined);
|
||||
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
|
||||
if (isMultiUserMode()) {
|
||||
void appendAdminAudit({
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @fileoverview Discord bridge settings and the deliberate-post route (src/discord-bridge.ts).
|
||||
*
|
||||
* GET /api/discord the config WITHOUT its token, plus gateway state and last result
|
||||
* PUT /api/discord change any field; an empty `token` clears it
|
||||
* POST /api/discord/test post one test message to the parent channel
|
||||
* POST /api/sessions/:id/discord-notify a CLI asks for the user (physical step, blocker, timed-out watch)
|
||||
*
|
||||
* The bot token is a bearer secret, so it is stored in its own 0600 file and never returned. In
|
||||
* multi-user mode the three settings routes are admin only: the server receives every session's
|
||||
* 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 { canAccessOwned, findSessionOrFail, getAuthUser, isAdmin, parseBody } from '../route-helpers.js';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
|
||||
import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js';
|
||||
import {
|
||||
discordConfigProblem,
|
||||
MAX_FILE_BYTES,
|
||||
maskToken,
|
||||
readDiscordConfig,
|
||||
writeDiscordConfig,
|
||||
type DiscordBridge,
|
||||
} from '../../discord-bridge.js';
|
||||
import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js';
|
||||
|
||||
export interface DiscordRouteDeps {
|
||||
bridge: DiscordBridge;
|
||||
configDir: string;
|
||||
ctx: SessionPort;
|
||||
hostTitle: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function resolveSessionId(ctx: SessionPort, req: FastifyRequest, id: string): string {
|
||||
if (ctx.sessions.has(id) || id.length < 8) return id;
|
||||
// Only sessions the caller may see count, so another user's session is never revealed by its prefix.
|
||||
const user = getAuthUser(req);
|
||||
const hits = [...ctx.sessions.values()].filter((s) => s.id.startsWith(id) && canAccessOwned(user, s.owner));
|
||||
return hits.length === 1 ? hits[0].id : id;
|
||||
}
|
||||
|
||||
// A loop or a prompt-injected agent must not ping the phones until Discord answers 429, nor hold 8 MB
|
||||
// uploads in memory without bound. Per session, refilled continuously (the same shape as paste-image's).
|
||||
const NOTIFY_PER_MINUTE = 6;
|
||||
const notifyBuckets = new Map<string, { tokens: number; at: number }>();
|
||||
|
||||
function consumeNotifyToken(sessionId: string, now: number = Date.now()): boolean {
|
||||
if (notifyBuckets.size > 1000) {
|
||||
for (const [k, b] of notifyBuckets) if (now - b.at > 3_600_000) notifyBuckets.delete(k);
|
||||
}
|
||||
const b = notifyBuckets.get(sessionId) ?? { tokens: NOTIFY_PER_MINUTE, at: now };
|
||||
b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + ((now - b.at) * NOTIFY_PER_MINUTE) / 60_000);
|
||||
b.at = now;
|
||||
notifyBuckets.set(sessionId, b);
|
||||
if (b.tokens < 1) return false;
|
||||
b.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Give back a token spent on a request that was refused before anything was posted. */
|
||||
function refundNotifyToken(sessionId: string): void {
|
||||
const b = notifyBuckets.get(sessionId);
|
||||
if (b) b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + 1);
|
||||
}
|
||||
|
||||
/** Test hook: forget every bucket between runs. */
|
||||
export function _resetNotifyBuckets(): void {
|
||||
notifyBuckets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
// Bounded first: realpath on a dead mount would hang the request.
|
||||
if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') {
|
||||
return describeUnknownPath('The session folder', workingDir, { pastCap: true });
|
||||
}
|
||||
// The agent chose this path: probe it bounded too, before realpath can hold a worker on a dead mount.
|
||||
const kind = await probePathKind(file, { pastCap: true });
|
||||
if (kind === 'unknown') return describeUnknownPath('The file', file, { pastCap: true });
|
||||
if (kind !== 'file') return 'Not a regular file';
|
||||
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)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const status = async (): Promise<DiscordStatus> => {
|
||||
const { token, ...rest } = await readDiscordConfig(deps.configDir);
|
||||
return {
|
||||
...rest,
|
||||
hasToken: token !== '',
|
||||
tokenMasked: maskToken(token),
|
||||
gateway: deps.bridge.gatewayState,
|
||||
gatewayError: deps.bridge.gatewayError,
|
||||
lastResult: deps.bridge.lastResult,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.put('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const patch = parseBody(DiscordUpdateSchema, req.body, 'Invalid Discord settings');
|
||||
const current = await readDiscordConfig(deps.configDir);
|
||||
const next: DiscordConfig = {
|
||||
...current,
|
||||
...patch,
|
||||
token: patch.token !== undefined ? patch.token.trim() : current.token,
|
||||
};
|
||||
const problem = discordConfigProblem(next);
|
||||
if (problem) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
try {
|
||||
await writeDiscordConfig(deps.configDir, next);
|
||||
await deps.bridge.reload();
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.post('/api/discord/test', async (req, reply): Promise<ApiResponse<DiscordResult>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const cfg = await readDiscordConfig(deps.configDir);
|
||||
if (!cfg.token || !cfg.parentChannelId) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save the bot token and parent channel ID first');
|
||||
}
|
||||
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether Discord did.
|
||||
return { success: true, data: await deps.bridge.sendTest(cfg, deps.hostTitle()) };
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => {
|
||||
const { id: rawId } = req.params as { id: string };
|
||||
const id = resolveSessionId(deps.ctx, req, rawId);
|
||||
const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message');
|
||||
const session = findSessionOrFail(deps.ctx, id, req);
|
||||
if (!consumeNotifyToken(session.id)) {
|
||||
reply.code(429);
|
||||
reply.header('Retry-After', '60');
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.RATE_LIMITED,
|
||||
`Too many messages (${NOTIFY_PER_MINUTE} per minute per session)`
|
||||
);
|
||||
}
|
||||
let upload: { name: string; data: Uint8Array } | undefined;
|
||||
if (file !== undefined) {
|
||||
const problem = await fileProblem(session.workingDir, !!session.remote, file);
|
||||
if (typeof problem === 'string') {
|
||||
refundNotifyToken(session.id);
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
upload = problem;
|
||||
}
|
||||
const result = await deps.bridge.notify(
|
||||
{
|
||||
event: 'session:discord-notify',
|
||||
title: urgent ? 'Needs you now' : 'Message from the CLI',
|
||||
body: message,
|
||||
urgency: urgent ? 'critical' : 'warning',
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
host: deps.hostTitle(),
|
||||
file: upload,
|
||||
},
|
||||
// deliberate: an agent's own message is never deduped, so a second, different post is not lost.
|
||||
{ urgent: urgent === true, deliberate: true }
|
||||
);
|
||||
if (result === null) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'The Discord bridge is not enabled');
|
||||
}
|
||||
return { success: true, data: result };
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
||||
export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js';
|
||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||
export { registerDiscordRoutes } from './discord-routes.js';
|
||||
export { registerDoctorRoutes } from './doctor-routes.js';
|
||||
export {
|
||||
registerCustomModelRoutes,
|
||||
|
||||
@@ -2920,16 +2920,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
|
||||
@@ -2948,8 +2946,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 {
|
||||
@@ -2967,7 +2964,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).
|
||||
@@ -2980,7 +2976,7 @@ export function registerSessionRoutes(
|
||||
text: getLastTranscriptResponse(blocks),
|
||||
timestamp: '',
|
||||
hasContext: blocks.length > 0,
|
||||
messages: externalQuery.context === 'full' ? blocks : undefined,
|
||||
messages: context === 'full' ? blocks : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3006,11 +3002,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) {
|
||||
@@ -3031,7 +3026,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,
|
||||
@@ -3042,6 +3037,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 {
|
||||
|
||||
@@ -20,6 +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 '../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';
|
||||
@@ -2249,6 +2250,38 @@ export const WebhookUpdateSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* PUT /api/discord. `.strict()` and every field optional, so a change of one option never needs the
|
||||
* token re-sent; an empty `token` clears it. Option lists are the bridge's own, so the schema cannot
|
||||
* accept a value the store would coerce away. Ids are checked again (as snowflakes) by the route.
|
||||
*/
|
||||
export const DiscordUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().max(200).optional(),
|
||||
guildId: z.string().max(25).optional(),
|
||||
parentChannelId: z.string().max(25).optional(),
|
||||
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(),
|
||||
typedHint: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** POST /api/sessions/:id/discord-notify */
|
||||
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();
|
||||
|
||||
// ========== Ralph Loop ==========
|
||||
|
||||
/** POST /api/ralph-loop/start */
|
||||
|
||||
+152
-2
@@ -45,6 +45,8 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from
|
||||
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';
|
||||
@@ -243,6 +245,7 @@ import {
|
||||
registerConfigBackupRoutes,
|
||||
runConfigBackup,
|
||||
registerWebhookRoutes,
|
||||
registerDiscordRoutes,
|
||||
registerDoctorRoutes,
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
@@ -511,6 +514,28 @@ export class WebServer extends EventEmitter {
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
/** 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,
|
||||
sendInput: async (sessionId, text, fromUser) => {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
// fromUser is true only for a Discord reply (a person typed it); the startup hint is Codeman's own
|
||||
// text and must not auto-name the tab or count as the person's input.
|
||||
if (await session.writeViaMux(text, { fromUser }).catch(() => false)) return true;
|
||||
return session.write(text, { fromUser });
|
||||
},
|
||||
agentExited: (id) => !!this.sessions.get(id)?.paneExit,
|
||||
awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
|
||||
// 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;
|
||||
private readonly titleHostname: string;
|
||||
@@ -1317,6 +1342,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);
|
||||
@@ -1329,12 +1356,23 @@ export class WebServer extends EventEmitter {
|
||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||
registerTabLayoutRoutes(this.app, ctx);
|
||||
registerMcpSyncRoutes(this.app);
|
||||
registerConfigBackupRoutes(this.app);
|
||||
registerConfigBackupRoutes(this.app, { reloadDiscord: () => this.discordBridge.reload() });
|
||||
registerWebhookRoutes(this.app, {
|
||||
notifier: this.webhookNotifier,
|
||||
configDir: getDataDir(),
|
||||
hostTitle: () => this.windowTitle,
|
||||
});
|
||||
registerDiscordRoutes(this.app, {
|
||||
bridge: this.discordBridge,
|
||||
configDir: getDataDir(),
|
||||
ctx,
|
||||
hostTitle: () => this.windowTitle,
|
||||
});
|
||||
if (!this.testMode) {
|
||||
// The gateway waits for restoreMuxSessions(): a reply during restore would be told the tab is gone.
|
||||
this.discordBridge.hold();
|
||||
void this.discordBridge.ensureLoaded().catch(() => undefined);
|
||||
}
|
||||
registerDoctorRoutes(this.app);
|
||||
registerCustomModelRoutes(this.app);
|
||||
registerCliRegistryRoutes(this.app);
|
||||
@@ -1809,7 +1847,11 @@ export class WebServer extends EventEmitter {
|
||||
// detach: mail posted before that is still there when the session is re-attached
|
||||
// (while detached, the routes answer 404, so nothing new arrives). Before
|
||||
// cancelAll, so a reader released by it reads an empty inbox.
|
||||
if (killMux) agentInbox.drop(sessionId);
|
||||
if (killMux) {
|
||||
agentInbox.drop(sessionId);
|
||||
// A real delete only: a detach (killMux=false) leaves the session running and re-attachable.
|
||||
void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined);
|
||||
}
|
||||
sessionWaits.notifySignal(sessionId, 'exit');
|
||||
sessionWaits.cancelAll(sessionId);
|
||||
approvalInbox.resolveForSession(sessionId, 'session_ended');
|
||||
@@ -2741,6 +2783,45 @@ export class WebServer extends EventEmitter {
|
||||
// Multi-user: derive an ownership routing hint so an event only reaches the
|
||||
// clients entitled to it (no-op in single-user — hint stays undefined).
|
||||
this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined);
|
||||
this.bridgeSessionEvent(event, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the startup hint may be typed into this tab: an external CLI (never Claude, which has it as a
|
||||
* launch flag, and never a shell), running locally. A remote (ssh) or Docker tab has no `codeman` on its
|
||||
* PATH, so the hint would tell the agent to run a command it does not have.
|
||||
*/
|
||||
private isHintableSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return !!session && isExternalCliMode(session.mode) && !session.remote && !session.docker;
|
||||
}
|
||||
|
||||
/** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */
|
||||
private bridgeSessionEvent(event: string, data: unknown): void {
|
||||
// Deletion is NOT handled here: `session:deleted` also fires for a detach (killMux=false), where the
|
||||
// session keeps running and can be re-attached. The real delete calls the bridge from the cleanup path.
|
||||
if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated && event !== SseEvent.SessionIdle) {
|
||||
return;
|
||||
}
|
||||
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.SessionIdle
|
||||
? // Claude is told at launch; a shell, a remote or a Docker tab must never be typed at.
|
||||
this.discordBridge.sessionIdle(id, this.isHintableSession(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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2987,6 +3068,27 @@ export class WebServer extends EventEmitter {
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
// 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,
|
||||
title: template.title,
|
||||
body,
|
||||
urgency: template.urgency as WebhookUrgency,
|
||||
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, !dialogEvent) : undefined,
|
||||
fullText: dcSession && !dialogEvent ? () => this.discordFullText(dcSession) : undefined,
|
||||
host: this.windowTitle,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
const subscriptions = this.pushStore.getAll();
|
||||
if (subscriptions.length === 0) return;
|
||||
|
||||
@@ -3068,6 +3170,34 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the person would see on the tab right now, for a Discord alert: the rendered tmux screen
|
||||
* (`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: Session, cutComposer: boolean): string {
|
||||
const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null;
|
||||
// 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<{ text: string; version?: 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;
|
||||
}
|
||||
const r = await this.lastResponseFor(session);
|
||||
// The turn it came from: the transcript's stamp, else when the person last submitted (a pane has no stamp).
|
||||
return { text: r.text, version: r.timestamp || String(session.lastSubmitAt ?? '') };
|
||||
}
|
||||
|
||||
/** The notification body for an event (shared by Web Push and the webhook channel). */
|
||||
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
@@ -3209,6 +3339,20 @@ export class WebServer extends EventEmitter {
|
||||
if (restoredMail > 0) console.log(`[AgentInbox] restored ${restoredMail} pending message(s)`);
|
||||
const restored = await this.restoreMuxSessions();
|
||||
await this.finalizeRestoredState(restored);
|
||||
void this.discordBridge
|
||||
.release()
|
||||
.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
|
||||
// THIS instance whose case is gone from docker-cases.json (best-effort, never
|
||||
@@ -3793,6 +3937,9 @@ export class WebServer extends EventEmitter {
|
||||
} catch (err) {
|
||||
console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
// Per-tab bridge state only: a reboot-restore rebuild reuses the old id and stays on offer for a
|
||||
// retry, so its thread must survive the discard.
|
||||
this.discordBridge.forgetTab(sessionId);
|
||||
// Any `session:updated` the half-built session emitted before it failed left a
|
||||
// tab on every other open board, and the client's handler is an upsert.
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
@@ -4365,6 +4512,9 @@ export class WebServer extends EventEmitter {
|
||||
// only by the periodic idle sweep, whose interval is disposed just below.
|
||||
closeAllLlamaSwapLogTails();
|
||||
|
||||
// Close the Discord gateway socket and its heartbeat, and clear the bridge's per-tab maps.
|
||||
this.discordBridge.stop();
|
||||
|
||||
// Dispose all managed timers (intervals + resettable timeouts)
|
||||
this.cleanup.dispose();
|
||||
|
||||
|
||||
Reference in new issue
Block a user