fix(discord): digit replies without Enter, start the gateway after restore, hide other users' session ids, fix docs

- a lone digit reply is typed without Enter while the tab has a pending permission or question
- the gateway starts after restoreMuxSessions (hold and release), so a reply during restore is not told the tab is gone
- discord-notify resolves an id prefix only among sessions the caller may see
- restore docs/architecture-invariants.md from base and add only the Discord section
- docs: self-ignoring folder, no logging, Notifications group, Send test, local-tab-only Claude hint, typedHint wording
This commit is contained in:
Devvyn committed 2026-10-30 07:58:33 +08:00
1 parent dc22519adb
commit 212989b349
10 files changed
+120 -49

No files matched your search

+20 -2
View File
@@ -648,6 +648,8 @@ export interface DiscordBridgeDeps {
/** Types `text` (already ending in Enter) into the session. False when the session is gone. */
/** `fromUser` is true only for what a person typed (a Discord reply), false for text Codeman types itself. */
sendInput: (sessionId: string, text: string, fromUser: boolean) => Promise<boolean>;
/** True while the session shows a permission or question dialog, which acts on a bare keypress. */
awaitingChoice?: (sessionId: string) => 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;
@@ -670,6 +672,8 @@ export class DiscordBridge {
private pending = new Map<string, Promise<string | DiscordResult>>();
/** The parent channel the thread map was built under. */
private threadsParent = '';
/** True from boot until the restored tabs exist (see hold). */
private held = false;
/** Thread-map writes run one after another: concurrent creations must not interleave on the tmp file. */
private persistChain: Promise<void> = Promise.resolve();
private mapLoad?: Promise<void>;
@@ -701,6 +705,18 @@ export class DiscordBridge {
}
/** (Re)read the config and start or stop the gateway to match. Call at boot and after a save. */
/** Hold the gateway back (boot): a reply must not arrive before the restored tabs exist. */
hold(): void {
this.held = true;
}
/** Boot is done: start the gateway if the bridge is on. */
async release(): Promise<void> {
if (!this.held) return;
this.held = false;
await this.reload();
}
async reload(): Promise<void> {
this.cfg = await readDiscordConfig(this.deps.configDir);
// The thread map is read once, by whoever gets here first; everyone else waits for that read. A second
@@ -723,7 +739,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) {
if (this.cfg.enabled && this.cfg.token && !this.held) {
this.gateway = new DiscordGateway(
() => this.cfg.token,
(m) => void this.onGatewayMessage(m).catch(() => undefined),
@@ -990,7 +1006,9 @@ export class DiscordBridge {
return;
}
if (saved.refused) await this.say(m.channel_id, saved.refused);
const ok = await this.deps.sendInput(sessionId, text, true).catch(() => false);
// A lone digit answers a dialog by itself: an Enter after it would land on whatever comes next.
const keys = /^\d\r$/.test(text) && this.deps.awaitingChoice?.(sessionId) ? text.slice(0, -1) : text;
const ok = await this.deps.sendInput(sessionId, keys, true).catch(() => false);
const reply = ok
? this.api.react(m.channel_id, m.id, '✅')
: this.api.post(m.channel_id, {
+1 -1
View File
@@ -3289,7 +3289,7 @@
<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.</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>
+7 -5
View File
@@ -15,7 +15,7 @@ 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';
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';
@@ -51,10 +51,12 @@ export type DiscordStatus = Omit<DiscordConfig, 'token'> & {
* 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, id: string): string {
function resolveSessionId(ctx: SessionPort, req: FastifyRequest, id: string): string {
if (ctx.sessions.has(id) || id.length < 8) return id;
const hits = [...ctx.sessions.keys()].filter((k) => k.startsWith(id));
return hits.length === 1 ? hits[0] : 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
@@ -180,7 +182,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe
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, rawId);
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)) {
+7 -1
View File
@@ -520,6 +520,7 @@ export class WebServer extends EventEmitter {
if (await session.writeViaMux(text, { fromUser }).catch(() => false)) return true;
return session.write(text, { fromUser });
},
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);
@@ -1356,7 +1357,11 @@ export class WebServer extends EventEmitter {
ctx,
hostTitle: () => this.windowTitle,
});
if (!this.testMode) void this.discordBridge.ensureLoaded().catch(() => undefined);
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);
@@ -3253,6 +3258,7 @@ 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().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