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

+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