feat: drop the typed first-message hint from the Discord bridge (#741)

Owner decision. Typing the note into a brand-new non-Claude tab spent a model
turn per tab and, for a spawned worker or a scheduled run, landed with the first
real prompt. Claude keeps its hint through the system prompt, and every other CLI
is told nothing. The setting goes with the path it gated: sessionIdle, the fresh
set, isHintableSession and the session:idle wiring. sendInput loses `fromUser`,
which was only ever false for the hint, and the deps test pins that a reply is a
person's own input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-04 17:03:14 +01:00
1 parent 7c8d440928
commit 9d04a4fb02
14 files changed
+51 -189

No files matched your search

+7 -34
View File
@@ -77,7 +77,6 @@ export const DEFAULT_DISCORD_CONFIG: DiscordConfig = {
pingMode: 'critical',
scope: 'attention',
startupHint: true,
typedHint: false,
};
export interface DiscordMessage {
@@ -169,9 +168,8 @@ const DOWNLOAD_TIMEOUT_MS = 30_000;
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, only when `typedHint` is on (see DiscordBridge.sessionIdle). No apostrophes: it is
* rendered into a shell command.
* What a session is told about the bridge. Claude gets it as `--append-system-prompt`; no other CLI
* is told. 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 ' +
@@ -399,7 +397,6 @@ function coerce(raw: unknown): DiscordConfig {
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,
typedHint: r.typedHint === true,
};
}
@@ -803,11 +800,10 @@ export interface DiscordBridgeDeps {
configDir: string;
fetchImpl: DiscordFetch;
/**
* Types `text` into the session (a line ending in Enter, or a lone digit for a dialog). False when the
* session is gone. `fromUser` is true only for what a person typed (a Discord reply), false for text
* Codeman types itself.
* Types a person's Discord reply into the session (a line ending in Enter, or a lone digit for a
* dialog). False when the session is gone.
*/
sendInput: (sessionId: string, text: string, fromUser: boolean) => Promise<boolean>;
sendInput: (sessionId: string, text: string) => Promise<boolean>;
/** True once the tab's agent has exited: tmux still accepts keys for a dead pane, so a tick would lie. */
agentExited?: (sessionId: string) => boolean;
/** True while the session shows a permission or question dialog, which acts on a bare keypress. */
@@ -832,7 +828,6 @@ export class DiscordBridge {
/** 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 | DiscordResult>>();
/** Tabs closed while their thread was still being created: the creation applies `onSessionDelete` to it. */
private closedWhilePending = new Set<string>();
@@ -1005,7 +1000,6 @@ export class DiscordBridge {
this.lastSent.clear();
this.lastReply.clear();
this.names.clear();
this.fresh.clear();
this.lastFull.clear();
this.inbound.clear();
}
@@ -1186,33 +1180,13 @@ export class DiscordBridge {
}
/** Session tab created: make its thread now when `threadCreation` is `onSessionStart`. */
async sessionCreated(sessionId: string, name: string | undefined, createdAt?: number): Promise<void> {
async sessionCreated(sessionId: string, name: string | undefined): Promise<void> {
await this.ensureLoaded();
// 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;
const made = await this.threadFor(sessionId, name);
if (typeof made !== 'string') this.last = made;
}
/**
* 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, hintable: boolean): Promise<void> {
if (!this.fresh.has(sessionId)) return;
if (!hintable || !this.cfg.enabled || !this.cfg.startupHint || !this.cfg.typedHint) {
this.fresh.delete(sessionId); // no longer new: turning the hint on later must not reach this tab
return;
}
// A dialog is up (codex's trust prompt reads as idle): Enter would pick its highlighted option, so wait for the next idle.
if (this.deps.awaitingChoice?.(sessionId)) return;
this.fresh.delete(sessionId);
const text = sanitiseReply(`${DISCORD_STARTUP_HINT} Reply with just: ok`);
// fromUser false: this is Codeman talking, so it must not auto-name the tab or count as the person's input.
if (text) await this.deps.sendInput(sessionId, text, false).catch(() => false);
}
async sessionRenamed(sessionId: string, name: string | undefined): Promise<void> {
const seen = this.names.get(sessionId);
this.names.set(sessionId, name ?? '');
@@ -1231,7 +1205,6 @@ export class DiscordBridge {
forgetTab(sessionId: string): void {
this.lastReply.delete(sessionId);
this.names.delete(sessionId);
this.fresh.delete(sessionId);
this.lastFull.delete(sessionId);
}
@@ -1343,7 +1316,7 @@ export class DiscordBridge {
if (saved.refused) await this.say(m.channel_id, saved.refused);
// 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 ok = await this.deps.sendInput(sessionId, keys).catch(() => false);
const reply = ok
? this.api.react(m.channel_id, m.id, '✅')
: this.api.post(m.channel_id, {
-5
View File
@@ -37,11 +37,6 @@ export interface DiscordConfig {
scope: WebhookScope;
/** Tell each session how to reach the user (see DISCORD_STARTUP_HINT). On by default while the bridge is on. */
startupHint: boolean;
/**
* Also type the hint, once, as the first message to a brand-new non-Claude CLI tab. Off by default: it spends a
* model turn per tab, and for a spawned worker or a cron session it lands on the same first idle as the real prompt.
*/
typedHint: boolean;
}
export interface DiscordResult {
-7
View File
@@ -3319,13 +3319,6 @@
</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>
-2
View File
@@ -1771,7 +1771,6 @@ Object.assign(CodemanApp.prototype, {
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'}` };
@@ -1797,7 +1796,6 @@ Object.assign(CodemanApp.prototype, {
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)
-1
View File
@@ -2268,7 +2268,6 @@ export const DiscordUpdateSchema = z
pingMode: z.enum(DISCORD_PING_MODES).optional(),
scope: z.enum(WEBHOOK_SCOPES).optional(),
startupHint: z.boolean().optional(),
typedHint: z.boolean().optional(),
})
.strict();
+9 -32
View File
@@ -520,13 +520,12 @@ export class WebServer extends EventEmitter {
private discordBridge = new DiscordBridge({
configDir: getDataDir(),
fetchImpl: webviewFetch,
sendInput: async (sessionId, text, fromUser) => {
sendInput: async (sessionId, text) => {
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 });
// A Discord reply is a person's own input, so it names the tab like any other prompt.
if (await session.writeViaMux(text, { fromUser: true }).catch(() => false)) return true;
return session.write(text, { fromUser: true });
},
agentExited: (id) => !!this.sessions.get(id)?.paneExit,
awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
@@ -2786,41 +2785,19 @@ export class WebServer extends EventEmitter {
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 };
};
if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated) return;
const d = (data ?? {}) as { id?: string; name?: string; session?: { id?: string; name?: string } };
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);
event === SseEvent.SessionCreated
? this.discordBridge.sessionCreated(id, s.name).then(() => this.discordBridge.sessionRenamed(id, s.name))
: this.discordBridge.sessionRenamed(id, s.name);
void run.catch(() => undefined);
}