@coreplane/switchboard 1.217.0 → 1.218.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/assets/config/config.example.yaml +6 -0
  2. package/dist/assets/deploy/cloudflare/worker.ts +4 -0
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +96 -6
  4. package/dist/assets/deploy/secrets.manifest.json +12 -0
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +18 -0
  9. package/dist/assets/src/core/runEvents.ts +6 -0
  10. package/dist/assets/src/core/runFriction.ts +4 -2
  11. package/dist/assets/src/core/runLedger/sessionLog.ts +22 -0
  12. package/dist/assets/src/core/runLedger/types.ts +17 -0
  13. package/dist/assets/src/mcp/registry.ts +33 -5
  14. package/dist/assets/web/dist/.vite/manifest.json +18 -18
  15. package/dist/assets/web/dist/assets/{ResidentDetailPage-CWyEd5fq.js → ResidentDetailPage-DvCt96Dk.js} +1 -1
  16. package/dist/assets/web/dist/assets/{ResidentsIndexPage-DqFqRTWb.js → ResidentsIndexPage-BZaYja1-.js} +1 -1
  17. package/dist/assets/web/dist/assets/{RunRoutePage-CghwVr_X.js → RunRoutePage-DI4vzCUu.js} +4 -4
  18. package/dist/assets/web/dist/assets/{RunsIndexPage-BcYehvW5.js → RunsIndexPage-Ch9uP4p4.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ScheduledPage-mKa20Qdd.js → ScheduledPage-DAkAHMCH.js} +1 -1
  20. package/dist/assets/web/dist/assets/{StatusDot-QMofJG-E.js → StatusDot-CfBonGcV.js} +1 -1
  21. package/dist/assets/web/dist/assets/{Tooltip-D-zVPexc.js → Tooltip-CBD36E3W.js} +1 -1
  22. package/dist/assets/web/dist/assets/{dist-D43zKxNV.js → dist-DQ1U73LF.js} +1 -1
  23. package/dist/assets/web/dist/assets/{main-ClTMtz8n.js → main-CdLnG-yl.js} +2 -2
  24. package/dist/cli.js +357 -92
  25. package/package.json +1 -1
@@ -473,6 +473,12 @@ workspaceDir: ./workspaces
473
473
  # auth: bearer
474
474
  # tokenEnv: MCP_GITHUB_TOKEN # static bearer from the bot env (startup fails if unset)
475
475
  # agents: [general, research, coding] # default [general, research]
476
+ # lake: # a server behind Cloudflare Access: the gate wants a service token,
477
+ # url: https://vega.example.com/mcp # not an Authorization header — name the two headers and the bot
478
+ # auth: none # env vars holding them (manifest secrets on Cloudflare); an unset
479
+ # headersEnv: # one makes the server `unavailable` for that run, naming it
480
+ # CF-Access-Client-Id: MCP_ACCESS_CLIENT_ID
481
+ # CF-Access-Client-Secret: MCP_ACCESS_CLIENT_SECRET
476
482
  # channels:
477
483
  # "slack:C0123":
478
484
  # mcpServers:
@@ -90,6 +90,8 @@ export interface Env {
90
90
  ANTHROPIC_ADMIN_KEY?: string; // costs dash (optional): Anthropic Admin API key for the LLM cost report
91
91
  MEMORY_TOKEN?: string; // durable memory + friction ledger + schedule firings + MCP registry: bearer for the state Worker
92
92
  MCP_CREDENTIAL_KEY?: string; // MCP registry: the bot-only key that seals server credentials before they reach the McpDO
93
+ MCP_ACCESS_CLIENT_ID?: string; // MCP servers behind Cloudflare Access: the service token's client id, named by a server's `headersEnv`
94
+ MCP_ACCESS_CLIENT_SECRET?: string; // MCP servers behind Cloudflare Access: the service token's client secret, named by a server's `headersEnv`
93
95
  STATE_WORKER_URL?: string; // var: the state Worker's base URL — where this shim records each scheduled firing
94
96
  ARTIFACTS_R2_ACCESS_KEY_ID?: string; // artifact store: the bucket-scoped S3 token the bot signs presigned URLs with
95
97
  ARTIFACTS_R2_SECRET_ACCESS_KEY?: string; // artifact store: the secret half of that token
@@ -124,6 +126,8 @@ const FORWARDED_OPTIONAL = [
124
126
  "BRAVE_SEARCH_API_KEY",
125
127
  "MEMORY_TOKEN",
126
128
  "MCP_CREDENTIAL_KEY",
129
+ "MCP_ACCESS_CLIENT_ID",
130
+ "MCP_ACCESS_CLIENT_SECRET",
127
131
  "STATE_WORKER_URL",
128
132
  "ARTIFACTS_R2_ACCESS_KEY_ID",
129
133
  "ARTIFACTS_R2_SECRET_ACCESS_KEY",
@@ -46,7 +46,10 @@ import {
46
46
  attachmentRefsOf,
47
47
  DEFAULT_SESSION_LOG_MAX_BYTES,
48
48
  droppedToolResultRow,
49
+ GAP_MARKER,
50
+ NOTEPAD_MAX_BYTES,
49
51
  planSessionTrim,
52
+ roleOfStoredRow,
50
53
  rowKind,
51
54
  sessionsToDrop,
52
55
  tailCut,
@@ -80,6 +83,7 @@ import {
80
83
  type LiveRunRow,
81
84
  type ReclaimedRun,
82
85
  type RunState,
86
+ type SessionHit,
83
87
  type StepRecord,
84
88
  type StopMode,
85
89
  type TranscriptAttachment,
@@ -210,6 +214,10 @@ const FTS_CANDIDATES_FLOOR = 50;
210
214
  * tokens and are untouched; the engine still ranks with the FULL query, so the
211
215
  * cap only shapes which rows can become candidates. */
212
216
  const MAX_MATCH_TOKENS = 24;
217
+ /** A `recall` query's size and the most hits one answers (session-log item 10):
218
+ * a query is a few words, and the tool's default is five. */
219
+ const MAX_SEARCH_QUERY_BYTES = 1_024;
220
+ const MAX_SEARCH_HITS = 50;
213
221
  /** Request body ceiling, checked against Content-Length before parsing. A full
214
222
  * batch (50 × 4000-char texts + keywords + envelope) fits comfortably. */
215
223
  const MAX_BODY_BYTES = 512 * 1024;
@@ -2892,18 +2900,65 @@ export class SessionLogDO extends DurableObject<Env> {
2892
2900
  return { ...(await this.read(from)), from };
2893
2901
  }
2894
2902
 
2895
- /** The rows whose text matches `query`, newest first. */
2896
- async search(query: string, limit: number): Promise<Array<{ idx: number; part: number; text: string }>> {
2903
+ /** The rows whose text matches `query`, in relevance order (FTS5's bm25 rank,
2904
+ * the order the memory store's search uses; the newest first among equals),
2905
+ * each with its turn, part, role, kind and indexed text — what `recall`
2906
+ * answers (session-log item 10). */
2907
+ async search(query: string, limit: number): Promise<SessionHit[]> {
2897
2908
  const match = ftsMatchExpr(query);
2898
2909
  if (match === null) return [];
2899
2910
  return this.sql
2900
- .exec<{ idx: number; part: number; text: string }>(
2901
- `SELECT t.idx, t.part, t.text FROM turns_fts f JOIN turns t ON t.id = f.rowid
2902
- WHERE turns_fts MATCH ? ORDER BY t.idx DESC, t.part DESC LIMIT ?`,
2911
+ .exec<{ idx: number; part: number; kind: string; json: string; text: string }>(
2912
+ `SELECT t.idx, t.part, t.kind, t.json, t.text FROM turns_fts f JOIN turns t ON t.id = f.rowid
2913
+ WHERE turns_fts MATCH ? ORDER BY f.rank, t.idx DESC, t.part DESC LIMIT ?`,
2903
2914
  match,
2904
2915
  limit,
2905
2916
  )
2906
- .toArray();
2917
+ .toArray()
2918
+ .map(({ idx, part, kind, json, text }) => {
2919
+ const role = roleOfStoredRow(json);
2920
+ return { idx, part, ...(role !== undefined ? { role } : {}), kind, text };
2921
+ });
2922
+ }
2923
+
2924
+ /** The gap markers (session-log item 9) whose turn lies in `[from, to]`: the
2925
+ * rows a follow-up appended because the run before it detached, so a search
2926
+ * whose hits straddle one can say the log ends short between them. */
2927
+ async gapsBetween(from: number, to: number): Promise<number[]> {
2928
+ return this.sql
2929
+ .exec<{ idx: number }>(
2930
+ `SELECT DISTINCT idx FROM turns WHERE idx >= ? AND idx <= ? AND kind = 'text' AND text = ? ORDER BY idx`,
2931
+ from,
2932
+ to,
2933
+ GAP_MARKER,
2934
+ )
2935
+ .toArray()
2936
+ .map((r) => r.idx);
2937
+ }
2938
+
2939
+ /** The notepad, replaced whole by the live run (session-log item 10): the
2940
+ * same fence as a row write — unknown-run before an owner, fenced for
2941
+ * another generation — and the write's time kept beside the text. */
2942
+ async writeNotepad(gen: string, text: string, now: number): Promise<FenceResult> {
2943
+ let out: FenceResult = { ok: true };
2944
+ this.ctx.storage.transactionSync(() => {
2945
+ const owner = this.owner();
2946
+ if (owner === undefined) {
2947
+ out = { ok: false, reason: "unknown-run" };
2948
+ return;
2949
+ }
2950
+ if (owner.gen !== gen) {
2951
+ out = { ok: false, reason: "fenced" };
2952
+ return;
2953
+ }
2954
+ this.sql.exec(
2955
+ `INSERT INTO notepad (k, text, updated_at) VALUES (1, ?, ?)
2956
+ ON CONFLICT(k) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at`,
2957
+ text,
2958
+ now,
2959
+ );
2960
+ });
2961
+ return out;
2907
2962
  }
2908
2963
 
2909
2964
  async bytes(): Promise<number> {
@@ -2968,6 +3023,9 @@ const LEDGER_ROUTES = new Set([
2968
3023
  "/runs/session/read",
2969
3024
  "/runs/session/read-tail",
2970
3025
  "/runs/session/clear-owner",
3026
+ "/runs/session/search",
3027
+ "/runs/session/notepad",
3028
+ "/runs/session/notepad/write",
2971
3029
  ]);
2972
3030
 
2973
3031
  /** Routes whose bodies may carry a record, a transcript chunk, or an event batch. */
@@ -3187,6 +3245,38 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
3187
3245
  if (!g.ok) return json({ error: g.error }, 400);
3188
3246
  return fenced(await stub.clearOwner(runId.value, g.value));
3189
3247
  }
3248
+ // `recall` (session-log item 10): the hits in relevance order, and the gap
3249
+ // markers that lie between the oldest and the newest of them.
3250
+ if (pathname === "/runs/session/search") {
3251
+ if (
3252
+ typeof b.query !== "string" ||
3253
+ b.query.trim().length === 0 ||
3254
+ utf8ByteLength(b.query) > MAX_SEARCH_QUERY_BYTES
3255
+ )
3256
+ return json({ error: `query must be a non-empty string of at most ${MAX_SEARCH_QUERY_BYTES} bytes` }, 400);
3257
+ const limit = b.limit;
3258
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_HITS)
3259
+ return json({ error: `limit must be an integer in 1..${MAX_SEARCH_HITS}` }, 400);
3260
+ const hits = await stub.search(b.query, limit);
3261
+ const gaps =
3262
+ hits.length > 1
3263
+ ? await stub.gapsBetween(Math.min(...hits.map((h) => h.idx)), Math.max(...hits.map((h) => h.idx)))
3264
+ : [];
3265
+ console.log(`[runs/session/search] ${key.value} -> ${hits.length} hit(s), ${gaps.length} gap(s)`);
3266
+ return json({ hits, gaps });
3267
+ }
3268
+ if (pathname === "/runs/session/notepad") return json({ notepad: await stub.notepad() });
3269
+ if (pathname === "/runs/session/notepad/write") {
3270
+ const g = gen(b.gen);
3271
+ if (!g.ok) return json({ error: g.error }, 400);
3272
+ if (typeof b.text !== "string") return json({ error: "text must be a string" }, 400);
3273
+ const bytes = utf8ByteLength(b.text);
3274
+ if (bytes > NOTEPAD_MAX_BYTES)
3275
+ return json({ error: `text is ${bytes} bytes; the notepad holds at most ${NOTEPAD_MAX_BYTES}` }, 400);
3276
+ const r = await stub.writeNotepad(g.value, b.text, systemClock());
3277
+ console.log(`[runs/session/notepad/write] ${key.value} <- ${bytes} byte(s), ok=${r.ok}`);
3278
+ return fenced(r);
3279
+ }
3190
3280
  return json({ error: "not found" }, 404);
3191
3281
  }
3192
3282
 
@@ -99,6 +99,18 @@
99
99
  "optional": true,
100
100
  "note": "AES-256-GCM key (32 bytes, base64: `openssl rand -base64 32`) the bot seals MCP server credentials with before they go to the state Worker's McpDO (docs/reference/specs/mcp-tools.md). Bot-only by design: the Worker stores ciphertext. Rotation re-seals every credential — not built yet; rotate = users re-run `mcp connect`."
101
101
  },
102
+ {
103
+ "name": "MCP_ACCESS_CLIENT_ID",
104
+ "workers": ["bot"],
105
+ "optional": true,
106
+ "note": "Client id of the Cloudflare Access service token the bot presents to MCP servers behind Access — a pinned server's `headersEnv` names it under `CF-Access-Client-Id` (docs/reference/specs/mcp-tools.md item 11). Minted in Zero Trust → Access → Service Auth of the account that guards the server, and enrolled on that Access application's policy. Optional: without it such a server is `unavailable` naming the variable."
107
+ },
108
+ {
109
+ "name": "MCP_ACCESS_CLIENT_SECRET",
110
+ "workers": ["bot"],
111
+ "optional": true,
112
+ "note": "Client secret of that service token — `headersEnv` names it under `CF-Access-Client-Secret`. Shown once at creation; rotate by minting a new token, `deploy secrets bot --only` both names, `deploy restart`, then revoke the old one."
113
+ },
102
114
  {
103
115
  "name": "MEMORY_TOKEN",
104
116
  "workers": ["bot", "resident", "memory"],
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.217.0",
3
+ "version": "1.218.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.217.0",
9
+ "version": "1.218.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -16710,7 +16710,7 @@
16710
16710
  },
16711
16711
  "packages/switchboard": {
16712
16712
  "name": "@coreplane/switchboard",
16713
- "version": "1.217.0",
16713
+ "version": "1.218.0",
16714
16714
  "license": "Apache-2.0",
16715
16715
  "dependencies": {
16716
16716
  "@anthropic-ai/sdk": "^0.124.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.217.0",
3
+ "version": "1.218.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.217.0",
3
- "commit": "9007d5664a8f07d092a33dbf373f5377577adc36",
4
- "builtAt": "2026-09-15T00:05:36.819Z"
2
+ "version": "1.218.0",
3
+ "commit": "2931369158bcc02d8e2cfd80eb661bae017188bd",
4
+ "builtAt": "2026-09-15T00:34:28.652Z"
5
5
  }
@@ -202,6 +202,14 @@ Whole files: up to 1 GiB where the artifact store is configured (a recording, a
202
202
  Files the person dropped on the thread that were too large to show you inline are already in ./attachments/ in your workspace when the turn's text names them (a video for ffmpeg, a large PDF, a zip); read them from there — never ask for a re-upload.
203
203
  Text stays in your message; do not attach what you can say.`;
204
204
 
205
+ // Every prompt that can run on the pi harness carries this verbatim
206
+ // (docs/reference/specs/session-log.md item 10; record 0035, "The notepad"): what
207
+ // belongs in the agent's notes for the thread, and why — the one thing sure to
208
+ // survive a compaction and reach the next run there — beside the reach `recall`
209
+ // gives into every earlier turn. Said once so the prompts cannot drift on it.
210
+ const NOTEPAD = `YOUR NOTES AND YOUR REACH BACK. This thread's conversation outlives your context window and this run: every turn — yours, the person's, every tool call and its output, from this run and the runs before it in this thread — is kept in a log you can search with the \`recall\` tool (words → the matching turns with their numbers; a turn number → that turn whole). When something you need is no longer in front of you, recall it instead of redoing the work or guessing.
211
+ Keep notes with the \`notes\` tool: one short document, replaced whole each time, at most 8 KiB — decisions and their reasons, the names of things you found (files, tests, commits, the head your tests were green at), what is not yet proven. They are the one thing sure to survive a compaction and to reach the next run in this thread: they ride your system prompt at its start and come back to you right after a compaction. Write them when you decide something worth keeping, not only at the end.`;
212
+
205
213
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
206
214
 
207
215
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -231,6 +239,8 @@ ${PR_DESCRIPTION_TEMPLATE}
231
239
 
232
240
  ${SHOW_FILES}
233
241
 
242
+ ${NOTEPAD}
243
+
234
244
  Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
235
245
 
236
246
  If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
@@ -271,6 +281,8 @@ ${PR_DESCRIPTION_TEMPLATE}
271
281
 
272
282
  ${SHOW_FILES}
273
283
 
284
+ ${NOTEPAD}
285
+
274
286
  Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
275
287
 
276
288
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
@@ -333,6 +345,8 @@ REVIEW THE PR'S OWN HEAD, NOTHING ELSE: the commit you read must be the PR's hea
333
345
 
334
346
  ${REVIEW_VERDICT_INSTRUCTION}
335
347
 
348
+ ${NOTEPAD}
349
+
336
350
  Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
337
351
 
338
352
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -363,6 +377,8 @@ REVIEW THE PR'S OWN HEAD, NOTHING ELSE: the commit you read must be the PR's hea
363
377
 
364
378
  ${REVIEW_VERDICT_INSTRUCTION}
365
379
 
380
+ ${NOTEPAD}
381
+
366
382
  Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending), update as items start (✱) and finish (✓ — only after they actually happened; never pre-mark reporting steps). Items are short outcomes, never commands.
367
383
 
368
384
  Your final message is posted to Slack. Lead with a one-line verdict, then the findings.`;
@@ -421,6 +437,8 @@ READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no
421
437
 
422
438
  Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending) once you have it, and update items as they start (✱) and finish (✓ — only after they actually happened). Items are short outcomes ("Clone and install", "Time the full suite"), never commands.
423
439
 
440
+ ${NOTEPAD}
441
+
424
442
  Report outcomes faithfully: a check you could not run is "could not check", never a guess. Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks — render the claim table as aligned rows inside a code block). Your final message is posted to Slack: lead with the overall verdict in one line, then the claim table, then what a follow-up should do.`;
425
443
 
426
444
  // The conductor (docs/reference/specs/agent-conductor.md): a run that starts
@@ -387,6 +387,12 @@ export type RunEvent =
387
387
  * `input`, bounded (newest 20 turns / 256 KiB) and gated by
388
388
  * `runHistory.includeContext`. */
389
389
  | { type: "context"; text: string; seq?: number; at?: number }
390
+ /** The agent's notes for this thread as the `notes` tool last wrote them
391
+ * (docs/reference/specs/session-log.md item 10; record 0035, "The notepad"):
392
+ * the whole notepad, at most `NOTEPAD_MAX_BYTES`, so the record shows what
393
+ * the next run of the session starts from. Published by the tool on every
394
+ * write; the record's last one is the final text. */
395
+ | { type: "notes"; text: string; spanId?: string; seq?: number; at?: number }
390
396
  /** The model's prose BETWEEN tool calls — text content that rode alongside
391
397
  * tool_use in one completion. Emitted by the runner, redacted, uncapped. The
392
398
  * final text-only completion is NOT one of these (that is the `answer`). */
@@ -193,14 +193,16 @@ interface PendingCall {
193
193
  }
194
194
 
195
195
  /** The event types that tell the run's story rather than its steps — the
196
- * narrative (`input`/`context`/`assistant`/`answer`) and what the run is about. */
197
- type NarrativeEvent = Extract<RunEvent, { type: "input" | "context" | "assistant" | "answer" | "run_meta" }>;
196
+ * narrative (`input`/`context`/`assistant`/`answer`, the `notes` the agent
197
+ * kept) and what the run is about. */
198
+ type NarrativeEvent = Extract<RunEvent, { type: "input" | "context" | "assistant" | "answer" | "notes" | "run_meta" }>;
198
199
  function isNarrative(ev: RunEvent): ev is NarrativeEvent {
199
200
  return (
200
201
  ev.type === "input" ||
201
202
  ev.type === "context" ||
202
203
  ev.type === "assistant" ||
203
204
  ev.type === "answer" ||
205
+ ev.type === "notes" ||
204
206
  ev.type === "run_meta"
205
207
  );
206
208
  }
@@ -62,6 +62,28 @@ export function rowKind(json: string): RowKind {
62
62
  }
63
63
  }
64
64
 
65
+ /** Whose turn a stored row is: the role the row's JSON carries; a compaction
66
+ * row or an unreadable one has none. What `recall` reports beside a hit. */
67
+ export function roleOfStoredRow(json: string): "user" | "assistant" | undefined {
68
+ const stored = parseStored(json);
69
+ if (!stored || "compaction" in stored) return undefined;
70
+ const role = (stored as { role?: unknown }).role;
71
+ return role === "user" || role === "assistant" ? role : undefined;
72
+ }
73
+
74
+ /** The notepad's size (record 0035, "The notepad"): one document per session,
75
+ * written whole, at most this many UTF-8 bytes; `notes` refuses over it naming
76
+ * the size, and the object's write route does too. */
77
+ export const NOTEPAD_MAX_BYTES = 8_192;
78
+
79
+ /** The row a follow-up appends when the previous run's record says `broken`
80
+ * (session-log item 9): a user text turn saying the log ends short of what
81
+ * that run saw. Named here so the object can tell a gap row apart for
82
+ * `recall`, which says when a search straddles one. */
83
+ export const GAP_MARKER =
84
+ "[The log of this conversation ends short of what the previous run saw: its connection to the ledger broke, " +
85
+ "so its later turns and its final reply are not here.]";
86
+
65
87
  const textOfResultContent = (content: unknown): string => {
66
88
  if (typeof content === "string") return content;
67
89
  if (!Array.isArray(content)) return "";
@@ -206,6 +206,23 @@ export interface TranscriptRow {
206
206
  json: string;
207
207
  }
208
208
 
209
+ /** One hit of a session log's full-text search (session-log item 10): the
210
+ * row's turn and part, whose turn it is, what kind of row (`rowKind`), and
211
+ * the text the index held for it. */
212
+ export interface SessionHit {
213
+ idx: number;
214
+ part: number;
215
+ role?: "user" | "assistant";
216
+ kind: string;
217
+ text: string;
218
+ }
219
+
220
+ /** A session's notepad (record 0035, "The notepad"): the text and when it was last written. */
221
+ export interface Notepad {
222
+ text: string;
223
+ updatedAt: number;
224
+ }
225
+
209
226
  /** An externalized attachment: base64 data stored once, referenced from a row. */
210
227
  export interface TranscriptAttachment {
211
228
  ref: string;
@@ -15,13 +15,19 @@ export type McpAuthKind = "none" | "bearer" | "oauth";
15
15
 
16
16
  /** One server as a scope carries it. `tokenEnv` is the static-config way to
17
17
  * supply a bearer (an env var on the bot); without it a bearer server's token
18
- * is the sealed credential the connect page stored. */
18
+ * is the sealed credential the connect page stored. `headersEnv` is the
19
+ * static-config way to supply any other request header — header name → the
20
+ * bot env var holding its value — for a server behind a gate that is not the
21
+ * server's own auth (a Cloudflare Access service token: `CF-Access-Client-Id`
22
+ * + `CF-Access-Client-Secret`). It composes with every `auth` kind; the
23
+ * Authorization header itself is `auth`'s and is refused here. */
19
24
  export interface McpServerEntry {
20
25
  url: string;
21
26
  /** Agents whose runs may see it (default general + research). */
22
27
  agents?: string[];
23
28
  auth: McpAuthKind;
24
29
  tokenEnv?: string;
30
+ headersEnv?: Record<string, string>;
25
31
  /** Who added it at run time (`slack:U…`, `cli:local`); absent for static config. */
26
32
  addedBy?: string;
27
33
  addedAt?: number;
@@ -126,6 +132,24 @@ export const MCP_SERVERS_PER_SCOPE_MAX = 32;
126
132
  const isStr = (v: unknown, max = 4_096): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
127
133
  const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
128
134
 
135
+ /** At most this many `headersEnv` entries on one server. */
136
+ export const MCP_HEADERS_MAX = 8;
137
+ /** An HTTP header field name (RFC 9110 token), ≤ 64 chars. */
138
+ export const MCP_HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,64}$/;
139
+
140
+ /** Shape only: a non-empty mapping of header name → env var name, each a
141
+ * string (the config layer holds names to `MCP_HEADER_NAME_RE` and refuses
142
+ * Authorization). */
143
+ function isHeadersEnv(v: unknown): v is Record<string, string> {
144
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
145
+ const entries = Object.entries(v as Record<string, unknown>);
146
+ return (
147
+ entries.length > 0 &&
148
+ entries.length <= MCP_HEADERS_MAX &&
149
+ entries.every(([name, envVar]) => isStr(name, 64) && isStr(envVar, 128))
150
+ );
151
+ }
152
+
129
153
  function isOutcome(v: unknown): v is NonNullable<McpTicket["outcome"]> {
130
154
  if (!v || typeof v !== "object" || Array.isArray(v)) return false;
131
155
  const o = v as Record<string, unknown>;
@@ -148,6 +172,7 @@ export function isMcpServerEntry(v: unknown): v is McpServerEntry {
148
172
  e.agents.every((a) => isStr(a, 32)))) &&
149
173
  (e.auth === "none" || e.auth === "bearer" || e.auth === "oauth") &&
150
174
  (e.tokenEnv === undefined || isStr(e.tokenEnv, 128)) &&
175
+ (e.headersEnv === undefined || isHeadersEnv(e.headersEnv)) &&
151
176
  (e.addedBy === undefined || isStr(e.addedBy, 260)) &&
152
177
  (e.addedAt === undefined || isNum(e.addedAt))
153
178
  );
@@ -194,9 +219,10 @@ export interface McpServerView {
194
219
  url: string;
195
220
  agents: string[];
196
221
  auth: McpAuthKind;
197
- /** `static` = bearer from `tokenEnv` on the bot; `connected` = a sealed
198
- * credential is stored (or no credential is needed); `awaiting_credential`
199
- * = bearer, nothing stored yet. */
222
+ /** `static` = the credential is the bot's environment (`tokenEnv`, or
223
+ * `headersEnv` on an `auth: none` server); `connected` = a sealed credential
224
+ * is stored (or no credential is needed); `awaiting_credential` = bearer,
225
+ * nothing stored yet. */
200
226
  state: "connected" | "awaiting_credential" | "static";
201
227
  source: "config" | "runtime";
202
228
  addedBy?: string;
@@ -212,7 +238,9 @@ export function serverView(
212
238
  const parsed = parseMcpScopeKey(scopeKey);
213
239
  const state: McpServerView["state"] =
214
240
  entry.auth === "none"
215
- ? "connected"
241
+ ? entry.headersEnv
242
+ ? "static"
243
+ : "connected"
216
244
  : entry.tokenEnv
217
245
  ? "static"
218
246
  : opts.hasCredential
@@ -28,7 +28,7 @@
28
28
  "src": "../node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-normal.woff2"
29
29
  },
30
30
  "../node_modules/@pierre/diffs/dist/index.js": {
31
- "file": "assets/dist-D43zKxNV.js",
31
+ "file": "assets/dist-DQ1U73LF.js",
32
32
  "name": "dist",
33
33
  "src": "../node_modules/@pierre/diffs/dist/index.js",
34
34
  "isDynamicEntry": true,
@@ -2562,16 +2562,16 @@
2562
2562
  "_seed-DeKSHrt2.js"
2563
2563
  ]
2564
2564
  },
2565
- "_StatusDot-QMofJG-E.js": {
2566
- "file": "assets/StatusDot-QMofJG-E.js",
2565
+ "_StatusDot-CfBonGcV.js": {
2566
+ "file": "assets/StatusDot-CfBonGcV.js",
2567
2567
  "name": "StatusDot",
2568
2568
  "imports": [
2569
2569
  "_seed-DeKSHrt2.js",
2570
- "_Tooltip-D-zVPexc.js"
2570
+ "_Tooltip-CBD36E3W.js"
2571
2571
  ]
2572
2572
  },
2573
- "_Tooltip-D-zVPexc.js": {
2574
- "file": "assets/Tooltip-D-zVPexc.js",
2573
+ "_Tooltip-CBD36E3W.js": {
2574
+ "file": "assets/Tooltip-CBD36E3W.js",
2575
2575
  "name": "Tooltip",
2576
2576
  "imports": [
2577
2577
  "_AppShell-lYVcj-k7.js",
@@ -2789,7 +2789,7 @@
2789
2789
  ]
2790
2790
  },
2791
2791
  "src/main.ts": {
2792
- "file": "assets/main-ClTMtz8n.js",
2792
+ "file": "assets/main-CdLnG-yl.js",
2793
2793
  "name": "main",
2794
2794
  "src": "src/main.ts",
2795
2795
  "isEntry": true,
@@ -2841,19 +2841,19 @@
2841
2841
  ]
2842
2842
  },
2843
2843
  "src/pages/ResidentDetailPage.vue": {
2844
- "file": "assets/ResidentDetailPage-CWyEd5fq.js",
2844
+ "file": "assets/ResidentDetailPage-DvCt96Dk.js",
2845
2845
  "name": "ResidentDetailPage",
2846
2846
  "src": "src/pages/ResidentDetailPage.vue",
2847
2847
  "isDynamicEntry": true,
2848
2848
  "imports": [
2849
2849
  "_AppShell-lYVcj-k7.js",
2850
2850
  "_seed-DeKSHrt2.js",
2851
- "_StatusDot-QMofJG-E.js",
2851
+ "_StatusDot-CfBonGcV.js",
2852
2852
  "_residentDiskBudget-BMBKlYRH.js"
2853
2853
  ]
2854
2854
  },
2855
2855
  "src/pages/ResidentsIndexPage.vue": {
2856
- "file": "assets/ResidentsIndexPage-DqFqRTWb.js",
2856
+ "file": "assets/ResidentsIndexPage-BZaYja1-.js",
2857
2857
  "name": "ResidentsIndexPage",
2858
2858
  "src": "src/pages/ResidentsIndexPage.vue",
2859
2859
  "isDynamicEntry": true,
@@ -2861,20 +2861,20 @@
2861
2861
  "_AppShell-lYVcj-k7.js",
2862
2862
  "_seed-DeKSHrt2.js",
2863
2863
  "_wallClock-61NpVkY6.js",
2864
- "_StatusDot-QMofJG-E.js",
2864
+ "_StatusDot-CfBonGcV.js",
2865
2865
  "_residentDiskBudget-BMBKlYRH.js",
2866
2866
  "_indexFormat-B-pd4r7Y.js"
2867
2867
  ]
2868
2868
  },
2869
2869
  "src/pages/RunRoutePage.vue": {
2870
- "file": "assets/RunRoutePage-CghwVr_X.js",
2870
+ "file": "assets/RunRoutePage-DI4vzCUu.js",
2871
2871
  "name": "RunRoutePage",
2872
2872
  "src": "src/pages/RunRoutePage.vue",
2873
2873
  "isDynamicEntry": true,
2874
2874
  "imports": [
2875
2875
  "_AppShell-lYVcj-k7.js",
2876
2876
  "_seed-DeKSHrt2.js",
2877
- "_Tooltip-D-zVPexc.js",
2877
+ "_Tooltip-CBD36E3W.js",
2878
2878
  "src/main.ts",
2879
2879
  "_wallClock-61NpVkY6.js",
2880
2880
  "_NotFoundPage-BzWS4Ca1.js",
@@ -2890,31 +2890,31 @@
2890
2890
  ]
2891
2891
  },
2892
2892
  "src/pages/RunsIndexPage.vue": {
2893
- "file": "assets/RunsIndexPage-BcYehvW5.js",
2893
+ "file": "assets/RunsIndexPage-Ch9uP4p4.js",
2894
2894
  "name": "RunsIndexPage",
2895
2895
  "src": "src/pages/RunsIndexPage.vue",
2896
2896
  "isDynamicEntry": true,
2897
2897
  "imports": [
2898
2898
  "_AppShell-lYVcj-k7.js",
2899
2899
  "_seed-DeKSHrt2.js",
2900
- "_Tooltip-D-zVPexc.js",
2900
+ "_Tooltip-CBD36E3W.js",
2901
2901
  "_wallClock-61NpVkY6.js",
2902
2902
  "_RunsTabs-DEICQ4FY.js",
2903
- "_StatusDot-QMofJG-E.js",
2903
+ "_StatusDot-CfBonGcV.js",
2904
2904
  "_indexFormat-B-pd4r7Y.js",
2905
2905
  "_favicon-CWPcvWvp.js",
2906
2906
  "_localIso-L06jV29p.js"
2907
2907
  ]
2908
2908
  },
2909
2909
  "src/pages/ScheduledPage.vue": {
2910
- "file": "assets/ScheduledPage-mKa20Qdd.js",
2910
+ "file": "assets/ScheduledPage-DAkAHMCH.js",
2911
2911
  "name": "ScheduledPage",
2912
2912
  "src": "src/pages/ScheduledPage.vue",
2913
2913
  "isDynamicEntry": true,
2914
2914
  "imports": [
2915
2915
  "_AppShell-lYVcj-k7.js",
2916
2916
  "_seed-DeKSHrt2.js",
2917
- "_Tooltip-D-zVPexc.js",
2917
+ "_Tooltip-CBD36E3W.js",
2918
2918
  "_wallClock-61NpVkY6.js",
2919
2919
  "_RunsTabs-DEICQ4FY.js",
2920
2920
  "_indexFormat-B-pd4r7Y.js",