@coreplane/switchboard 1.251.0 → 1.252.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 (74) hide show
  1. package/dist/assets/config/config.example.yaml +4 -2
  2. package/dist/assets/deploy/cloudflare/preflight.mjs +19 -21
  3. package/dist/assets/deploy/cloudflare/worker.ts +6 -3
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +77 -12
  5. package/dist/assets/deploy/cloudflare-resident/memoryGuard.ts +212 -0
  6. package/dist/assets/deploy/cloudflare-resident/refresh.ts +1 -1
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +317 -56
  8. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +4 -2
  9. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.d.mts +31 -0
  10. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.mjs +119 -0
  11. package/dist/assets/package-lock.json +3 -3
  12. package/dist/assets/package.json +3 -2
  13. package/dist/assets/project.json +13 -9
  14. package/dist/assets/source.json +3 -3
  15. package/dist/assets/src/agents/registry.ts +5 -5
  16. package/dist/assets/src/core/budgets.ts +22 -0
  17. package/dist/assets/src/core/coordinator/contract.ts +42 -0
  18. package/dist/assets/src/core/coordinator/driver.ts +134 -10
  19. package/dist/assets/src/core/drain.ts +50 -0
  20. package/dist/assets/src/core/memory/engine.ts +98 -0
  21. package/dist/assets/src/core/memory/scorer.ts +12 -4
  22. package/dist/assets/src/core/memory/types.ts +69 -12
  23. package/dist/assets/src/core/modelCard.ts +32 -4
  24. package/dist/assets/src/core/modelPricing.ts +111 -1
  25. package/dist/assets/src/core/modelProxy/usage.ts +88 -0
  26. package/dist/assets/src/core/modelRegistry.ts +15 -1
  27. package/dist/assets/src/core/refusal.ts +4 -7
  28. package/dist/assets/src/core/reviewVerdict.ts +4 -0
  29. package/dist/assets/src/core/runEvents.ts +51 -2
  30. package/dist/assets/src/core/runFriction.ts +7 -2
  31. package/dist/assets/src/core/runLedger/types.ts +11 -0
  32. package/dist/assets/src/core/runUsage.ts +67 -13
  33. package/dist/assets/src/core/schedules.ts +3 -0
  34. package/dist/assets/src/core/ship/contract.ts +41 -14
  35. package/dist/assets/src/core/ship/coordinator.ts +380 -53
  36. package/dist/assets/src/core/ship/renewal.ts +10 -5
  37. package/dist/assets/src/core/trace/attrs.ts +24 -0
  38. package/dist/assets/src/core/types.ts +5 -5
  39. package/dist/assets/src/core/verbosity.ts +48 -0
  40. package/dist/assets/src/deploy/liveGate.ts +40 -13
  41. package/dist/assets/src/deploy/restart.ts +11 -12
  42. package/dist/assets/src/execution/residentDepCache.ts +50 -1
  43. package/dist/assets/src/execution/residentDepsStore.ts +40 -2
  44. package/dist/assets/src/execution/residentRefresh.ts +55 -3
  45. package/dist/assets/src/execution/residentSteps.ts +4 -0
  46. package/dist/assets/src/execution/sandboxErrors.ts +8 -0
  47. package/dist/assets/web/dist/.vite/manifest.json +55 -55
  48. package/dist/assets/web/dist/assets/{DeliveryPage-DUXd-Sl-.js → DeliveryPage-3ELQWM0r.js} +1 -1
  49. package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +2 -0
  50. package/dist/assets/web/dist/assets/{PendingTurnRow-DDhMhrI7.js → PendingTurnRow-ChCQOLgZ.js} +1 -1
  51. package/dist/assets/web/dist/assets/{ResidentDetailPage-BnEoOnGQ.js → ResidentDetailPage-C9y3nbo8.js} +1 -1
  52. package/dist/assets/web/dist/assets/{ResidentsIndexPage-Dxpgf-l-.js → ResidentsIndexPage-i1RG9e7g.js} +1 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +1 -0
  54. package/dist/assets/web/dist/assets/{RunRoutePage-9klVWhSF.js → RunRoutePage-B3IirUVi.js} +4 -4
  55. package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +1 -0
  56. package/dist/assets/web/dist/assets/{ScheduledPage-B_GgeJrb.js → ScheduledPage-DvYwM2TE.js} +1 -1
  57. package/dist/assets/web/dist/assets/{SettingsPage-BXX4R113.js → SettingsPage-Bo6yCyXZ.js} +1 -1
  58. package/dist/assets/web/dist/assets/{StatusDot-BOaw8le9.js → StatusDot-CAfS1AUi.js} +1 -1
  59. package/dist/assets/web/dist/assets/{Tooltip-DYZZ4l4V.js → Tooltip-tZoum_T-.js} +1 -1
  60. package/dist/assets/web/dist/assets/{UnitRoutePage-BaSW5Odq.js → UnitRoutePage-BmdOHwNn.js} +1 -1
  61. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +1 -0
  62. package/dist/assets/web/dist/assets/{dist-BCVXeBJ9.js → dist-DfbEpHXR.js} +1 -1
  63. package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +1 -0
  64. package/dist/assets/web/dist/assets/{main-Dkcbtu3u.js → main-5Gm_1Gv8.js} +2 -2
  65. package/dist/assets/web/dist/assets/sseReplay-DmyMXfRC.js +11 -0
  66. package/dist/cli.js +2470 -902
  67. package/package.json +1 -1
  68. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +0 -68
  69. package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +0 -2
  70. package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +0 -1
  71. package/dist/assets/web/dist/assets/RunsIndexPage-BplMIgaw.js +0 -1
  72. package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +0 -1
  73. package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +0 -1
  74. package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +0 -9
@@ -1,4 +1,6 @@
1
- import { parseModelRef } from "./provider.js";
1
+ import { parseModelRef, type TokenUsage } from "./provider.js";
2
+ import type { CardPrice } from "./modelCard.js";
3
+ import type { ReportedCost } from "./modelProxy/usage.js";
2
4
  import type { ModelUsage, RunUsage } from "./runUsage.js";
3
5
 
4
6
  // The price of a model's tokens (docs/reference/specs/costs.md): one table
@@ -148,6 +150,105 @@ export function modelPriceOf(ref: string, prices: ModelPriceTable = NO_PRICES):
148
150
  return { input: list.input, output: list.output, cacheRead: list.cacheRead, cacheWrite: list.cacheWrite5m };
149
151
  }
150
152
 
153
+ // ---- one turn, priced at the proxy (model-proxy.md item 6) -----------------------
154
+
155
+ /** Which layer priced the turn: the provider's own reported cost, the
156
+ * operator's table (`costs.prices` or a block's `models.<id>.price`), the
157
+ * registry card (the Anthropic family list folds into this layer), or none —
158
+ * an unpriced turn is never $0. */
159
+ export type PriceSource = "provider" | "operator" | "registry" | "none";
160
+
161
+ /** The meter row's dollars: `usd` when a layer priced the turn, `feeUsd` on a
162
+ * BYOK turn (the aggregator's fee — inside `usd` when the vendor's charge was
163
+ * reported beside it, otherwise beside the layer that priced the tokens),
164
+ * and the layer. */
165
+ export interface TurnPrice {
166
+ usd?: number;
167
+ feeUsd?: number;
168
+ priceSource: PriceSource;
169
+ }
170
+
171
+ /** What `priceTurn` reads of the run's model card: the ref the spans name,
172
+ * the card's rate when a layer named one, and which layer did
173
+ * (`ModelCard.provenance.price`). */
174
+ export interface TurnPriceCard {
175
+ ref: string;
176
+ price?: CardPrice;
177
+ pricedBy?: "operator" | "registry" | "wire";
178
+ }
179
+
180
+ const turnTokensUsd = (u: TokenUsage, p: { input: number; output: number; cacheRead: number; cacheWrite: number }) =>
181
+ (u.inputTokens * p.input +
182
+ u.outputTokens * p.output +
183
+ (u.cacheReadTokens ?? 0) * p.cacheRead +
184
+ (u.cacheWriteTokens ?? 0) * p.cacheWrite) /
185
+ 1_000_000;
186
+
187
+ /** A card rate over one turn's counts, tiers by pi's rule (`calculateCost`):
188
+ * the input side is input + cache reads + cache writes, and the WHOLE request
189
+ * re-rates at the highest tier whose threshold it exceeds. */
190
+ export function cardPriceUsd(usage: TokenUsage, price: CardPrice): number {
191
+ const inputSide = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
192
+ let rates: { input: number; output: number; cacheRead: number; cacheWrite: number } = price;
193
+ let matched = -1;
194
+ for (const tier of price.tiers ?? []) {
195
+ if (inputSide > tier.inputTokensAbove && tier.inputTokensAbove > matched) {
196
+ rates = tier;
197
+ matched = tier.inputTokensAbove;
198
+ }
199
+ }
200
+ return turnTokensUsd(usage, rates);
201
+ }
202
+
203
+ /**
204
+ * One turn's price, by record 0052's precedence: a provider-reported cost is
205
+ * stored as reported (`provider`; on BYOK the fee and the vendor's upstream
206
+ * charge sum into `usd` with `feeUsd` beside), else the operator's layer
207
+ * (`costs.prices` for the exact ref, or the card's operator-named rate), else
208
+ * the registry layer (the card's rate with its tiers, or the Anthropic family
209
+ * list — costs.md item 4b's fallback, folded here), else `none` — a turn no
210
+ * layer prices, and a stream broken before its usage arrived, stays unpriced.
211
+ */
212
+ export function priceTurn(
213
+ card: TurnPriceCard,
214
+ reported: ReportedCost | undefined,
215
+ usage: TokenUsage | undefined,
216
+ prices: ModelPriceTable = NO_PRICES,
217
+ ): TurnPrice {
218
+ if (reported) {
219
+ if (!reported.byok) return { usd: reported.cost, priceSource: "provider" };
220
+ if (reported.upstreamCost !== undefined) {
221
+ return { usd: reported.cost + reported.upstreamCost, priceSource: "provider", feeUsd: reported.cost };
222
+ }
223
+ // A BYOK chunk without the vendor's charge: the fee is not the turn's
224
+ // dollars, so it rides beside whatever layer below prices the tokens —
225
+ // never as an authoritative figure that understates the turn.
226
+ return { ...priceTurn(card, undefined, usage, prices), feeUsd: reported.cost };
227
+ }
228
+ if (!usage) return { priceSource: "none" };
229
+ const operator = prices[card.ref];
230
+ if (operator) return { usd: turnTokensUsd(usage, operator), priceSource: "operator" };
231
+ if (card.price) {
232
+ return {
233
+ usd: cardPriceUsd(usage, card.price),
234
+ priceSource: card.pricedBy === "operator" ? "operator" : "registry",
235
+ };
236
+ }
237
+ const list = anthropicPriceOf(modelIdOf(card.ref));
238
+ if (list) {
239
+ return {
240
+ usd: turnTokensUsd(usage, {
241
+ input: list.input,
242
+ output: list.output,
243
+ cacheRead: list.cacheRead,
244
+ cacheWrite: list.cacheWrite5m,
245
+ }),
246
+ priceSource: "registry",
247
+ };
248
+ }
249
+ return { priceSource: "none" };
250
+ }
251
+
151
252
  /** What a model's counted tokens cost at a price, USD. */
152
253
  export function modelUsageUsd(m: ModelUsage, p: ModelPrice): number {
153
254
  return (
@@ -211,6 +312,15 @@ export function llmUsdOfUsage(
211
312
  let unpricedTokens = 0;
212
313
  const byModel: Record<string, PricedModelUsage> = {};
213
314
  for (const [ref, m] of Object.entries(usage.byModel)) {
315
+ // A model whose spans priced their own turns (model-proxy item 6) keeps
316
+ // the recorded figure — a provider-reported cost beats any table — and
317
+ // null (a turn without a figure) reads as unpriced, never re-priced here.
318
+ if (m.usd !== undefined) {
319
+ if (m.usd === null) unpricedTokens += m.inputTokens + m.outputTokens + m.cacheReadTokens + m.cacheWriteTokens;
320
+ else usd += m.usd;
321
+ byModel[ref] = { ...m, usd: m.usd };
322
+ continue;
323
+ }
214
324
  const price = modelPriceOf(ref, prices);
215
325
  const priced = price ? modelUsageUsd(m, price) : undefined;
216
326
  if (priced === undefined) unpricedTokens += m.inputTokens + m.outputTokens + m.cacheReadTokens + m.cacheWriteTokens;
@@ -0,0 +1,88 @@
1
+ // A provider's usage object as the proxy's meter reads it
2
+ // (docs/reference/specs/model-proxy.md item 6): the three wire shapes the proxy
3
+ // forwards — Anthropic's `message.usage`, the Chat Completions `usage` and the
4
+ // Responses API's `usage` — each
5
+ // normalized to the one `TokenUsage` every `model.turn` span carries. Undefined
6
+ // unless both core counts are numbers: a malformed or absent usage never fails
7
+ // a completion, it only leaves the turn unmetered. The first two readers were the
8
+ // native adapters' until record 0032's series deleted the adapters; the proxy
9
+ // is their one reader now.
10
+
11
+ import type { TokenUsage } from "../provider.js";
12
+
13
+ /** Anthropic `message.usage` → TokenUsage. */
14
+ export function usageFromAnthropic(u: unknown): TokenUsage | undefined {
15
+ if (typeof u !== "object" || u === null) return undefined;
16
+ const o = u as Record<string, unknown>;
17
+ if (typeof o.input_tokens !== "number" || typeof o.output_tokens !== "number") return undefined;
18
+ const usage: TokenUsage = { inputTokens: o.input_tokens, outputTokens: o.output_tokens };
19
+ if (typeof o.cache_read_input_tokens === "number") usage.cacheReadTokens = o.cache_read_input_tokens;
20
+ if (typeof o.cache_creation_input_tokens === "number") usage.cacheWriteTokens = o.cache_creation_input_tokens;
21
+ return usage;
22
+ }
23
+
24
+ /** OpenAI-style `usage` → TokenUsage. `prompt_tokens_details.cached_tokens` is
25
+ * the cache-read count where the server reports one. */
26
+ export function usageFromOpenAI(u: unknown): TokenUsage | undefined {
27
+ if (typeof u !== "object" || u === null) return undefined;
28
+ const o = u as Record<string, unknown>;
29
+ if (typeof o.prompt_tokens !== "number" || typeof o.completion_tokens !== "number") return undefined;
30
+ const usage: TokenUsage = { inputTokens: o.prompt_tokens, outputTokens: o.completion_tokens };
31
+ const details = o.prompt_tokens_details;
32
+ if (typeof details === "object" && details !== null) {
33
+ const cached = (details as Record<string, unknown>).cached_tokens;
34
+ if (typeof cached === "number") usage.cacheReadTokens = cached;
35
+ }
36
+ return usage;
37
+ }
38
+
39
+ /** A provider-reported cost beside the counters (model-proxy.md item 6): the
40
+ * OpenRouter final chunk's `usage.cost` in USD — on a BYOK turn (`is_byok`)
41
+ * the aggregator's fee alone, with what the vendor billed the operator's own
42
+ * key in `cost_details.upstream_inference_cost`. Read off the same usage
43
+ * object the counters come from; a usage without a finite `cost` reports
44
+ * none (Anthropic's wire never carries one). */
45
+ export interface ReportedCost {
46
+ /** The figure as reported, stored as reported (record 0052). */
47
+ cost: number;
48
+ /** OpenRouter's `is_byok`: the turn billed the operator's own vendor key. */
49
+ byok?: boolean;
50
+ /** `cost_details.upstream_inference_cost`: the vendor's own charge on a BYOK turn. */
51
+ upstreamCost?: number;
52
+ }
53
+
54
+ /** The cost fields of one wire `usage` object, or undefined when it carries none. */
55
+ export function reportedCostOf(u: unknown): ReportedCost | undefined {
56
+ if (typeof u !== "object" || u === null) return undefined;
57
+ const o = u as Record<string, unknown>;
58
+ if (typeof o.cost !== "number" || !Number.isFinite(o.cost)) return undefined;
59
+ const details = o.cost_details;
60
+ const upstream =
61
+ typeof details === "object" && details !== null
62
+ ? (details as Record<string, unknown>).upstream_inference_cost
63
+ : undefined;
64
+ return {
65
+ cost: o.cost,
66
+ ...(o.is_byok === true ? { byok: true } : {}),
67
+ ...(typeof upstream === "number" && Number.isFinite(upstream) ? { upstreamCost: upstream } : {}),
68
+ };
69
+ }
70
+
71
+ /** The Responses API's `usage` → TokenUsage. `input_tokens_details.cached_tokens`
72
+ * is the cache-read count and `input_tokens_details.cache_write_tokens` the
73
+ * cache-write count where a server reports one (OpenAI itself never does; a
74
+ * gateway on the wire may). `output_tokens_details.reasoning_tokens` rides
75
+ * inside `output_tokens` — the shape is read, never a fifth counter. */
76
+ export function usageFromResponses(u: unknown): TokenUsage | undefined {
77
+ if (typeof u !== "object" || u === null) return undefined;
78
+ const o = u as Record<string, unknown>;
79
+ if (typeof o.input_tokens !== "number" || typeof o.output_tokens !== "number") return undefined;
80
+ const usage: TokenUsage = { inputTokens: o.input_tokens, outputTokens: o.output_tokens };
81
+ const details = o.input_tokens_details;
82
+ if (typeof details === "object" && details !== null) {
83
+ const { cached_tokens, cache_write_tokens } = details as Record<string, unknown>;
84
+ if (typeof cached_tokens === "number") usage.cacheReadTokens = cached_tokens;
85
+ if (typeof cache_write_tokens === "number") usage.cacheWriteTokens = cache_write_tokens;
86
+ }
87
+ return usage;
88
+ }
@@ -23,7 +23,21 @@ export interface RegistryCard {
23
23
  * not named (the card's reader applies pi's rule). */
24
24
  thinkingLevelMap?: Record<string, string | null>;
25
25
  input?: string[];
26
- cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
26
+ cost?: {
27
+ input?: number;
28
+ output?: number;
29
+ cacheRead?: number;
30
+ cacheWrite?: number;
31
+ /** pi's long-context tiers: the whole request re-rates at the highest tier
32
+ * whose threshold the input side (input + cache reads + cache writes) exceeds. */
33
+ tiers?: Array<{
34
+ inputTokensAbove?: number;
35
+ input?: number;
36
+ output?: number;
37
+ cacheRead?: number;
38
+ cacheWrite?: number;
39
+ }>;
40
+ };
27
41
  contextWindow?: number;
28
42
  maxTokens?: number;
29
43
  compat?: Record<string, unknown>;
@@ -87,6 +87,7 @@ const CAUSE_OF = {
87
87
  ship_preflight_head_unknown: "system",
88
88
  ship_preflight_closed_resume: "request",
89
89
  ship_preflight_no_task: "request",
90
+ ship_preflight_base_missing: "request",
90
91
  // the plan hand-off's fifteen sentences (two share `plan_history_unavailable`)
91
92
  plan_base_unknown: "request",
92
93
  plan_routed_seed: "request",
@@ -102,13 +103,9 @@ const CAUSE_OF = {
102
103
  plan_runner_conflict: "system",
103
104
  plan_instance_orphaned: "system",
104
105
  plan_start_failed: "system",
105
- // the directive and resolve parsers' thrown errors (A6 of the inventory)
106
- directive_agent: "request",
107
- directive_effort: "request",
108
- directive_budget: "request",
109
- directive_severity: "request",
110
- directive_renewals: "request",
111
- directive_verbosity: "request",
106
+ // the resolve parser's thrown errors (A6 of the inventory); the directive
107
+ // parser no longer refuses — a token whose value is outside its vocabulary
108
+ // is text (the interim grammar, src/directives.ts)
112
109
  provider_unknown: "request",
113
110
  // the model card's refusal (record 0052, model-proxy item 11): a control the
114
111
  // resolved card does not take, named before any card or model call
@@ -108,6 +108,10 @@ export interface Finding {
108
108
  line?: number;
109
109
  /** One line naming the issue; the full explanation lives in the prose. */
110
110
  title: string;
111
+ /** Machine provenance: true only on a check finding the ship round's checks
112
+ * step itself appended (ship/coordinator.ts `checkFinding`) — never set from
113
+ * a reviewer's input, whatever id the reviewer chose. */
114
+ check?: true;
111
115
  }
112
116
 
113
117
  export interface ReviewVerdict {
@@ -397,6 +397,10 @@ export type ShipRoundOutcome =
397
397
  | "approve"
398
398
  | "request_changes"
399
399
  | "no_verdict"
400
+ /** The round's checks step read a failed check at the reviewed head (record
401
+ * 0055): the failures become check findings and the findings step runs as
402
+ * for any changes-requested round. */
403
+ | "checks_failed"
400
404
  | "aborted"
401
405
  | "stopped"
402
406
  /** The coding round ended at its lease with the unit unfinished and the row
@@ -536,6 +540,11 @@ export type RunEvent =
536
540
  callId?: string;
537
541
  spanId?: string;
538
542
  logIndex?: number;
543
+ /** The bound the call itself declared, in ms (a bash `timeout`, clamped
544
+ * by nothing here — pi runs an unbounded call until the loop's end).
545
+ * The stall signal (docs/reference/specs/live-view.md item 32) marks a
546
+ * call past it; absent when the call declared none. */
547
+ boundMs?: number;
539
548
  seq?: number;
540
549
  at?: number;
541
550
  }
@@ -671,8 +680,12 @@ export type RunEvent =
671
680
  * and — for a repo run — the repo, ref, PR number and PR head as resolved
672
681
  * BEFORE the first model turn (`RepoContext`). Published by the dispatcher
673
682
  * right after `input`, once per run, so the run page can head its Request
674
- * block with linked `owner/repo · ref · #PR · sha`. Additive: every
675
- * consumer that only knows the other types keeps working. */
683
+ * block with linked `owner/repo · ref · #PR · sha`. A HOSTED ship parent
684
+ * (record 0060) carries a second one at the hand-off naming its runner
685
+ * instance (`instanceId`): `run_meta` may repeat on such a run, and every
686
+ * reader of a run's instance id resolves it from the LAST `run_meta`
687
+ * carrying one. Additive: every consumer that only knows the other types
688
+ * keeps working. */
676
689
  | {
677
690
  type: "run_meta";
678
691
  agent: string;
@@ -865,6 +878,23 @@ export type RunEvent =
865
878
  * `coordinator_tag` is — so the thread's owner rule can find the instance
866
879
  * from the page's ship run. Additive: unknown → ignored. */
867
880
  | { type: "ship_handoff"; instanceId: string; seq?: number; at?: number }
881
+ /** A coordinator child interrupted under a deploy roll (docs/reference/specs/run-history.md
882
+ * item 47a): the run closes `interrupted` for a restart from its request —
883
+ * a workspace lost across a bot roll, the resident's container replaced
884
+ * with the relaunch refused — and the record says so as a typed fact
885
+ * beside the `resumed`/`sandbox_restarted` notes, `reason` the refusal or
886
+ * note in one line. Published straight to the registry by the reattach
887
+ * path and the run loop's interruption; the parent's wait settles on its
888
+ * Workflow twin (`child-interrupted-<runId>`) beside `run-finished-<runId>`
889
+ * and confirms by `read-record`. Additive: unknown → ignored. */
890
+ | { type: "child_interrupted"; parentInstanceId: string; reason: string; seq?: number; at?: number }
891
+ /** A coordinator child resumed across a deploy roll (run-history item 47a):
892
+ * the same run re-attached its workspace and carries on under its record,
893
+ * tag and budget. Published straight to the registry by the reattach path;
894
+ * the Workflow twin (`child-resumed-<runId>`) tells the parent's wait to
895
+ * keep waiting rather than walk out the chunk asking. Additive: unknown →
896
+ * ignored. */
897
+ | { type: "child_resumed"; parentInstanceId: string; summary: string; seq?: number; at?: number }
868
898
  /** The review post-step's outcome when the verdict landed
869
899
  * (docs/reference/specs/agent-review.md item 18): the pull request it was
870
900
  * posted to, the head it was pinned to (the carried head after a rebase,
@@ -905,6 +935,25 @@ export type RunEvent =
905
935
  seq?: number;
906
936
  at?: number;
907
937
  }
938
+ /** A hosted ship parent's unit fact (record 0060; docs/reference/specs/agent-ship.md
939
+ * item 17): the runner's routes write it to the parent run through the
940
+ * coordinator's `hostPublish` — `unit-start` publishes state `started` with
941
+ * the unit's thread key and lead, `round` the round's outcome as the unit's
942
+ * state, `unit-end` the ending's kind with its report — carrying the pull
943
+ * request number when the unit's row knows one. The run page draws it as a
944
+ * step whose detail is the report; the friction analyzer counts it as a
945
+ * side fact, never activity. Additive: unknown → ignored. */
946
+ | {
947
+ type: "ship_unit";
948
+ unit: string;
949
+ state: string;
950
+ threadKey?: string;
951
+ lead?: string;
952
+ report?: string;
953
+ pr?: number;
954
+ seq?: number;
955
+ at?: number;
956
+ }
908
957
  /** The request router's decision (docs/reference/specs/routing-and-config.md
909
958
  * item 21): the preset a plain message was routed to, the one-line reason
910
959
  * the router gave (redacted, capped — the same text the card's `routed:`
@@ -413,8 +413,10 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
413
413
  // call that already produced its own tool pair, and artifact beside the
414
414
  // attach_file call (or the dispatcher's staging) that moved the file;
415
415
  // review_artifact, pr_description, pr_opened, review_posted, the ship_round
416
- // boundaries and the router's route are published by the dispatcher/pipeline
417
- // outside the model loop entirely. Counting any of them would distort the story.
416
+ // boundaries, the hosted parent's ship_unit facts (record 0060 — written by
417
+ // the runner's routes, not the model) and the router's route are published
418
+ // by the dispatcher/pipeline outside the model loop entirely. Counting any
419
+ // of them would distort the story.
418
420
  if (
419
421
  ev.type === "skill_use" ||
420
422
  ev.type === "artifact" ||
@@ -423,8 +425,11 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
423
425
  ev.type === "pr_opened" ||
424
426
  ev.type === "coordinator_tag" ||
425
427
  ev.type === "ship_handoff" ||
428
+ ev.type === "child_interrupted" ||
429
+ ev.type === "child_resumed" ||
426
430
  ev.type === "review_posted" ||
427
431
  ev.type === "ship_round" ||
432
+ ev.type === "ship_unit" ||
428
433
  ev.type === "route" ||
429
434
  ev.type === "refusal" ||
430
435
  ev.type === "reference" ||
@@ -121,6 +121,17 @@ export interface LiveRunMeta {
121
121
  * the checklist, the pushed branch, the review head, the infra counters). */
122
122
  export type RunState = Record<string, unknown>;
123
123
 
124
+ /** A hosted ship parent's hosting fact on the row's state (record 0060): the
125
+ * runner instance the run hosts and the deadline past which a reclaim closes
126
+ * the row `interrupted` instead of re-hosting it. Set by the ship branch once
127
+ * the instance exists; read by the boot reclaim. */
128
+ export interface HostingState {
129
+ instanceId: string;
130
+ /** Epoch ms: the hand-off time plus the instance's `caps.maxMinutes` plus
131
+ * `HOSTED_DEADLINE_MARGIN_MINUTES`. */
132
+ until: number;
133
+ }
134
+
124
135
  export interface LiveRunRow {
125
136
  runId: string;
126
137
  threadKey: string;
@@ -23,6 +23,17 @@ export interface ModelUsage {
23
23
  outputTokens: number;
24
24
  cacheReadTokens: number;
25
25
  cacheWriteTokens: number;
26
+ /** The model's dollars summed from its turns' own `usd` attrs (the proxy's
27
+ * meter row, model-proxy item 6): a number when every turn that counted
28
+ * tokens carried one, null when such a turn lacked it (unpriced — a sum
29
+ * that left counted tokens out would understate the model; a turn that
30
+ * counted none, an errored call or a retry, leaves nothing out), absent on
31
+ * a record whose turns predate the meter row (the price table then prices
32
+ * the tokens, costs.md item 4b). */
33
+ usd?: number | null;
34
+ /** The distinct `priceSource` words the turns carried, sorted — what the
35
+ * by-model view names as the source. Absent when no turn carried one. */
36
+ priceSources?: string[];
26
37
  }
27
38
 
28
39
  export interface RunUsage {
@@ -46,6 +57,14 @@ export const emptyUsage = (): RunUsage => ({ turns: 0, byModel: {} });
46
57
  /** The run's usage from its events: one `model.turn` span end per provider call. */
47
58
  export function usageOfEvents(events: readonly RunEvent[]): RunUsage {
48
59
  const usage = emptyUsage();
60
+ // The meter row per model (model-proxy item 6): the turns' own dollars and
61
+ // sources. `usd` sums only when every turn that counted tokens carried one —
62
+ // a turn that counted none (an upstream error, a retry the harness's SDK
63
+ // spent, a stream broken before its usage) has nothing a sum could leave
64
+ // out, so it never turns the model unpriced; a model none of whose turns
65
+ // carried price attrs stays without the fields (a record from before the
66
+ // meter row — the table prices it, costs.md item 4b).
67
+ const priced = new Map<string, { usd: number; pricedTurns: number; tokenTurns: number; sources: Set<string> }>();
49
68
  for (const e of events) {
50
69
  if (e.type !== "span_end" || e.name !== MODEL_TURN) continue;
51
70
  const attrs = (e.attrs ?? {}) as Record<string, unknown>;
@@ -57,6 +76,8 @@ export function usageOfEvents(events: readonly RunEvent[]): RunUsage {
57
76
  cacheReadTokens: 0,
58
77
  cacheWriteTokens: 0,
59
78
  };
79
+ const counted =
80
+ num(attrs.inputTokens) + num(attrs.outputTokens) + num(attrs.cacheReadTokens) + num(attrs.cacheWriteTokens);
60
81
  m.turns += 1;
61
82
  m.inputTokens += num(attrs.inputTokens);
62
83
  m.outputTokens += num(attrs.outputTokens);
@@ -64,38 +85,71 @@ export function usageOfEvents(events: readonly RunEvent[]): RunUsage {
64
85
  m.cacheWriteTokens += num(attrs.cacheWriteTokens);
65
86
  usage.byModel[model] = m;
66
87
  usage.turns += 1;
88
+ const hasUsd = typeof attrs.usd === "number" && Number.isFinite(attrs.usd);
89
+ const hasSource = typeof attrs.priceSource === "string" && attrs.priceSource !== "";
90
+ const p = priced.get(model) ?? { usd: 0, pricedTurns: 0, tokenTurns: 0, sources: new Set<string>() };
91
+ if (counted > 0) p.tokenTurns += 1;
92
+ if (hasUsd) {
93
+ p.usd += attrs.usd as number;
94
+ p.pricedTurns += 1;
95
+ }
96
+ if (hasSource) p.sources.add(attrs.priceSource as string);
97
+ priced.set(model, p);
98
+ }
99
+ for (const [model, p] of priced) {
100
+ if (p.pricedTurns === 0 && p.sources.size === 0) continue;
101
+ const m = usage.byModel[model]!;
102
+ m.usd = p.pricedTurns >= p.tokenTurns ? p.usd : null;
103
+ if (p.sources.size > 0) m.priceSources = [...p.sources].sort();
67
104
  }
68
105
  return usage;
69
106
  }
70
107
 
108
+ /** The two sides' `usd` folded: a sum when both carry one, null when either
109
+ * has an unpriced turn, absent when either predates the meter row (the whole
110
+ * cell then prices from the table, never half a figure). */
111
+ const foldUsd = (a: ModelUsage["usd"], b: ModelUsage["usd"]): ModelUsage["usd"] =>
112
+ a === undefined || b === undefined ? undefined : a === null || b === null ? null : a + b;
113
+
71
114
  export function addUsage(a: RunUsage, b: RunUsage): RunUsage {
72
115
  const out: RunUsage = { turns: a.turns + b.turns, byModel: {} };
73
116
  for (const src of [a.byModel, b.byModel]) {
74
117
  for (const [model, m] of Object.entries(src)) {
75
- const acc = out.byModel[model] ?? {
76
- turns: 0,
77
- inputTokens: 0,
78
- outputTokens: 0,
79
- cacheReadTokens: 0,
80
- cacheWriteTokens: 0,
81
- };
118
+ const acc = out.byModel[model];
119
+ if (!acc) {
120
+ out.byModel[model] = { ...m, ...(m.priceSources ? { priceSources: [...m.priceSources] } : {}) };
121
+ continue;
122
+ }
82
123
  acc.turns += m.turns;
83
124
  acc.inputTokens += m.inputTokens;
84
125
  acc.outputTokens += m.outputTokens;
85
126
  acc.cacheReadTokens += m.cacheReadTokens;
86
127
  acc.cacheWriteTokens += m.cacheWriteTokens;
87
- out.byModel[model] = acc;
128
+ const usd = foldUsd(acc.usd, m.usd);
129
+ if (usd === undefined) delete acc.usd;
130
+ else acc.usd = usd;
131
+ const sources = new Set([...(acc.priceSources ?? []), ...(m.priceSources ?? [])]);
132
+ if (sources.size > 0) acc.priceSources = [...sources].sort();
88
133
  }
89
134
  }
90
135
  return out;
91
136
  }
92
137
 
93
- const isModelUsage = (v: unknown): v is ModelUsage =>
94
- typeof v === "object" &&
95
- v !== null &&
96
- (["turns", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"] as const).every(
97
- (k) => typeof (v as Record<string, unknown>)[k] === "number",
138
+ const isModelUsage = (v: unknown): v is ModelUsage => {
139
+ if (typeof v !== "object" || v === null) return false;
140
+ const m = v as Record<string, unknown>;
141
+ if (
142
+ !["turns", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"].every(
143
+ (k) => typeof m[k] === "number",
144
+ )
145
+ )
146
+ return false;
147
+ if (m.usd !== undefined && m.usd !== null && typeof m.usd !== "number") return false;
148
+ return (
149
+ m.priceSources === undefined ||
150
+ (Array.isArray(m.priceSources) && m.priceSources.every((s) => typeof s === "string"))
98
151
  );
152
+ };
99
153
 
100
154
  export function isRunUsage(v: unknown): v is RunUsage {
101
155
  if (typeof v !== "object" || v === null) return false;
@@ -379,6 +379,9 @@ export interface WatchdogSummary {
379
379
  action?: unknown;
380
380
  error?: string;
381
381
  disk?: unknown;
382
+ /** The resident's last memory reading (`{usedBytes, capBytes, percent, …}`,
383
+ * resident-repos.md item 70) when it has one. */
384
+ memory?: unknown;
382
385
  /** What the pass did about the resident's refresh Workflow instance: `{id, action, why}`. */
383
386
  instance?: unknown;
384
387
  }>;
@@ -439,28 +439,55 @@ export const TIMEOUT_ON_LONG_COMMANDS =
439
439
  "State a timeout on any command you expect to run longer than a minute: a timeout that reaches past the loop's " +
440
440
  "end is refused before the command runs, never cut midway.";
441
441
 
442
- /** The fast gates a plan child runs before every push, named one by one
443
- * (agent-ship item 13; agent-coding item 9). "Its cheapest proving checks"
444
- * left the choice to the child, and children chose wrong: prettier was
445
- * reported clean while `format:check` was red, and hygiene imprints reached
446
- * CI that `hygiene:check` would have caught locally. Naming the commands
447
- * makes each gate a receipt — the exit line goes into the handoff's verified
448
- * list, and a gate the child could not run is unproven, never claimed clean. */
442
+ /** The fast gates a plan child runs before every push, named one by one and
443
+ * each scoped to the changed set (agent-ship item 13; agent-coding item 9).
444
+ * "Its cheapest proving checks" left the choice to the child, and children
445
+ * chose wrong in both directions: prettier was reported clean while
446
+ * `format:check` was red, hygiene imprints reached CI that `hygiene:check`
447
+ * would have caught locally — and children ran the whole suite and the whole
448
+ * typecheck on the shared resident, minutes each call, time-sliced against
449
+ * every other run. So the gates are the changed-set forms, the full runs are
450
+ * said to be CI's alone in the same breath, and each gate is a receipt — the
451
+ * exit line goes into the PR description's validation table as the row's proof
452
+ * (the handoff has no verified list; parseHandoff carries deviations, followUps,
453
+ * unproven and landed), and a gate the child could not run goes under the
454
+ * handoff's unproven list, never claimed clean. The tests compare against the
455
+ * pull request's own base — `origin/<base>` — which the render substitutes
456
+ * from the rebase's `onto` when the contract knows it. */
457
+ /** The changed-set test command as the contract renders it before the base is
458
+ * known; the render substitutes the unit's base for `<base>` (`renderFirstInstruction`). */
459
+ export const CHANGED_SET_TEST_PLACEHOLDER = "`npx vitest run --changed origin/<base>`";
460
+
449
461
  export const FAST_GATES_BEFORE_PUSH =
450
- "The fast gates, before every push: `npx prettier --check` on the changed files, `npm run hygiene:check`, " +
451
- "`npm run specs:check`, and `tsc --noEmit` on the touched project under `NODE_OPTIONS=--max-old-space-size=6144`. " +
452
- "Paste each command's exit line into the handoff's verified list; a gate you could not run goes under unproven " +
453
- "and is never claimed clean.";
462
+ "The fast gates, before every push — each scoped to the changed set, never the whole project: " +
463
+ `${CHANGED_SET_TEST_PLACEHOLDER} (the pull request's base; or the touched test files), ` +
464
+ "`tsc --noEmit -p` the touched tsconfig under `NODE_OPTIONS=--max-old-space-size=6144`, " +
465
+ "`npx prettier --check` on the changed files, `npm run hygiene:check` and `npm run specs:check` — " +
466
+ "then your judgement on what else this change needs, not a longer checklist. Every CI pipeline runs the " +
467
+ "tests, the types, the formatting and the full verification on your push, so you never run them again: " +
468
+ "you validate and fix your own change before pushing, at the changed-set scope. Passing the full test suite " +
469
+ "and the full typecheck is NOT part of your criteria: CI is that gate and the only place they run — on a " +
470
+ "shared resident they cost minutes that every other run pays for. " +
471
+ "Paste each command's exit line into the PR description's validation table as the row's proof; a gate you " +
472
+ "could not run goes under the handoff's unproven list and is never claimed clean.";
454
473
 
455
474
  function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
456
475
  const branch = rebase.branch ? `\`${rebase.branch}\`` : "the unit's branch";
457
476
  const onto = rebase.onto ? `\`${rebase.onto}\`` : "the merged parent";
477
+ // the changed-set test run compares against the unit's own base, which the
478
+ // contract knows as the rebase target; unknown, the placeholder stands
479
+ // split/join, never String.replace: a `$` in a branch name is literal text here
480
+ const gates = rebase.onto
481
+ ? FAST_GATES_BEFORE_PUSH.split(`${CHANGED_SET_TEST_PLACEHOLDER} (the pull request's base; or`).join(
482
+ `\`npx vitest run --changed origin/${rebase.onto}\` (or`,
483
+ )
484
+ : FAST_GATES_BEFORE_PUSH;
458
485
  return (
459
486
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
460
487
  `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
461
- `Push the branch as soon as the change exists and the fast gates pass — before the project's ` +
462
- `full verification, which runs after that push with any fix as a further commit; an unpushed tree does not ` +
463
- `survive the run's end. ${FAST_GATES_BEFORE_PUSH} Right before each push, fetch ${onto} again and rebase ` +
488
+ `Push the branch as soon as the change exists and the fast gates pass — the project's full verification ` +
489
+ `is CI's gate, run there after the push with any fix as a further commit; an unpushed tree does not ` +
490
+ `survive the run's end. ${gates} Right before each push, fetch ${onto} again and rebase ` +
464
491
  `once more if it moved while you worked, so the pull request is not born conflicting. At the wind-down note, ` +
465
492
  `commit and push what compiles, say what does not, then answer. ${TIMEOUT_ON_LONG_COMMANDS}`
466
493
  );