diff --git a/CLAUDE.md b/CLAUDE.md index 57b45d6a..505d511b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` -**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, a resumed one, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` +**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token and is never returned; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); the alert tail is the RENDERED tmux screen (`screenTail`), never the raw stream, which a full-screen UI repaints in place. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter, capped and rate limited; posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) and, only with the separate `typedHint` setting (default OFF), is typed once as a first message to brand-new external CLI tabs on their first idle (`sessionIdle`; never a shell, a restored tab, a resumed one, or a remote (ssh) or Docker tab, which have no `codeman` on their PATH). `codeman notify "" [--urgent] [--file ]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md` **MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9ceca0d0..9216522a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1493,7 +1493,7 @@ One thread per tab in a private Discord channel, alerts out and replies typed ba | Method | Path | Body | Notes | | ------ | ------ | ------ | ------ | | `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. | -| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, pingMode?, scope?, startupHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. | +| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, pingMode?, scope?, startupHint?, typedHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. | | `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. | | `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent?, file? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `file` is an absolute path inside the tab's working folder (real path, regular file, at most 8 MB, local sessions only) uploaded with the post; `400` otherwise. `409` while the bridge is disabled. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. | diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index 71f0b563..f257e0a2 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -88,20 +88,21 @@ In the Discord mobile app set the server to **All Messages**. Discord's own push ## Options -| Option | Values | Default | Effect | -| ----------------- | --------------------------- | ----------- | ---------------------------------------------------------------------------------------- | -| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | -| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | -| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | -| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | -| `startupHint` | on, off | on | Tell each CLI about `codeman notify` (Claude at launch, others once on their first idle) | +| Option | Values | Default | Effect | +| ----------------- | --------------------------- | ----------- | --------------------------------------------------------------------------------------- | +| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab | +| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads | +| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts | +| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too | +| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch | +| `typedHint` | on, off | off | Also send the note once, as a first message, to brand-new non-Claude CLI tabs | ## Telling the CLIs -A CLI only posts deliberately if it knows it can. While the bridge and `startupHint` are on: +A CLI only posts deliberately if it knows it can. While the bridge is on: -- **Claude** is launched with `--append-system-prompt`, so nothing shows in the conversation. -- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab becomes idle. Tabs that are restored when Codeman starts, resumed tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it. +- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. +- **Every other CLI** is sent nothing unless you turn on `typedHint` (off by default: it costs one model turn per tab, and for a spawned worker or a scheduled run it arrives together with the first real prompt). With it on, the same text is sent once, as a message, the first time a brand-new tab becomes idle. Tabs that are restored when Codeman starts, resumed tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it. The text explains `codeman notify "message" --urgent`. `codeman` is already on the PATH of every session and reads `CODEMAN_SESSION_ID` and `CODEMAN_API_URL`, so no other setup is needed. Without a bridge it prints that the bridge is off and exits cleanly. Tabs started before the bridge was enabled are not told; use the skill or `CLAUDE.md` for those. diff --git a/docs/wiki/Discord-Bridge.md b/docs/wiki/Discord-Bridge.md index 8899bf3f..2e5c94ce 100644 --- a/docs/wiki/Discord-Bridge.md +++ b/docs/wiki/Discord-Bridge.md @@ -38,23 +38,24 @@ only), never in the shared settings, and never shown again once saved. ## Options -| Option | Choices | Default | -| ------------------------------ | ---------------------------------------- | --------------- | -| Create threads | On the first alert, or for every new tab | First alert | -| When a tab is deleted | Keep, archive or delete the thread | Archive | -| @ ping | Never, critical events only, every post | Critical only | -| Which events | Needs attention, or everything | Needs attention | -| Tell each CLI how to reach you | On or off | On | +| Option | Choices | Default | +| ---------------------------------- | ---------------------------------------- | --------------- | +| Create threads | On the first alert, or for every new tab | First alert | +| When a tab is deleted | Keep, archive or delete the thread | Archive | +| @ ping | Never, critical events only, every post | Critical only | +| Which events | Needs attention, or everything | Needs attention | +| Tell Claude how to reach you | On or off | On | +| Also tell other CLIs, as a message | On or off | Off | Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a permission prompt, a question, an error, or a post an agent marked urgent. ## Telling the agents -An agent only posts deliberately if it knows it can. With **Tell each CLI how to reach you** on: +An agent only posts deliberately if it knows it can: -- **Claude** is launched with the instruction as part of its system prompt, so nothing shows in the chat. -- **Every other CLI** is sent the same text once, as a message, the first time a brand-new tab goes idle. +- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. +- **Every other CLI** is told only if you turn on **Also tell other CLIs, as a message** (off by default: it costs one model turn per tab, and for a spawned worker or a scheduled run it arrives with the first real prompt). Then the same text is sent once, as a message, the first time a brand-new tab goes idle. Restored tabs, resumed tabs, shell tabs, and remote (ssh) and Docker tabs (which have no `codeman` command) are never sent it. Tabs that were already open when you turned the bridge on are not told. The alerts still reach diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index db2d2a94..4ee46016 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -63,6 +63,11 @@ 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 const DEFAULT_DISCORD_CONFIG: DiscordConfig = { @@ -76,6 +81,7 @@ export const DEFAULT_DISCORD_CONFIG: DiscordConfig = { pingMode: 'critical', scope: 'attention', startupHint: true, + typedHint: false, }; export interface DiscordMessage { @@ -271,6 +277,7 @@ 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, }; } @@ -859,7 +866,7 @@ export class DiscordBridge { */ async sessionIdle(sessionId: string, hintable: boolean): Promise { if (!this.fresh.delete(sessionId)) return; - if (!hintable || !this.cfg.enabled || !this.cfg.startupHint) return; + if (!hintable || !this.cfg.enabled || !this.cfg.startupHint || !this.cfg.typedHint) return; 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); diff --git a/src/web/public/index.html b/src/web/public/index.html index 2bf3dda3..5a00822d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3288,11 +3288,18 @@
- Tell each CLI how to reach you - 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. + Tell Claude how to reach you + 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.
+
+
+ Also tell other CLIs, as a first message + 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, resumed, remote or Docker tabs. Off by default. +
+ +
Which events diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 3e76fd86..d1311e47 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1770,6 +1770,7 @@ 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'}` }; @@ -1794,6 +1795,7 @@ 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) diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e012c818..5b4b3f09 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2232,6 +2232,7 @@ 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(); diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 57a4de71..25eb0879 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -3,7 +3,16 @@ * @fileoverview Discord bridge: pure rules, the 0600 store, the REST client, the gateway state * machine (fake socket) and the bridge's gates. No network, no real Discord. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -634,7 +643,7 @@ describe('DiscordBridge files and startup hint', () => { now: () => t, sleep: async () => undefined, }); - return writeDiscordConfig(dir, { ...CFG, ...cfg }).then(async () => { + return writeDiscordConfig(dir, { ...CFG, typedHint: true, ...cfg }).then(async () => { await b.reload(); await b.notify(MSG); posted.length = 0; @@ -730,6 +739,15 @@ describe('DiscordBridge files and startup hint', () => { b.stop(); }); + it('sends the typed hint only when typedHint is on (the default is off)', async () => { + const off = await make({ typedHint: false }); + await off.sessionCreated('s7', 'new', t - 1000); + await off.sessionIdle('s7', true); + expect(sent).toHaveLength(0); + off.stop(); + expect(DEFAULT_DISCORD_CONFIG.typedHint).toBe(false); + }); + it('does not hint a restored (old) session, or when startupHint is off', async () => { const b = await make(); await b.sessionCreated('s4', 'old', 1); // created long ago: restored at server start diff --git a/test/routes/discord-routes.test.ts b/test/routes/discord-routes.test.ts index 14efe600..c4c33ff7 100644 --- a/test/routes/discord-routes.test.ts +++ b/test/routes/discord-routes.test.ts @@ -79,6 +79,19 @@ describe('/api/discord', () => { bridge.stop(); }); + it('stores startupHint (default on) and typedHint (default off) and returns them', async () => { + const { app, bridge } = await harness(); + const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data; + expect(first).toMatchObject({ startupHint: true, typedHint: false }); + const put = await app.inject({ + method: 'PUT', + url: '/api/discord', + payload: { typedHint: true, startupHint: false }, + }); + expect(put.json().data).toMatchObject({ startupHint: false, typedHint: true }); + bridge.stop(); + }); + it('refuses a bad id, an incomplete enable, and unknown fields', async () => { const { app } = await harness(); const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } });