@oxygen-agent/cli 1.894.0 → 1.906.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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The four states a plan step may be in.
3
+ *
4
+ * This is the vocabulary `plan_update`'s JSON Schema enforces going forward, and
5
+ * @oxygen/agent-runtime imports it so the schema and this reader can never drift.
6
+ */
7
+ export declare const COPILOT_PLAN_STEP_STATUSES: readonly ["pending", "in_progress", "done", "blocked"];
8
+ export type CopilotPlanStepStatus = (typeof COPILOT_PLAN_STEP_STATUSES)[number];
9
+ /**
10
+ * An unrecognised status resolves to `pending`, never to `done`.
11
+ *
12
+ * This is the fail-closed direction and it is deliberate. Resolving the unknown
13
+ * to `done` would let a typo report a run as finished and silence the open-steps
14
+ * nudge; resolving it to `pending` at worst leaves a finished step looking open,
15
+ * which is visible and self-correcting on the next plan update.
16
+ */
17
+ export declare function normalizeCopilotPlanStepStatus(raw: unknown): CopilotPlanStepStatus;
18
+ /** A step is open until it is done. `blocked` is open too -- it still owes an outcome. */
19
+ export declare function isCopilotPlanStepOpen(step: {
20
+ status: CopilotPlanStepStatus;
21
+ }): boolean;
22
+ /**
23
+ * The step shape after normalisation, before any timing is attached.
24
+ *
25
+ * `title` accepts three spellings because three have been produced: the tool
26
+ * schema and `packages/copilot`'s dormant `PlanStep` type both say `title`, while
27
+ * the model actually shipped `description` on every step of session 4097ef0d.
28
+ * `parsePlanSteps` in the web app required `title` and `continue`d past anything
29
+ * else, which is why it returned null for every real payload ever written.
30
+ */
31
+ export type CopilotPlanRawStep = {
32
+ id: string | null;
33
+ title: string;
34
+ status: CopilotPlanStepStatus;
35
+ estimateSeconds: number | null;
36
+ substeps: Array<{
37
+ id: string | null;
38
+ title: string;
39
+ status: CopilotPlanStepStatus;
40
+ }>;
41
+ };
42
+ /**
43
+ * Pull the step list out of a `plan_updated` payload.
44
+ *
45
+ * Accepts both `{steps}` at the root (what the runtime emits) and the nested
46
+ * `{plan:{steps}}` shape, so a payload written by either producer renders.
47
+ */
48
+ export declare function readCopilotPlanPayload(payload: Record<string, unknown> | null | undefined): {
49
+ summary: string;
50
+ steps: CopilotPlanRawStep[];
51
+ } | null;
52
+ /** Where a step's ETA came from. Rendered beside the number, never hidden. */
53
+ export type CopilotPlanEtaBasis =
54
+ /** A model estimate scaled by how wrong its estimates have been so far. */
55
+ "calibrated"
56
+ /** No usable estimate; the mean of this turn's own completed steps. */
57
+ | "measured"
58
+ /** The model's estimate, with nothing completed yet to calibrate it. */
59
+ | "model"
60
+ /** Nothing to go on. The surface must say so rather than invent a number. */
61
+ | "none";
62
+ export type CopilotPlanSubstep = {
63
+ id: string;
64
+ title: string;
65
+ status: CopilotPlanStepStatus;
66
+ /** `model` when the model declared it, `tool` when it is an observed call. */
67
+ source: "model" | "tool";
68
+ durationMs: number | null;
69
+ startedAt: string | null;
70
+ /**
71
+ * Set only for a `subagent_run`. A sub-agent's own capability calls never reach
72
+ * the ledger (it is handed capability tools directly, bypassing the runtime tool
73
+ * path), so the rollup counts are all there is -- and the rail must not imply a
74
+ * deeper trace exists.
75
+ */
76
+ subagent?: {
77
+ name: string;
78
+ inferences: number;
79
+ capabilityCalls: number;
80
+ };
81
+ };
82
+ export type CopilotPlanStep = {
83
+ id: string;
84
+ title: string;
85
+ status: CopilotPlanStepStatus;
86
+ estimateSeconds: number | null;
87
+ startedAt: string | null;
88
+ endedAt: string | null;
89
+ /** Wall time the step has taken, or took. Null until it starts. */
90
+ elapsedMs: number | null;
91
+ /** Seconds still expected. Null for a done step, or when nothing can be said. */
92
+ etaSeconds: number | null;
93
+ etaBasis: CopilotPlanEtaBasis;
94
+ substeps: CopilotPlanSubstep[];
95
+ };
96
+ export type CopilotPlanProjection = {
97
+ summary: string | null;
98
+ steps: CopilotPlanStep[];
99
+ progress: {
100
+ done: number;
101
+ total: number;
102
+ ratio: number;
103
+ };
104
+ eta: {
105
+ remainingSeconds: number | null;
106
+ basis: CopilotPlanEtaBasis;
107
+ /** How many completed, timed steps the measurement rests on. */
108
+ measuredSteps: number;
109
+ /** True when the turn's wall-clock deadline, not the plan, is the binding bound. */
110
+ deadlineBound: boolean;
111
+ };
112
+ /** Ledger position of the newest plan_updated, so a caller can tell staleness. */
113
+ updatedAtSeq: number;
114
+ updatedAt: string;
115
+ };
116
+ export type CopilotPlanSourceEvent = {
117
+ seq: number;
118
+ kind: string;
119
+ payload: Record<string, unknown> | null;
120
+ created_at: string | Date;
121
+ };
122
+ export type ProjectCopilotPlanInput = {
123
+ /** Ordered by seq. Only plan_updated / tool_call_* / context_assembled are read. */
124
+ events: CopilotPlanSourceEvent[];
125
+ /** The active turn's wall-clock deadline, used only to clamp the total. */
126
+ turnDeadlineAt?: string | Date | null;
127
+ now?: Date;
128
+ };
129
+ /**
130
+ * Rebuild the plan, with timings, from the session's event ledger.
131
+ *
132
+ * Returns null when the session has never published a plan -- which is the correct
133
+ * answer for a trivial turn, since the prompt tells the model to skip `plan_update`
134
+ * entirely and call `finish`. A rail that renders an empty shell for every greeting
135
+ * would be worse than one that stays away.
136
+ */
137
+ export declare function projectCopilotPlan(input: ProjectCopilotPlanInput): CopilotPlanProjection | null;
@@ -0,0 +1,435 @@
1
+ // The Copilot's plan, as a surface can render it: major steps, their substeps,
2
+ // and a timed projection of what is left.
3
+ //
4
+ // The plan itself is not new -- the shared runtime has published a `plan_update`
5
+ // tool and written a `plan_updated` event since the Strands cut-over. What was
6
+ // missing is anything that READS it. This module is that reader, and it lives in
7
+ // @oxygen/shared for one reason: web, CLI and MCP must show the same numbers, and
8
+ // the only way to guarantee that is for there to be one implementation of them.
9
+ // The web surface renders what the API computed here; it does not re-derive.
10
+ //
11
+ // TIME IS DERIVED, NOT STORED. `copilot_events` carries a single `created_at` per
12
+ // row -- there is no per-step duration column anywhere, and there is no ETA field
13
+ // in the tool schema, the payload, or the DB. Everything below is reconstructed
14
+ // from the ordered ledger, which is also why every number reports the BASIS it
15
+ // rests on: a projection that cannot say where its estimate came from is not
16
+ // inspectable, and an uninspectable number on a progress rail is worse than no
17
+ // number at all.
18
+ /**
19
+ * The four states a plan step may be in.
20
+ *
21
+ * This is the vocabulary `plan_update`'s JSON Schema enforces going forward, and
22
+ * @oxygen/agent-runtime imports it so the schema and this reader can never drift.
23
+ */
24
+ export const COPILOT_PLAN_STEP_STATUSES = ["pending", "in_progress", "done", "blocked"];
25
+ /**
26
+ * Forgiving on read, strict on write.
27
+ *
28
+ * The tool schema now pins the enum, but the ledger is append-only and already
29
+ * holds events written when `steps` was `{type:"object", additionalProperties:true}`
30
+ * with no validation at all. Session 4097ef0d wrote `status: "completed"` -- which
31
+ * `hasOpenPlanSteps` (testing `status !== "done"`) read as an OPEN step, misfiring
32
+ * the stop-nudge on a plan that was finished. Normalising on read is what lets one
33
+ * predicate serve both the historical rows and the schema-constrained ones.
34
+ */
35
+ const STATUS_ALIASES = {
36
+ pending: "pending",
37
+ todo: "pending",
38
+ not_started: "pending",
39
+ "not-started": "pending",
40
+ planned: "pending",
41
+ queued: "pending",
42
+ in_progress: "in_progress",
43
+ "in-progress": "in_progress",
44
+ inprogress: "in_progress",
45
+ running: "in_progress",
46
+ active: "in_progress",
47
+ started: "in_progress",
48
+ working: "in_progress",
49
+ done: "done",
50
+ complete: "done",
51
+ completed: "done",
52
+ finished: "done",
53
+ succeeded: "done",
54
+ success: "done",
55
+ blocked: "blocked",
56
+ failed: "blocked",
57
+ error: "blocked",
58
+ stuck: "blocked",
59
+ };
60
+ /**
61
+ * An unrecognised status resolves to `pending`, never to `done`.
62
+ *
63
+ * This is the fail-closed direction and it is deliberate. Resolving the unknown
64
+ * to `done` would let a typo report a run as finished and silence the open-steps
65
+ * nudge; resolving it to `pending` at worst leaves a finished step looking open,
66
+ * which is visible and self-correcting on the next plan update.
67
+ */
68
+ export function normalizeCopilotPlanStepStatus(raw) {
69
+ if (typeof raw !== "string")
70
+ return "pending";
71
+ return STATUS_ALIASES[raw.trim().toLowerCase()] ?? "pending";
72
+ }
73
+ /** A step is open until it is done. `blocked` is open too -- it still owes an outcome. */
74
+ export function isCopilotPlanStepOpen(step) {
75
+ return step.status !== "done";
76
+ }
77
+ function readText(value) {
78
+ return typeof value === "string" ? value.trim() : "";
79
+ }
80
+ function readEstimateSeconds(value) {
81
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
82
+ return null;
83
+ // Same bounds the tool schema advertises. A model that ignores them does not get
84
+ // to skew the calibration factor with a 40-hour step.
85
+ return Math.min(Math.max(Math.round(value), 5), 3600);
86
+ }
87
+ function readRawStep(raw) {
88
+ if (!raw || typeof raw !== "object")
89
+ return null;
90
+ const obj = raw;
91
+ const title = readText(obj.title) || readText(obj.description) || readText(obj.name);
92
+ if (title.length === 0)
93
+ return null;
94
+ const substeps = [];
95
+ if (Array.isArray(obj.substeps)) {
96
+ for (const rawSub of obj.substeps) {
97
+ if (!rawSub || typeof rawSub !== "object")
98
+ continue;
99
+ const sub = rawSub;
100
+ const subTitle = readText(sub.title) || readText(sub.description) || readText(sub.name);
101
+ if (subTitle.length === 0)
102
+ continue;
103
+ substeps.push({
104
+ id: readText(sub.id) || null,
105
+ title: subTitle,
106
+ status: normalizeCopilotPlanStepStatus(sub.status),
107
+ });
108
+ }
109
+ }
110
+ return {
111
+ id: readText(obj.id) || null,
112
+ title,
113
+ status: normalizeCopilotPlanStepStatus(obj.status),
114
+ estimateSeconds: readEstimateSeconds(obj.estimate_seconds ?? obj.estimateSeconds),
115
+ substeps,
116
+ };
117
+ }
118
+ /**
119
+ * Pull the step list out of a `plan_updated` payload.
120
+ *
121
+ * Accepts both `{steps}` at the root (what the runtime emits) and the nested
122
+ * `{plan:{steps}}` shape, so a payload written by either producer renders.
123
+ */
124
+ export function readCopilotPlanPayload(payload) {
125
+ if (!payload || typeof payload !== "object")
126
+ return null;
127
+ const nested = payload.plan;
128
+ const rawSteps = (nested && typeof nested === "object" ? nested.steps : undefined) ??
129
+ payload.steps;
130
+ if (!Array.isArray(rawSteps))
131
+ return null;
132
+ const steps = [];
133
+ for (const raw of rawSteps) {
134
+ const step = readRawStep(raw);
135
+ if (step)
136
+ steps.push(step);
137
+ }
138
+ const summary = readText(payload.summary) ||
139
+ (nested && typeof nested === "object" ? readText(nested.summary) : "");
140
+ if (steps.length === 0 && summary.length === 0)
141
+ return null;
142
+ return { summary, steps };
143
+ }
144
+ /** Bookkeeping calls. Real work, but not work the USER asked for -- not substeps. */
145
+ const BOOKKEEPING_TOOLS = new Set(["plan_update", "set_goal", "finish", "wait"]);
146
+ /**
147
+ * One wild estimate must not be allowed to scale every remaining step.
148
+ *
149
+ * The calibration factor is a ratio of sums, so a single step the model guessed at
150
+ * 10s that actually took 10 minutes would otherwise multiply the whole tail by 60.
151
+ */
152
+ const MIN_CALIBRATION = 0.25;
153
+ const MAX_CALIBRATION = 4;
154
+ const toMs = (value) => value instanceof Date ? value.getTime() : new Date(value).getTime();
155
+ const toIso = (value) => value instanceof Date ? value.toISOString() : new Date(value).toISOString();
156
+ const normalizeTitleKey = (title) => title.replace(/\s+/g, " ").trim().toLowerCase();
157
+ /**
158
+ * Rebuild the plan, with timings, from the session's event ledger.
159
+ *
160
+ * Returns null when the session has never published a plan -- which is the correct
161
+ * answer for a trivial turn, since the prompt tells the model to skip `plan_update`
162
+ * entirely and call `finish`. A rail that renders an empty shell for every greeting
163
+ * would be worse than one that stays away.
164
+ */
165
+ export function projectCopilotPlan(input) {
166
+ const now = input.now ?? new Date();
167
+ const nowMs = now.getTime();
168
+ const events = [...input.events].sort((a, b) => a.seq - b.seq);
169
+ const planEvents = events.filter((event) => event.kind === "plan_updated");
170
+ if (planEvents.length === 0)
171
+ return null;
172
+ // -- Pass 1: fold the successive plans into one tracked set -----------------
173
+ //
174
+ // Identity is what makes timing possible. Match on `id` first, then on the
175
+ // normalised title. A step that matches NEITHER is genuinely new, and a payload
176
+ // in which nothing matches is a plan REPLACEMENT rather than an update -- which
177
+ // is exactly what session 4097ef0d's second plan_update was, having rewritten
178
+ // every description. Carrying timings across a rewrite would attribute one
179
+ // step's clock to an unrelated step; dropping them loses nothing real.
180
+ let tracked = [];
181
+ let summary = "";
182
+ let updatedAtSeq = planEvents[0]?.seq ?? 0;
183
+ let updatedAt = planEvents[0] ? toIso(planEvents[0].created_at) : now.toISOString();
184
+ for (const event of planEvents) {
185
+ const parsed = readCopilotPlanPayload(event.payload);
186
+ if (!parsed)
187
+ continue;
188
+ const atMs = toMs(event.created_at);
189
+ updatedAtSeq = event.seq;
190
+ updatedAt = toIso(event.created_at);
191
+ if (parsed.summary)
192
+ summary = parsed.summary;
193
+ if (parsed.steps.length === 0)
194
+ continue;
195
+ const byId = new Map(tracked.filter((t) => t.id).map((t) => [t.id, t]));
196
+ const byTitle = new Map(tracked.map((t) => [normalizeTitleKey(t.title), t]));
197
+ const next = [];
198
+ for (const [index, raw] of parsed.steps.entries()) {
199
+ const previous = (raw.id ? byId.get(raw.id) : undefined) ?? byTitle.get(normalizeTitleKey(raw.title));
200
+ const entry = previous ?? {
201
+ id: raw.id ?? `step-${index + 1}`,
202
+ title: raw.title,
203
+ status: raw.status,
204
+ estimateSeconds: raw.estimateSeconds,
205
+ startedAtMs: null,
206
+ endedAtMs: null,
207
+ modelSubsteps: [],
208
+ toolSubsteps: [],
209
+ };
210
+ entry.title = raw.title;
211
+ entry.status = raw.status;
212
+ // An estimate is a prior: keep the first one the model committed to rather
213
+ // than letting it be revised down to match reality after the fact.
214
+ if (entry.estimateSeconds === null)
215
+ entry.estimateSeconds = raw.estimateSeconds;
216
+ if (raw.substeps.length > 0)
217
+ entry.modelSubsteps = raw.substeps;
218
+ // The clock starts when the step is first SEEN in progress and stops when it
219
+ // is first seen finished. A step that appears already-done (no in-progress
220
+ // sighting) has no duration, and must not become a measurement sample.
221
+ if (raw.status === "in_progress" && entry.startedAtMs === null)
222
+ entry.startedAtMs = atMs;
223
+ if ((raw.status === "done" || raw.status === "blocked") && entry.endedAtMs === null) {
224
+ entry.endedAtMs = entry.startedAtMs === null ? null : atMs;
225
+ }
226
+ next.push(entry);
227
+ }
228
+ tracked = next;
229
+ }
230
+ if (tracked.length === 0)
231
+ return null;
232
+ // -- Pass 2: attribute observed tool calls to the step that was running -----
233
+ attributeToolCalls(events, planEvents, tracked, nowMs);
234
+ // -- Pass 3: the ETA --------------------------------------------------------
235
+ const samples = tracked
236
+ .filter((t) => t.status === "done" && t.startedAtMs !== null && t.endedAtMs !== null)
237
+ .map((t) => ({ step: t, seconds: (t.endedAtMs - t.startedAtMs) / 1000 }))
238
+ .filter((sample) => sample.seconds > 0);
239
+ const meanMeasured = samples.length > 0 ? samples.reduce((sum, s) => sum + s.seconds, 0) / samples.length : null;
240
+ const calibrationInputs = samples.filter((s) => s.step.estimateSeconds !== null);
241
+ let calibration = null;
242
+ if (calibrationInputs.length > 0) {
243
+ const measured = calibrationInputs.reduce((sum, s) => sum + s.seconds, 0);
244
+ const estimated = calibrationInputs.reduce((sum, s) => sum + (s.step.estimateSeconds ?? 0), 0);
245
+ if (estimated > 0) {
246
+ calibration = Math.min(Math.max(measured / estimated, MIN_CALIBRATION), MAX_CALIBRATION);
247
+ }
248
+ }
249
+ const steps = tracked.map((entry) => {
250
+ // Once a step has started it has an elapsed time: its own end if it finished,
251
+ // otherwise the clock. A step that never started has none -- which is exactly
252
+ // why it is also never a measurement sample.
253
+ const elapsedMs = entry.startedAtMs === null ? null : (entry.endedAtMs ?? nowMs) - entry.startedAtMs;
254
+ let etaSeconds = null;
255
+ let etaBasis = "none";
256
+ if (entry.status === "pending" || entry.status === "in_progress") {
257
+ let budget = null;
258
+ if (entry.estimateSeconds !== null && calibration !== null) {
259
+ budget = entry.estimateSeconds * calibration;
260
+ etaBasis = "calibrated";
261
+ }
262
+ else if (entry.estimateSeconds !== null) {
263
+ budget = entry.estimateSeconds;
264
+ etaBasis = "model";
265
+ }
266
+ else if (meanMeasured !== null) {
267
+ budget = meanMeasured;
268
+ etaBasis = "measured";
269
+ }
270
+ if (budget !== null) {
271
+ const spent = entry.status === "in_progress" && elapsedMs !== null ? elapsedMs / 1000 : 0;
272
+ etaSeconds = Math.max(Math.round(budget - spent), 0);
273
+ }
274
+ }
275
+ return {
276
+ id: entry.id,
277
+ title: entry.title,
278
+ status: entry.status,
279
+ estimateSeconds: entry.estimateSeconds,
280
+ startedAt: entry.startedAtMs === null ? null : new Date(entry.startedAtMs).toISOString(),
281
+ endedAt: entry.endedAtMs === null ? null : new Date(entry.endedAtMs).toISOString(),
282
+ elapsedMs,
283
+ etaSeconds,
284
+ etaBasis,
285
+ substeps: mergeSubsteps(entry),
286
+ };
287
+ });
288
+ const contributing = steps.filter((step) => (step.status === "pending" || step.status === "in_progress") && step.etaSeconds !== null);
289
+ let remainingSeconds = contributing.length > 0 ? contributing.reduce((sum, s) => sum + (s.etaSeconds ?? 0), 0) : null;
290
+ const overallBasis = remainingSeconds === null
291
+ ? "none"
292
+ : calibration !== null
293
+ ? "calibrated"
294
+ : meanMeasured !== null
295
+ ? "measured"
296
+ : "model";
297
+ // The turn cannot outlive its own deadline, so neither may the estimate. When
298
+ // the clamp binds, say so -- "4 minutes" and "4 minutes, but the turn is cut off
299
+ // in 90 seconds" are different facts and the second one is the useful one.
300
+ let deadlineBound = false;
301
+ if (remainingSeconds !== null && input.turnDeadlineAt) {
302
+ const deadlineLeft = (toMs(input.turnDeadlineAt) - nowMs) / 1000;
303
+ if (Number.isFinite(deadlineLeft) && deadlineLeft >= 0 && deadlineLeft < remainingSeconds) {
304
+ remainingSeconds = Math.round(deadlineLeft);
305
+ deadlineBound = true;
306
+ }
307
+ }
308
+ const done = steps.filter((step) => step.status === "done").length;
309
+ return {
310
+ summary: summary.length > 0 ? summary : null,
311
+ steps,
312
+ progress: { done, total: steps.length, ratio: steps.length === 0 ? 0 : done / steps.length },
313
+ eta: {
314
+ remainingSeconds,
315
+ basis: overallBasis,
316
+ measuredSteps: samples.length,
317
+ deadlineBound,
318
+ },
319
+ updatedAtSeq,
320
+ updatedAt,
321
+ };
322
+ }
323
+ /**
324
+ * Pair tool_call_started/finished on `call_id` and file each pair under whichever
325
+ * step was in progress when the call STARTED.
326
+ *
327
+ * Attribution uses the plan as of the call's own seq, not the final plan -- a call
328
+ * made during step 2 belongs to step 2 even after step 4 becomes current.
329
+ */
330
+ function attributeToolCalls(events, planEvents, tracked, nowMs) {
331
+ const byId = new Map(tracked.map((t) => [t.id, t]));
332
+ const byTitle = new Map(tracked.map((t) => [normalizeTitleKey(t.title), t]));
333
+ /** Which tracked step was in progress at a given ledger position. */
334
+ const currentStepAt = (seq) => {
335
+ let current = null;
336
+ for (const planEvent of planEvents) {
337
+ if (planEvent.seq > seq)
338
+ break;
339
+ const parsed = readCopilotPlanPayload(planEvent.payload);
340
+ if (!parsed || parsed.steps.length === 0)
341
+ continue;
342
+ const inProgress = parsed.steps.find((step) => step.status === "in_progress");
343
+ if (!inProgress)
344
+ continue;
345
+ current =
346
+ (inProgress.id ? byId.get(inProgress.id) : undefined) ??
347
+ byTitle.get(normalizeTitleKey(inProgress.title)) ??
348
+ current;
349
+ }
350
+ return current;
351
+ };
352
+ const open = new Map();
353
+ // Sub-agent rollups arrive on a separate context_assembled event keyed by call_id.
354
+ const rollups = new Map();
355
+ for (const event of events) {
356
+ const payload = event.payload ?? {};
357
+ if (event.kind === "context_assembled" && payload.mode === "subagent") {
358
+ const callId = readText(payload.call_id);
359
+ if (callId) {
360
+ rollups.set(callId, {
361
+ name: readText(payload.subagent) || "sub-agent",
362
+ inferences: typeof payload.inferences === "number" ? payload.inferences : 0,
363
+ capabilityCalls: typeof payload.capability_calls === "number" ? payload.capability_calls : 0,
364
+ });
365
+ }
366
+ continue;
367
+ }
368
+ if (event.kind !== "tool_call_started" && event.kind !== "tool_call_finished")
369
+ continue;
370
+ const callId = readText(payload.call_id);
371
+ const tool = readText(payload.tool);
372
+ if (!callId || BOOKKEEPING_TOOLS.has(tool))
373
+ continue;
374
+ const capability = readText(payload.capability) || tool || "capability";
375
+ if (event.kind === "tool_call_started") {
376
+ open.set(callId, {
377
+ step: currentStepAt(event.seq),
378
+ startedMs: toMs(event.created_at),
379
+ capability,
380
+ callId,
381
+ });
382
+ continue;
383
+ }
384
+ const started = open.get(callId);
385
+ open.delete(callId);
386
+ const target = started?.step ?? currentStepAt(event.seq);
387
+ if (!target)
388
+ continue;
389
+ const rollup = rollups.get(callId);
390
+ target.toolSubsteps.push({
391
+ id: callId,
392
+ // `summary` is the tool_call_finished remap the Copilot host writes; it is a
393
+ // line ("63 columns"), which is what a substep wants. Fall back to the
394
+ // capability name rather than to a serialized result.
395
+ title: readText(payload.summary) || capability,
396
+ status: payload.ok === false ? "blocked" : "done",
397
+ source: "tool",
398
+ durationMs: started ? toMs(event.created_at) - started.startedMs : null,
399
+ startedAt: started ? new Date(started.startedMs).toISOString() : null,
400
+ ...(rollup ? { subagent: rollup } : {}),
401
+ });
402
+ }
403
+ // A call still open at the end of the ledger is genuinely running right now.
404
+ for (const [callId, entry] of open) {
405
+ if (!entry.step)
406
+ continue;
407
+ entry.step.toolSubsteps.push({
408
+ id: callId,
409
+ title: entry.capability,
410
+ status: "in_progress",
411
+ source: "tool",
412
+ durationMs: nowMs - entry.startedMs,
413
+ startedAt: new Date(entry.startedMs).toISOString(),
414
+ });
415
+ }
416
+ }
417
+ /**
418
+ * Model-declared substeps first, then the observed calls.
419
+ *
420
+ * Both are kept because they answer different questions: the model's substeps say
421
+ * what it MEANT to do, the tool calls say what it actually did. Showing only the
422
+ * first trusts a field the model frequently omits; showing only the second reduces
423
+ * a plan to a call log.
424
+ */
425
+ function mergeSubsteps(entry) {
426
+ const declared = entry.modelSubsteps.map((sub, index) => ({
427
+ id: sub.id ?? `${entry.id}-sub-${index + 1}`,
428
+ title: sub.title,
429
+ status: sub.status,
430
+ source: "model",
431
+ durationMs: null,
432
+ startedAt: null,
433
+ }));
434
+ return [...declared, ...entry.toolSubsteps];
435
+ }
@@ -18,6 +18,16 @@ export type DncIdentityInput = {
18
18
  detail?: string;
19
19
  metadata?: Record<string, unknown>;
20
20
  };
21
+ /** Durable subject metadata shared by independently enforced DNC ledgers. */
22
+ export declare const DNC_SUBJECT_KINDS: readonly ["person", "company"];
23
+ export type DncSubjectKind = (typeof DNC_SUBJECT_KINDS)[number];
24
+ export type DncSubject = {
25
+ id: string;
26
+ kind: DncSubjectKind;
27
+ name: string | null;
28
+ };
29
+ export declare function dncSubjectMetadata(subject: DncSubject): Record<string, unknown>;
30
+ export declare function readDncSubject(metadata: unknown): DncSubject | null;
21
31
  export type CompanyDncIdentity = {
22
32
  kind: "domain" | "linkedin_company";
23
33
  value: string;
@@ -17,6 +17,29 @@ export const DNC_IDENTITY_KINDS = [
17
17
  "company_domain",
18
18
  "linkedin_company",
19
19
  ];
20
+ /** Durable subject metadata shared by independently enforced DNC ledgers. */
21
+ export const DNC_SUBJECT_KINDS = ["person", "company"];
22
+ export function dncSubjectMetadata(subject) {
23
+ return {
24
+ dnc_subject_id: subject.id,
25
+ dnc_subject_kind: subject.kind,
26
+ ...(subject.name ? { dnc_subject_name: subject.name } : {}),
27
+ };
28
+ }
29
+ export function readDncSubject(metadata) {
30
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
31
+ return null;
32
+ const record = metadata;
33
+ const id = readNonEmptyString(record.dnc_subject_id);
34
+ const kind = readNonEmptyString(record.dnc_subject_kind);
35
+ if (!id || !DNC_SUBJECT_KINDS.includes(kind))
36
+ return null;
37
+ return {
38
+ id,
39
+ kind: kind,
40
+ name: readNonEmptyString(record.dnc_subject_name),
41
+ };
42
+ }
20
43
  const E164_PATTERN = /^\+[1-9]\d{1,14}$/;
21
44
  const DOMAIN_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
22
45
  const COMPANY_DOMAIN_ROW_KEYS = [
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared/dedicated send-drain handoff timing. The worker and every status
3
+ * surface must evaluate the same readiness envelope: inventory existence alone
4
+ * is not proof that a dedicated process may claim sends yet.
5
+ */
6
+ /** Longest a process may reuse one control-plane scope snapshot. */
7
+ export declare const EGRESS_SEND_SCOPE_CACHE_MAX_AGE_MS = 60000;
8
+ /** Longest a tenant cycle may start provider sends under that snapshot. */
9
+ export declare const EGRESS_SEND_AUTHORITY_RETENTION_MS = 45000;
10
+ /** Clock/scheduling margin between the old authority expiring and the new one starting. */
11
+ export declare const EGRESS_SEND_HANDOFF_MARGIN_MS = 5000;
12
+ /**
13
+ * No new drain may become authoritative until every old cached scope AND every
14
+ * send cycle started on that cache's final millisecond have expired.
15
+ */
16
+ export declare const DEDICATED_EGRESS_HANDOFF_DELAY_MS: number;
17
+ export declare const DEDICATED_EGRESS_LIVENESS_MAX_AGE_MS: number;
18
+ export type DedicatedEgressTransportReadiness = {
19
+ ready: true;
20
+ } | {
21
+ ready: false;
22
+ reason: "handoff_pending" | "vendor_verification_missing_or_stale";
23
+ };
24
+ export type FlyDedicatedEgressTransportReadiness = {
25
+ ready: true;
26
+ appName: string;
27
+ } | {
28
+ ready: false;
29
+ reason: "dedicated_app_identity_missing" | "handoff_pending" | "vendor_verification_missing_or_stale";
30
+ };
31
+ export declare function evaluateDedicatedEgressTransportReadiness(input: {
32
+ createdAt: Date | null;
33
+ vendorVerifiedAt: Date | null;
34
+ }, now?: number): DedicatedEgressTransportReadiness;
35
+ /**
36
+ * The app name Fly assigns to this inventory row, or null when the row cannot
37
+ * authorize any dedicated process. Automated Fly purchase stores the app name
38
+ * as metadata.vendor_order_id because one app is exactly one vendor lease.
39
+ */
40
+ export declare function readFlyDedicatedEgressAppName(input: {
41
+ environment: "dev" | "prod";
42
+ metadata: Record<string, unknown> | null;
43
+ }): string | null;
44
+ /**
45
+ * Fly-specific readiness shared by the drain, status, and purchase replay.
46
+ * Timestamps cannot authorize an app by themselves: an orphan process for the
47
+ * same org must match the exact active row's immutable Fly app identity.
48
+ */
49
+ export declare function evaluateFlyDedicatedEgressTransportReadiness(input: {
50
+ createdAt: Date | null;
51
+ vendorVerifiedAt: Date | null;
52
+ environment: "dev" | "prod";
53
+ metadata: Record<string, unknown> | null;
54
+ }, now?: number): FlyDedicatedEgressTransportReadiness;
55
+ /**
56
+ * Retiring a drain is the inverse handoff: shared must remain excluded until
57
+ * every process that could have cached dedicated authority has expired it.
58
+ * Writers stamp this field with DB time while preserving the rest of metadata.
59
+ */
60
+ export declare function dedicatedEgressRetirementStillCooling(metadata: Record<string, unknown> | null, now?: number): boolean;