@coreplane/switchboard 1.248.0 → 1.249.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 (47) hide show
  1. package/dist/assets/config/config.example.yaml +43 -17
  2. package/dist/assets/deploy/cloudflare/preflight.mjs +21 -19
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +31 -0
  4. package/dist/assets/deploy/cloudflare-resident/drain.ts +109 -0
  5. package/dist/assets/deploy/cloudflare-resident/threadErr.ts +32 -3
  6. package/dist/assets/deploy/cloudflare-resident/worker.ts +174 -10
  7. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +56 -9
  8. package/dist/assets/deploy/secrets.manifest.json +6 -0
  9. package/dist/assets/package-lock.json +3 -3
  10. package/dist/assets/package.json +1 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/agents/registry.ts +4 -4
  13. package/dist/assets/src/core/budgets.ts +24 -0
  14. package/dist/assets/src/core/coordinator/contract.ts +4 -3
  15. package/dist/assets/src/core/coordinator/driver.ts +40 -7
  16. package/dist/assets/src/core/modelCard.ts +348 -0
  17. package/dist/assets/src/core/modelPricing.ts +14 -5
  18. package/dist/assets/src/core/modelRegistry.ts +51 -0
  19. package/dist/assets/src/core/provider.ts +103 -0
  20. package/dist/assets/src/core/refusal.ts +181 -0
  21. package/dist/assets/src/core/runEvents.ts +43 -0
  22. package/dist/assets/src/core/ship/coordinator.ts +71 -11
  23. package/dist/assets/src/core/ship/handoff.ts +54 -19
  24. package/dist/assets/src/core/trace/workerTrace.ts +9 -3
  25. package/dist/assets/src/core/types.ts +327 -0
  26. package/dist/assets/src/deploy/liveGate.ts +35 -0
  27. package/dist/assets/src/deploy/restart.ts +12 -11
  28. package/dist/assets/src/execution/sandboxErrors.ts +114 -4
  29. package/dist/assets/web/dist/.vite/manifest.json +30 -30
  30. package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +1 -0
  31. package/dist/assets/web/dist/assets/{HomePage-DYxC0izY.js → HomePage-BpQRky8B.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentDetailPage-DLIpWYOc.js → ResidentDetailPage-BIUXyz6K.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentsIndexPage-6LipuDjR.js → ResidentsIndexPage-BZymgSAb.js} +1 -1
  34. package/dist/assets/web/dist/assets/{RunFoldRow-V-iSy64e.js → RunFoldRow-3m4CPRI4.js} +1 -1
  35. package/dist/assets/web/dist/assets/{RunRoutePage-DUalB1u2.js → RunRoutePage-bgkjkA0p.js} +3 -3
  36. package/dist/assets/web/dist/assets/{RunsIndexPage-B9Ba1KdD.js → RunsIndexPage-8S944AzB.js} +1 -1
  37. package/dist/assets/web/dist/assets/{ScheduledPage-KdjLtD_7.js → ScheduledPage-8bBtG9y3.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SettingsPage-IT5l_NaL.js → SettingsPage-DQeNvfaV.js} +1 -1
  39. package/dist/assets/web/dist/assets/{StatusDot-DBHAl4Il.js → StatusDot-BPE5syBa.js} +1 -1
  40. package/dist/assets/web/dist/assets/{Tooltip-_LEjptLV.js → Tooltip-DkoeZfTs.js} +1 -1
  41. package/dist/assets/web/dist/assets/UnitRoutePage-BUzw--Ii.js +1 -0
  42. package/dist/assets/web/dist/assets/{dist-BcYPGOBL.js → dist-D11y9ZJ4.js} +1 -1
  43. package/dist/assets/web/dist/assets/{main-DUfSE0dj.js → main-B6LcgNM6.js} +2 -2
  44. package/dist/cli.js +2451 -1009
  45. package/package.json +1 -1
  46. package/dist/assets/web/dist/assets/DeliveryPage-NP4g6bQd.js +0 -1
  47. package/dist/assets/web/dist/assets/UnitRoutePage-DPBsvGPR.js +0 -1
@@ -4,7 +4,9 @@ import { redactSecrets } from "../redact.js";
4
4
  // docs/reference/specs/agent-ship.md item 14, agent-coding.md item 9): what a
5
5
  // coding child hands back beside its pull-request description when it ran for
6
6
  // a plan unit — where it departed from the unit and why, what it found and did
7
- // not do, and which of the unit's criteria it could not prove. It is data, not
7
+ // not do, which of the unit's criteria it could not prove, and what of the
8
+ // unit was already on the base before it began (`landed`, the fact the ship
9
+ // machine ends a unit `already_landed` on, agent-ship.md item 12). It is data, not
8
10
  // prose in a final message: the child submits it through `submit_handoff` (the
9
11
  // same tool path as the description), the run record carries it, and the
10
12
  // parent posts it to the unit's board issue, where a person decides each row's
@@ -38,10 +40,24 @@ export interface HandoffUnproven {
38
40
  why: string;
39
41
  }
40
42
 
43
+ /** A part of the unit's scope that was already on the base when the run
44
+ * began — the fact the ship machine reads at round 0 (agent-ship.md item 12):
45
+ * a handoff naming where the scope landed, beside a branch with no commits
46
+ * over the base, ends the unit `already_landed` instead of aborting it. */
47
+ export interface HandoffLanded {
48
+ /** What of the unit was already there. */
49
+ what: string;
50
+ /** Where it landed — the pull request or commit that carries it. */
51
+ where: string;
52
+ }
53
+
41
54
  export interface Handoff {
42
55
  deviations: HandoffDeviation[];
43
56
  followUps: HandoffFollowUp[];
44
57
  unproven: HandoffUnproven[];
58
+ /** Absent on a handoff that named none and on a record written before the
59
+ * list existed; every reader treats absent as empty. */
60
+ landed?: HandoffLanded[];
45
61
  }
46
62
 
47
63
  /** The most entries one list may carry: a handoff is a summary for a person,
@@ -58,25 +74,33 @@ const FIELDS: Readonly<Record<ListKey, readonly string[]>> = {
58
74
  deviations: ["from", "to", "why"],
59
75
  followUps: ["what", "where"],
60
76
  unproven: ["criterion", "why"],
77
+ landed: ["what", "where"],
61
78
  };
62
- const LISTS: readonly ListKey[] = ["deviations", "followUps", "unproven"];
79
+ const LISTS: readonly ListKey[] = ["deviations", "followUps", "unproven", "landed"];
80
+ /** The lists every handoff carries; `landed` is optional (its type says so). */
81
+ const REQUIRED: ReadonlySet<ListKey> = new Set<ListKey>(["deviations", "followUps", "unproven"]);
63
82
 
64
- /** A handoff under construction: each list as entries keyed by `FIELDS`. It IS
65
- * a `Handoff` once every entry carries its list's fields — which the two
66
- * builders below guarantee — so the one conversion at their boundary is the
67
- * table above standing in for three interface declarations. */
68
- type FieldLists = Record<ListKey, Record<string, string>[]>;
83
+ /** A handoff under construction: each list as entries keyed by `FIELDS`, the
84
+ * optional one present only when given. It IS a `Handoff` once every entry
85
+ * carries its list's fields — which the two builders below guarantee — so the
86
+ * one conversion at their boundary is the table above standing in for four
87
+ * interface declarations. */
88
+ type FieldLists = Record<Exclude<ListKey, "landed">, Record<string, string>[]> & {
89
+ landed?: Record<string, string>[];
90
+ };
69
91
 
70
92
  const emptyLists = (): FieldLists => ({ deviations: [], followUps: [], unproven: [] });
71
93
  const asHandoff = (lists: FieldLists): Handoff => lists as unknown as Handoff;
72
94
  const asLists = (h: Handoff): FieldLists => h as unknown as FieldLists;
95
+ /** A list's entries, an absent optional list read as empty. */
96
+ const listOf = (lists: FieldLists, key: ListKey): Record<string, string>[] => lists[key] ?? [];
73
97
 
74
98
  export function emptyHandoff(): Handoff {
75
99
  return asHandoff(emptyLists());
76
100
  }
77
101
 
78
102
  export function isEmptyHandoff(h: Handoff): boolean {
79
- return LISTS.every((k) => h[k].length === 0);
103
+ return LISTS.every((k) => listOf(asLists(h), k).length === 0);
80
104
  }
81
105
 
82
106
  function isRecord(v: unknown): v is Record<string, unknown> {
@@ -90,6 +114,7 @@ export function isHandoffShape(v: unknown): v is Handoff {
90
114
  if (!isRecord(v)) return false;
91
115
  return LISTS.every((key) => {
92
116
  const list = v[key];
117
+ if (list === undefined) return !REQUIRED.has(key);
93
118
  return (
94
119
  Array.isArray(list) &&
95
120
  list.every((entry) => isRecord(entry) && FIELDS[key].every((f) => typeof entry[f] === "string"))
@@ -105,18 +130,21 @@ const fieldList = (key: ListKey): string => {
105
130
  };
106
131
 
107
132
  /** Validate untrusted input (a tool call) into a Handoff: the three lists
108
- * present (each may be empty), at most `HANDOFF_MAX_ITEMS` entries each,
109
- * every field a non-empty string of at most `HANDOFF_MAX_FIELD_CHARS` once
110
- * trimmed; unknown keys are dropped. A refusal is a string naming the path,
111
- * never a throw, so the model can fix the object and call again. */
133
+ * present (each may be empty) and `landed` when given, at most
134
+ * `HANDOFF_MAX_ITEMS` entries each, every field a non-empty string of at most
135
+ * `HANDOFF_MAX_FIELD_CHARS` once trimmed; unknown keys are dropped. A refusal
136
+ * is a string naming the path, never a throw, so the model can fix the object
137
+ * and call again. */
112
138
  export function parseHandoff(input: unknown): ParsedHandoff {
113
139
  if (!isRecord(input))
114
140
  return { ok: false, error: "handoff: must be an object with deviations, followUps and unproven" };
115
141
  const out = emptyLists();
116
142
  for (const key of LISTS) {
117
143
  const list = input[key];
144
+ if (list === undefined && !REQUIRED.has(key)) continue;
118
145
  if (!Array.isArray(list))
119
146
  return { ok: false, error: `${key}: must be an array (empty when there is nothing to say)` };
147
+ out[key] = [];
120
148
  if (list.length > HANDOFF_MAX_ITEMS) return { ok: false, error: `${key}: at most ${HANDOFF_MAX_ITEMS} entries` };
121
149
  for (let i = 0; i < list.length; i++) {
122
150
  const entry: unknown = list[i];
@@ -132,7 +160,7 @@ export function parseHandoff(input: unknown): ParsedHandoff {
132
160
  };
133
161
  clean[f] = value;
134
162
  }
135
- out[key].push(clean);
163
+ out[key]!.push(clean);
136
164
  }
137
165
  }
138
166
  return { ok: true, handoff: asHandoff(out) };
@@ -144,12 +172,14 @@ export function parseHandoff(input: unknown): ParsedHandoff {
144
172
  * run record or a board issue. */
145
173
  export function redactHandoff(h: Handoff, redact: (s: string) => string = redactSecrets): Handoff {
146
174
  const out = emptyLists();
175
+ const lists = asLists(h);
147
176
  for (const key of LISTS) {
148
- for (const entry of asLists(h)[key]) {
177
+ if (lists[key] === undefined) continue;
178
+ out[key] = lists[key].map((entry) => {
149
179
  const clean: Record<string, string> = {};
150
180
  for (const f of FIELDS[key]) clean[f] = redact(entry[f]!);
151
- out[key].push(clean);
152
- }
181
+ return clean;
182
+ });
153
183
  }
154
184
  return asHandoff(out);
155
185
  }
@@ -193,14 +223,18 @@ function entryLines(h: Handoff): Array<{ list: ListKey; bullet: string; row: str
193
223
  const text = `${u.criterion} — ${u.why}`;
194
224
  return { list: "unproven" as const, bullet: text, row: `Unproven: ${text}` };
195
225
  }),
226
+ ...(h.landed ?? []).map((l) => {
227
+ const text = `${l.what} — ${l.where}`;
228
+ return { list: "landed" as const, bullet: text, row: `Landed: ${text}` };
229
+ }),
196
230
  ];
197
231
  }
198
232
 
199
233
  /** Each entry as one line, list by list — a deviation as `Deviation: from → to
200
234
  * — why`, a follow-up as `what — where`, an unproven criterion as `Unproven:
201
- * criterion — why` — the same words the ledger rows carry, for a reader that
202
- * wants the handoff as plain lines (the thread's artifacts block). Empty for
203
- * an empty handoff. */
235
+ * criterion — why`, a landed part as `Landed: what — where` — the same words
236
+ * the ledger rows carry, for a reader that wants the handoff as plain lines
237
+ * (the thread's artifacts block). Empty for an empty handoff. */
204
238
  export function handoffLines(h: Handoff): string[] {
205
239
  return entryLines(h).map((e) => e.row);
206
240
  }
@@ -227,6 +261,7 @@ const HEADINGS: Readonly<Record<ListKey, string>> = {
227
261
  deviations: "### Deviations",
228
262
  followUps: "### Follow-ups",
229
263
  unproven: "### Unproven",
264
+ landed: "### Already landed",
230
265
  };
231
266
 
232
267
  /**
@@ -57,9 +57,15 @@ export function shimRoute(pathname: string): string | undefined {
57
57
  // The model proxy's two routes (docs/reference/specs/model-proxy.md): a bounded
58
58
  // request per model call, forwarded to the container like everything else.
59
59
  if (pathname === "/v1/messages" || pathname === "/v1/chat/completions") return "model-proxy";
60
- // The pi harness's three routes (docs/reference/specs/harness-pi.md item 7): a
61
- // run's extension asking for its tools, a verdict, a relayed tool's result.
62
- if (pathname === "/harness/tools" || pathname === "/harness/authorize" || pathname === "/harness/tool")
60
+ // The pi harness's four routes (docs/reference/specs/harness-pi.md item 7): a
61
+ // run's extension asking for its tools, a verdict, a relayed tool's result,
62
+ // how a compaction is written.
63
+ if (
64
+ pathname === "/harness/tools" ||
65
+ pathname === "/harness/authorize" ||
66
+ pathname === "/harness/tool" ||
67
+ pathname === "/harness/compaction"
68
+ )
63
69
  return "harness";
64
70
  if (pathname === "/docs" || pathname.startsWith("/docs/")) return "docs";
65
71
  if (pathname === "/" || pathname === "/index.html") return "page";
@@ -0,0 +1,327 @@
1
+ // Channel abstraction. A channel (Slack, CLI, Discord, HTTP, ...) is only a
2
+ // transport: it receives text from a user somewhere, hands it to the core
3
+ // dispatcher as an IncomingMessage, and provides a ChannelIO for the core to
4
+ // talk back through. Everything else — config resolution, permissions, agent
5
+ // selection, execution — is channel-agnostic and lives in the dispatcher.
6
+
7
+ /** An image the user attached, already downloaded and base64-encoded. */
8
+ export interface ImageAttachment {
9
+ /** e.g. "image/png" — adapters only pass types every provider accepts */
10
+ mediaType: string;
11
+ /** base64 payload, no data: URI prefix */
12
+ data: string;
13
+ name?: string;
14
+ }
15
+
16
+ /**
17
+ * A non-image file the user attached, already downloaded. Two representations
18
+ * share this carrier, keyed by `mediaType`:
19
+ * - PDFs (`mediaType === "application/pdf"`) → `data` is the base64 payload,
20
+ * rendered as a provider-native document block where supported.
21
+ * - Text/code/CSV/log files (any other `mediaType`) → `data` is the decoded
22
+ * UTF-8 file content, inlined as a fenced text part.
23
+ */
24
+ export interface DocumentAttachment {
25
+ mediaType: string;
26
+ data: string;
27
+ name?: string;
28
+ }
29
+
30
+ /** A file left on the platform by reference (record 0033): what the store's
31
+ * copy needs and what the turn's line says — metadata only, never bytes. */
32
+ export interface StagedFile {
33
+ name: string;
34
+ size: number;
35
+ /** The platform's media type, as the object's content type and the line's word. */
36
+ type: string;
37
+ /** The platform's private download URL (Slack: `url_private`); the bot's Worker reads it, the bot never does. */
38
+ url: string;
39
+ /** The platform's id of the message that carried the file — the per-message segment of its key. */
40
+ messageId: string;
41
+ }
42
+
43
+ export interface IncomingMessage {
44
+ /**
45
+ * Scope key for channel-level config. Must be globally unique across
46
+ * platforms — adapters namespace with a platform prefix, e.g. "slack:C0123".
47
+ */
48
+ channelId: string;
49
+ /** Scope key for user-level config and permissions, e.g. "slack:U0123". */
50
+ userId: string;
51
+ /**
52
+ * Stable key for the conversation: same thread => same key => same
53
+ * workspace/sandbox. e.g. "slack:C0123:1712345.6789".
54
+ */
55
+ threadKey: string;
56
+ /** The request text, already stripped of platform artifacts (mentions etc.). */
57
+ text: string;
58
+ /**
59
+ * Human display name for the channel/conversation (e.g. "general"),
60
+ * for run labels and other human-facing surfaces. Adapters resolve it from their
61
+ * platform; the core stays channel-agnostic and treats it as an optional hint —
62
+ * absent for adapters that have no name (HTTP/MCP) or when a lookup fails.
63
+ */
64
+ channelName?: string;
65
+ /**
66
+ * Human display name for the sending user (e.g. "alice"). Same contract as
67
+ * `channelName`: an optional adapter-provided hint, never required by the core.
68
+ */
69
+ userName?: string;
70
+ /**
71
+ * A link back to the triggering message on its platform (a Slack permalink),
72
+ * for human-facing surfaces such as the run page's Request block. Optional
73
+ * adapter hint like the names above — absent for HTTP/MCP.
74
+ */
75
+ sourceUrl?: string;
76
+ /**
77
+ * The app that posted the message for the person `userId` names, when the
78
+ * message was not their own (a Claude Code session relaying a request from
79
+ * its owner's thread, docs/reference/specs/slack-channel.md item 13) — its
80
+ * display name. Absent when the person posted the message themselves.
81
+ */
82
+ relayedBy?: string;
83
+ /**
84
+ * The same app as a platform-namespaced actor id (`slack:bot:<bot_id>`), set
85
+ * exactly when `relayedBy` is: authorization treats the run as that app
86
+ * acting on behalf of `userId`, so a relayed request never holds more than
87
+ * the app and the person both hold (authorization.md item 14).
88
+ */
89
+ postedBy?: string;
90
+ /**
91
+ * The credential that authenticated the request, as a platform-namespaced
92
+ * actor id (`http:<subject>`, `mcp:<subject>`, `cli:local`), when the
93
+ * adapter resolved it to the PERSON it is bound to and named them in
94
+ * `userId` (authorization.md item 15: an ingress token entry's `email`, the
95
+ * CLI's `SWITCHBOARD_CLI_EMAIL`). Identity, never authority: the run, its
96
+ * record and its costs are the person's; what the run may do is exactly what
97
+ * config grants this credential — every gate decides on the actor
98
+ * `resolveChatActor` builds from it. Absent when the sender and the
99
+ * credential are one (Slack, an unbound token) or when the request was
100
+ * relayed (`postedBy`).
101
+ */
102
+ authenticatedAs?: string;
103
+ /** Images attached to the triggering message, if any. */
104
+ images?: ImageAttachment[];
105
+ /** Non-image files (PDFs, text/code/CSV/logs) on the triggering message, if any. */
106
+ documents?: DocumentAttachment[];
107
+ /** Files the inline path cannot carry — too large, or a type the model does
108
+ * not read — left on the platform by reference (record 0033): a run with a
109
+ * workspace stages them into `attachments/` before its turn; the bytes never
110
+ * enter the bot. Present only when the artifact store is configured. */
111
+ staged?: StagedFile[];
112
+ /** The platform's id of this message (Slack's `ts`, the value `staged[].messageId`
113
+ * carries): the `input` event and the files received with it name it, so the
114
+ * run page joins them by data. Absent for a channel with no message id
115
+ * (CLI, HTTP, MCP) → `messageIdOf` uses the run id. */
116
+ messageId?: string;
117
+ /**
118
+ * When OUR process saw the message (ms epoch, from the adapter's clock at its
119
+ * entry — never from a body or a platform stamp): the run's window opens
120
+ * here (docs/reference/specs/tracing.md). Absent (tests, older callers) → the dispatcher
121
+ * reads its own clock at entry.
122
+ */
123
+ receivedAt?: number;
124
+ /**
125
+ * When the platform says the message was posted (Slack's `ts`), for the
126
+ * `queued … before we saw it` caption; a caption, never part of a duration.
127
+ */
128
+ originAt?: number;
129
+ }
130
+
131
+ /** The request's message id as its `input` event and its received files record it:
132
+ * the platform's when the adapter gave one, else the run id — one deterministic
133
+ * value both writers reach for, so a channel without message ids still joins. */
134
+ export function messageIdOf(msg: Pick<IncomingMessage, "messageId">, runId: string): string {
135
+ return msg.messageId ?? runId;
136
+ }
137
+
138
+ export interface HistoryItem {
139
+ role: "user" | "assistant";
140
+ text: string;
141
+ /** When the platform stamps its messages: the turn's time in epoch ms, so a
142
+ * follow-up can tell the lines written after a run ended from the ones that
143
+ * run already saw (docs/reference/specs/session-log.md item 9). */
144
+ at?: number;
145
+ /** Images attached to this turn, if any (user turns only in practice). */
146
+ images?: ImageAttachment[];
147
+ /** Non-image files attached to this turn, if any (user turns only in practice). */
148
+ documents?: DocumentAttachment[];
149
+ }
150
+
151
+ /** What the run is doing right now, typed so each channel draws it in its
152
+ * own dialect and none re-parses text to find a command
153
+ * (docs/reference/specs/run-visibility.md item 2). `command` is a shell call
154
+ * with the command it was given — Slack draws it as a code block; a text
155
+ * surface flattens it to one `→ $ …` line. `line` is the one-line trace
156
+ * every other event makes (`→ read src/a.ts`, `✓ bash: exit 0`, `💭 thought
157
+ * for 5.1s`). */
158
+ export type StatusActivity = { kind: "command"; tool: string; command: string } | { kind: "line"; text: string };
159
+
160
+ /** A structured progress frame; adapters decide how to render it. */
161
+ export interface StatusUpdate {
162
+ /** one-line headline, e.g. "⚡ review on anthropic/claude-fable-5 · 42s" */
163
+ title: string;
164
+ /** the agent's checklist and any lead lines, one per line */
165
+ detail?: string;
166
+ /** the current activity, live frames only — a close never carries it */
167
+ activity?: StatusActivity;
168
+ /**
169
+ * The run's live page, rendered by each channel in its own short form
170
+ * (Slack: a one-line `<url|label>` hyperlink; CLI: the bare URL). Kept out of
171
+ * `detail` on purpose: the capability URL is 100+ chars and, inlined, wraps
172
+ * to four lines on Slack — enough to push the card behind the "Show more"
173
+ * fold, where EVERY edit (5 s heartbeat included) flashes the card open and
174
+ * shut and shoves the thread around.
175
+ */
176
+ link?: { url: string; label: string };
177
+ }
178
+
179
+ /** A live, updatable progress indicator (e.g. an edited Slack message). */
180
+ export interface StatusHandle {
181
+ update(frame: StatusUpdate): void;
182
+ done(frame: StatusUpdate): Promise<void>;
183
+ /** Where the indicator lives, when it is a message another process could
184
+ * edit (Slack: channel + ts) — recorded on the run ledger so a resumed run
185
+ * closes the same card (docs/reference/specs/run-history.md item 35). Absent for a
186
+ * no-op handle. */
187
+ handle?: { channel: string; ts: string };
188
+ }
189
+
190
+ /** How a run ended, as reported to the channel once its record is closed.
191
+ * `interrupted` is a run whose pi container was replaced under it
192
+ * (docs/reference/specs/harness-pi.md item 16): its request runs again as a
193
+ * new run on the same channel handle, whose own receipt follows. */
194
+ export type RunFinalStatus = "completed" | "failed" | "stopped_soft" | "stopped_hard" | "interrupted";
195
+
196
+ /** The receipt a channel gets when the run behind its request finishes: the
197
+ * run id (the `/runs/:id` record) and its terminal status. Never the view
198
+ * token — a receipt names the run, it does not grant access to it. */
199
+ export interface RunReceipt {
200
+ id: string;
201
+ status: RunFinalStatus;
202
+ }
203
+
204
+ /** A thread a channel opened for a child run (docs/reference/specs/thread-admission.md
205
+ * item 6): the key the child is dispatched on and the handle its replies,
206
+ * card and history go through. */
207
+ export interface OpenedThread {
208
+ thread: {
209
+ /** The new thread's key, namespaced like every thread key (`slack:C…:<ts>`). */
210
+ threadKey: string;
211
+ /** A link to the thread's lead message on its platform, when the platform has one. */
212
+ sourceUrl?: string;
213
+ };
214
+ io: ChannelIO;
215
+ }
216
+
217
+ /** A minted one-shot upload (`ChannelIO.uploadTicket`): where the container
218
+ * POSTs the bytes, and the call that shares the uploaded file into the
219
+ * conversation once the POST succeeded. */
220
+ export interface UploadTicket {
221
+ /** Accepts one POST of exactly the ticketed size; single use, short-lived. */
222
+ url: string;
223
+ /** Share the uploaded file into the conversation with `lead` as its message. */
224
+ complete(lead: string): Promise<void>;
225
+ }
226
+
227
+ /** What the core needs from a channel to serve one request. */
228
+ /** What a channel shows for a confirmation (`ChannelIO.offer`): the id its
229
+ * affordance carries back, the full command line to run, the one risk line
230
+ * (empty when the command declares none), the footer naming the scope that
231
+ * asked, and when the offer expires (ms epoch, the config object's clock). */
232
+ export interface ConfirmationOffer {
233
+ id: string;
234
+ line: string;
235
+ risk: string;
236
+ footer: string;
237
+ expiresAt: number;
238
+ /** Present on a question's offer (record 0054): the refusal's sentence,
239
+ * shown above the line, and the evidence naming the match, shown under it.
240
+ * A channel that offers labels the same two actions Yes and No instead of
241
+ * Run and Cancel; Yes redispatches the stored proposal, No cancels. */
242
+ question?: { text: string; evidence: string };
243
+ }
244
+
245
+ export interface ChannelIO {
246
+ /** Post a reply in the conversation. Adapter handles chunking/formatting. */
247
+ reply(text: string): Promise<void>;
248
+ /**
249
+ * Present when this channel has nowhere to deliver a reply (the resumed-run
250
+ * null channel, docs/reference/specs/run-history.md item 38): the reason, e.g.
251
+ * "no channel to deliver to". `reply` still resolves (it logs), but the run's
252
+ * seal must say `replyOk: false` with this reason — a reply nobody could
253
+ * receive was not delivered. Absent on every real channel.
254
+ */
255
+ undeliverable?: string;
256
+ /**
257
+ * Post `lead` as the message and `text` as an attached file beside it — for
258
+ * output too long to read as chat (a 100-tool `mcp show`): the channel's
259
+ * collapsible container rather than a run of chunked messages. Optional;
260
+ * a channel without attachments (or one whose upload fails) falls back to
261
+ * `reply(lead + text)` itself, so callers never branch on the outcome.
262
+ */
263
+ attach?(file: { name: string; text: string; lead: string }): Promise<void>;
264
+ /**
265
+ * Post `lead` as the message and `bytes` as a file beside it — a screenshot,
266
+ * a PDF, a recording a run produced in its workspace, for the person to see
267
+ * inline. The binary sibling of `attach`: bytes have no text fallback, so a
268
+ * channel that cannot take the file (no upload API, a failed upload) throws
269
+ * and the caller reports it. Optional; a channel without uploads leaves it
270
+ * out and the tool behind it says so.
271
+ */
272
+ attachFile?(file: { name: string; bytes: Uint8Array; lead: string }): Promise<void>;
273
+ /**
274
+ * A one-shot upload the run's CONTAINER performs (docs/reference/specs/agent-coding.md
275
+ * item 10, record 0033): the channel mints a URL that accepts exactly
276
+ * `size` bytes under `name`, the container POSTs the file to it, and
277
+ * `complete(lead)` shares it into the conversation with the lead. The bot
278
+ * process never holds the bytes — this is how a 1 GB recording reaches the
279
+ * thread. Optional; a channel without such an API (the CLI, HTTP) leaves it
280
+ * out and the tool posts the run-page link through `reply` instead.
281
+ */
282
+ uploadTicket?(file: { name: string; size: number }): Promise<UploadTicket>;
283
+ /**
284
+ * Show the confirmation a routed write is offered as
285
+ * (docs/reference/specs/routing-and-config.md item 25, record 0044): the full
286
+ * command line the router bound, its one risk line, the footer naming the
287
+ * scope that asked, and the id the channel's affordance carries back to
288
+ * `dispatchClick` — a button whose value is the id, on a channel with
289
+ * components. Optional; a channel without it (the CLI, an HTTP reply, the
290
+ * browser) is answered the pasteable line instead, and nothing below the
291
+ * seam names a channel. The channel shows the offer and holds nothing else:
292
+ * the row lives in the config object until the click or the expiry.
293
+ */
294
+ offer?(offer: ConfirmationOffer): Promise<void>;
295
+ /** Create a progress indicator. Adapters may return a no-op handle. */
296
+ status(initial: StatusUpdate): Promise<StatusHandle>;
297
+ /**
298
+ * Prior turns of this conversation, oldest first, excluding the triggering
299
+ * message and any bot status noise. Adapters without history return [].
300
+ */
301
+ history(): Promise<HistoryItem[]>;
302
+ /**
303
+ * Called once by the core when the run created for this request has been
304
+ * finished in the registry (agent runs AND inline command runs), before the
305
+ * reply goes out. Single-shot channels (HTTP) hand the receipt back to their
306
+ * caller so a machine client — e.g. the Worker shim firing a scheduled job —
307
+ * can name the run it caused. Optional: Slack/CLI need nothing from it.
308
+ */
309
+ runFinished?(receipt: RunReceipt): void;
310
+ /**
311
+ * Called once by the core the moment a run has been CREATED in the registry
312
+ * (before it executes), with the run id. The async HTTP ingress path uses it
313
+ * to answer `202 Accepted` with the run id while the run continues in the
314
+ * background; Slack/CLI need nothing from it. Optional, like runFinished.
315
+ */
316
+ runStarted?(started: { id: string }): void;
317
+ /**
318
+ * Open a thread of this channel's own for a child run
319
+ * (docs/reference/specs/thread-admission.md item 6): post `lead` where a new
320
+ * thread can start — top-level in this conversation's channel — and hand
321
+ * back the thread's key and a handle bound to it. Optional: a single-shot
322
+ * channel (HTTP, MCP) has no thread to open, and a spawn from such a channel
323
+ * is refused by name (`spawn_unsupported`); it never falls back to the
324
+ * parent's own thread.
325
+ */
326
+ openThread?(lead: string): Promise<OpenedThread>;
327
+ }
@@ -187,6 +187,41 @@ export function decideRestarted(
187
187
  });
188
188
  }
189
189
 
190
+ /** The words of a wait that ran out, per command: what was not done, how to
191
+ * run it again, what `--force` would do instead. */
192
+ export interface GaveUpWords {
193
+ notDone: string;
194
+ rerun: string;
195
+ force: string;
196
+ }
197
+
198
+ /** `deploy all`'s words: the release job is re-run once the runs finish. */
199
+ export const DEPLOY_GAVE_UP_WORDS: GaveUpWords = {
200
+ notDone: "NOT deployed",
201
+ rerun: "re-run the deploy once they finish (a CI job: `gh run rerun RUN_ID --failed`)",
202
+ force: "--force to deploy over them (kills the runs in flight that no resume recovers)",
203
+ };
204
+
205
+ /** `deploy restart`'s words. */
206
+ export const RESTART_GAVE_UP_WORDS: GaveUpWords = {
207
+ notDone: "NOT restarted",
208
+ rerun: "re-run `deploy restart` once they finish",
209
+ force: "--force to stop over them (kills the runs in flight that no resume recovers)",
210
+ };
211
+
212
+ /** The failure a preflight still refusing at the end of the wait budget
213
+ * produces. The wait is only how long to hold before failing: it never ends
214
+ * in a deploy over what refused (a rolled container kills the runs it drives,
215
+ * and a handoff is a recovery, not a guarantee), so the line names the budget,
216
+ * the refusal, that nothing was done, and the two ways forward. */
217
+ export function preflightGaveUpLine(
218
+ waitMaxMs: number,
219
+ reason: string,
220
+ words: GaveUpWords = DEPLOY_GAVE_UP_WORDS,
221
+ ): string {
222
+ return `preflight still refusing after ${waitMaxMs / 60_000} min (${reason}) — ${words.notDone}; ${words.rerun}, or ${words.force}`;
223
+ }
224
+
190
225
  /** The line printed on every preflight retry, so a long wait is never silent.
191
226
  * `tag` names the command waiting (`deploy:all`, `deploy:restart`). */
192
227
  export function heartbeatLine(
@@ -53,20 +53,21 @@ export const RESTART_SCOPE = "deploy:write";
53
53
  export interface RestartVerdict {
54
54
  allow: boolean;
55
55
  forced: boolean;
56
- /** What refuses (fail-closed: no JSON body, an impossible count). */
56
+ /** What refuses: runs in flight (a stop rolls the container under them), and the fail-closed cases (no JSON body, an impossible count). */
57
57
  problems: string[];
58
- /** What is said but does not refuse: runs in flight (they hand off), a drain under way. */
58
+ /** What is said but does not refuse: a drain under way with nothing in flight. */
59
59
  warnings: string[];
60
60
  message: string;
61
61
  }
62
62
  /**
63
63
  * Whether the container may be stopped now — the deploy preflight's rules
64
64
  * (deploy/cloudflare/preflight.mjs `decide`) minus the rollout-state check (a
65
- * restart is not a rollout). Since the handoff (docs/reference/specs/run-history.md item
66
- * 39) runs in flight and a drain under way are WARNINGS, not refusals: SIGTERM
67
- * hands every resumable run to the next generation. Fail closed on a body
68
- * that is not JSON or an impossible count; `force` allows those anyway, with
69
- * the warning.
65
+ * restart is not a rollout). Runs in flight REFUSE: the stop rolls the
66
+ * container under them, and the handoff (docs/reference/specs/run-history.md
67
+ * item 39) is a recovery the next generation may fail, not a guarantee — the
68
+ * CLI waits the 409 out instead. A drain under way with nothing in flight is a
69
+ * warning. Fail closed on a body that is not JSON or an impossible count;
70
+ * `force` allows everything anyway, flagged, naming what it kills.
70
71
  */
71
72
  export function decideRestart(body: HealthzBody | undefined, opts: { force: boolean }): RestartVerdict {
72
73
  const problems: string[] = [];
@@ -79,8 +80,8 @@ export function decideRestart(body: HealthzBody | undefined, opts: { force: bool
79
80
  if (!Number.isInteger(body.inFlight) || (body.inFlight as number) < 0) {
80
81
  problems.push(`bot reports an impossible inFlight=${JSON.stringify(body.inFlight)} (counter bug or old Worker)`);
81
82
  } else if ((body.inFlight as number) > 0) {
82
- warnings.push(
83
- `${body.inFlight} run(s) in flight — handed to the next generation on SIGTERM (run-history item 39); they continue there`,
83
+ problems.push(
84
+ `${body.inFlight} run(s) in flight — a stop rolls the bot container under them (a handoff is a recovery, not a guarantee)`,
84
85
  );
85
86
  }
86
87
  if (body.draining === true)
@@ -98,14 +99,14 @@ export function decideRestart(body: HealthzBody | undefined, opts: { force: bool
98
99
  forced: true,
99
100
  problems,
100
101
  warnings,
101
- message: `restart WARNING: stopping by force despite —\n${detail}`,
102
+ message: `restart WARNING: stopping by force despite —\n${detail}\n this WILL kill the runs in flight that no resume recovers`,
102
103
  };
103
104
  return {
104
105
  allow: false,
105
106
  forced: false,
106
107
  problems,
107
108
  warnings,
108
- message: `restart REFUSED —\n${detail}\n wait and retry, or pass --force to stop blind`,
109
+ message: `restart REFUSED —\n${detail}\n wait for them to finish and retry, or pass --force to stop over them`,
109
110
  };
110
111
  }
111
112