pi-crew 0.11.0 → 0.11.2
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.
- package/CHANGELOG.md +155 -9
- package/README.md +161 -1037
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -90644
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -3
- package/schema.json +39 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
- package/skills/real-test-pi-crew/SKILL.md +371 -34
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -2
- package/src/config/config.ts +47 -13
- package/src/config/defaults.ts +0 -1
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -5
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/crew-vibes/config.ts +0 -21
- package/src/extension/crew-vibes/index.ts +0 -2
- package/src/extension/crew-vibes/render.ts +1 -50
- package/src/extension/help.ts +21 -12
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/register.ts +7 -2
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +36 -19
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -1
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/subagent-tools.ts +16 -6
- package/src/extension/registration/team-tool.ts +10 -3
- package/src/extension/registration/terminal-status-wiring.ts +172 -0
- package/src/extension/registration/viewers.ts +6 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +33 -4
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/task-display.ts +1 -1
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +18 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +140 -15
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/adaptive-card.ts +65 -0
- package/src/ui/agents-jobs-browser.ts +70 -64
- package/src/ui/card-colors.ts +36 -7
- package/src/ui/dashboard-panes/agents-pane.ts +55 -14
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
- package/src/ui/dashboard-panes/health-pane.ts +7 -5
- package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
- package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
- package/src/ui/dashboard-panes/pane-theme.ts +21 -0
- package/src/ui/dashboard-panes/plan-pane.ts +63 -30
- package/src/ui/dashboard-panes/progress-pane.ts +3 -2
- package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
- package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
- package/src/ui/dwf-phase-display.ts +3 -20
- package/src/ui/format-helpers.ts +22 -0
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/crew-editor.ts +13 -3
- package/src/ui/inline-panel/index.ts +60 -4
- package/src/ui/keybinding-map.ts +251 -35
- package/src/ui/live-conversation-overlay.ts +180 -47
- package/src/ui/live-run-sidebar.ts +134 -55
- package/src/ui/mascot.ts +32 -16
- package/src/ui/overlays/agent-picker-overlay.ts +81 -26
- package/src/ui/overlays/confirm-overlay.ts +55 -29
- package/src/ui/overlays/help-overlay.ts +108 -53
- package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
- package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
- package/src/ui/powerbar-publisher.ts +0 -1
- package/src/ui/rail.ts +333 -0
- package/src/ui/run-dashboard.ts +193 -79
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/settings-overlay.ts +81 -39
- package/src/ui/spinner.ts +26 -2
- package/src/ui/terminal-status.ts +7 -1
- package/src/ui/theme-adapter.ts +0 -45
- package/src/ui/theme-discovery.ts +12 -6
- package/src/ui/tool-progress-formatter.ts +128 -9
- package/src/ui/tool-renderers/brief-mode.ts +10 -67
- package/src/ui/tool-renderers/index.ts +374 -523
- package/src/ui/transcript-viewer.ts +30 -12
- package/src/ui/widget/index.ts +32 -52
- package/src/ui/widget/task-list.ts +64 -32
- package/src/ui/widget/widget-formatters.ts +3 -402
- package/src/ui/widget/widget-model.ts +28 -7
- package/src/ui/widget/widget-renderer.ts +201 -128
- package/src/ui/widget/widget-types.ts +0 -2
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/utils/visual.ts +0 -4
- package/src/worktree/worktree-manager.ts +206 -26
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/extension/crew-vibes/figures.ts +0 -22
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/loaders.ts +0 -6
- package/src/ui/overlay-stack.ts +0 -148
|
@@ -93,7 +93,14 @@ function buildCleanupSessionResourcesOnly(ctx: RegistrationContext): () => void
|
|
|
93
93
|
void disposeObservability(ctx.observabilityState, ctx.cleanedUp);
|
|
94
94
|
ctx.lifecycleState.deliveryCoordinator?.dispose();
|
|
95
95
|
clearHooksScoped();
|
|
96
|
-
|
|
96
|
+
// F13 (RR-018): RPC + the crew global registry are EXTENSION-lifetime
|
|
97
|
+
// resources — they are NOT torn down on a session switch. The RPC
|
|
98
|
+
// handle resolves the current session context at request time
|
|
99
|
+
// (wire-cross-extension.ts), and session_start rebinds the registry to
|
|
100
|
+
// the current session's manifest cache (refreshCrossExtensionWiringForSession).
|
|
101
|
+
// Removing them here used to kill RPC permanently for the process
|
|
102
|
+
// lifetime (4 pi-crew:rpc:* subscriptions → 0 after the first switch,
|
|
103
|
+
// never reinstalled). Full teardown still happens in cleanupRuntime.
|
|
97
104
|
ctx.lifecycleState.overflowTracker?.dispose();
|
|
98
105
|
ctx.lifecycleState.deliveryCoordinator = undefined;
|
|
99
106
|
ctx.lifecycleState.overflowTracker = undefined;
|
|
@@ -104,8 +111,7 @@ function buildCleanupSessionResourcesOnly(ctx: RegistrationContext): () => void
|
|
|
104
111
|
ctx.renderScheduler = undefined;
|
|
105
112
|
ctx.autoRecoveryLast.clear();
|
|
106
113
|
disposeNotifications(ctx.lifecycleState);
|
|
107
|
-
ctx.rpcHandle
|
|
108
|
-
ctx.rpcHandle = undefined;
|
|
114
|
+
// F13: ctx.rpcHandle stays installed here (extension lifetime).
|
|
109
115
|
ctx.disposeI18n();
|
|
110
116
|
ctx.sessionGeneration += 1;
|
|
111
117
|
ctx.currentCtx = undefined;
|
|
@@ -123,3 +123,41 @@ export function __test__subagentSpawnParams(params: Record<string, unknown>, ctx
|
|
|
123
123
|
batchId: typeof params.batch_id === "string" && params.batch_id.trim() ? params.batch_id.trim() : undefined,
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* FINDING 6 (2026-09-23 battery): when a notification is cleared (e.g. the
|
|
129
|
+
* health monitor resolved a false/stale alert), copies of the ORIGINAL
|
|
130
|
+
* warning may still sit in the host session's follow-up queue — the host
|
|
131
|
+
* drains queued follow-ups one per turn boundary, so stale copies kept
|
|
132
|
+
* dripping into the parent conversation for hours after the clear.
|
|
133
|
+
*
|
|
134
|
+
* Feature-detected purge: newer pi runtimes expose
|
|
135
|
+
* `clearQueuedUserMessagesMatching(predicate)` (agent-session), which removes
|
|
136
|
+
* still-QUEUED messages whose text matches. Duck-typed here because the
|
|
137
|
+
* installed runtime surface (0.87.0) does not export it yet — on such hosts
|
|
138
|
+
* this is a documented no-op (returns false); the fire-cap policy in
|
|
139
|
+
* health-notify-policy.ts is the defense that works on all hosts.
|
|
140
|
+
*/
|
|
141
|
+
interface QueuePurgeCapablePi {
|
|
142
|
+
clearQueuedUserMessagesMatching?: (predicate: (text: string) => boolean) => { steering: string[]; followUp: string[] };
|
|
143
|
+
session?: {
|
|
144
|
+
clearQueuedUserMessagesMatching?: (predicate: (text: string) => boolean) => { steering: string[]; followUp: string[] };
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function purgeQueuedAmbientNotifications(pi: ExtensionAPI, match: (text: string) => boolean): boolean {
|
|
149
|
+
const api = pi as unknown as QueuePurgeCapablePi;
|
|
150
|
+
const surface =
|
|
151
|
+
typeof api.clearQueuedUserMessagesMatching === "function"
|
|
152
|
+
? api.clearQueuedUserMessagesMatching
|
|
153
|
+
: typeof api.session?.clearQueuedUserMessagesMatching === "function"
|
|
154
|
+
? api.session.clearQueuedUserMessagesMatching
|
|
155
|
+
: undefined;
|
|
156
|
+
if (!surface) return false;
|
|
157
|
+
try {
|
|
158
|
+
const removed = surface.call(api.session ?? api, match);
|
|
159
|
+
return (removed?.followUp?.length ?? 0) + (removed?.steering?.length ?? 0) > 0;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -291,7 +291,7 @@ export function registerSubagentTools(
|
|
|
291
291
|
}),
|
|
292
292
|
t("agent.retrieveHint"),
|
|
293
293
|
].join("\n"),
|
|
294
|
-
{ agentId: record.id, status: record.status },
|
|
294
|
+
{ agentId: record.id, agentName: record.type, status: record.status },
|
|
295
295
|
),
|
|
296
296
|
terminate: true,
|
|
297
297
|
};
|
|
@@ -322,6 +322,7 @@ export function registerSubagentTools(
|
|
|
322
322
|
].join("\n"),
|
|
323
323
|
{
|
|
324
324
|
agentId: record.id,
|
|
325
|
+
agentName: record.type,
|
|
325
326
|
runId: record.runId,
|
|
326
327
|
status: record.status,
|
|
327
328
|
},
|
|
@@ -459,6 +460,7 @@ export function registerSubagentTools(
|
|
|
459
460
|
text,
|
|
460
461
|
{
|
|
461
462
|
agentId: current.id,
|
|
463
|
+
agentName: current.type,
|
|
462
464
|
runId: current.runId,
|
|
463
465
|
status: current.status,
|
|
464
466
|
},
|
|
@@ -531,14 +533,14 @@ export function registerSubagentTools(
|
|
|
531
533
|
if (!manifestTask) {
|
|
532
534
|
return subagentToolResult(
|
|
533
535
|
`Task '${taskId}' not found in the owning run; cannot steer.`,
|
|
534
|
-
{ agentId: record.id, runId: record.runId, taskId, status: record.status },
|
|
536
|
+
{ agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
|
|
535
537
|
true,
|
|
536
538
|
);
|
|
537
539
|
}
|
|
538
540
|
if (TEAM_TERMINAL_TASK_STATUSES.has(manifestTask.status)) {
|
|
539
541
|
return subagentToolResult(
|
|
540
542
|
`Task '${taskId}' is ${manifestTask.status}; cannot steer.`,
|
|
541
|
-
{ agentId: record.id, runId: record.runId, taskId, status: record.status },
|
|
543
|
+
{ agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
|
|
542
544
|
true,
|
|
543
545
|
);
|
|
544
546
|
}
|
|
@@ -560,7 +562,7 @@ export function registerSubagentTools(
|
|
|
560
562
|
if (existingBytes + Buffer.byteLength(line) > MAX_STEERING_BYTES) {
|
|
561
563
|
return subagentToolResult(
|
|
562
564
|
`Steering file for task '${taskId}' has reached its size cap (${(MAX_STEERING_BYTES / 1024).toFixed(0)}KiB); steer refused.`,
|
|
563
|
-
{ agentId: record.id, runId: record.runId, taskId, status: record.status },
|
|
565
|
+
{ agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
|
|
564
566
|
true,
|
|
565
567
|
);
|
|
566
568
|
}
|
|
@@ -579,7 +581,7 @@ export function registerSubagentTools(
|
|
|
579
581
|
);
|
|
580
582
|
return subagentToolResult(
|
|
581
583
|
`Steer write failed for task '${taskId}': ${err instanceof Error ? err.message : String(err)} (see pi-crew error log)`,
|
|
582
|
-
{ agentId: record.id, runId: record.runId, taskId, status: record.status },
|
|
584
|
+
{ agentId: record.id, agentName: record.type, runId: record.runId, taskId, status: record.status },
|
|
583
585
|
true,
|
|
584
586
|
);
|
|
585
587
|
}
|
|
@@ -590,6 +592,7 @@ export function registerSubagentTools(
|
|
|
590
592
|
].join("\n"),
|
|
591
593
|
{
|
|
592
594
|
agentId: record.id,
|
|
595
|
+
agentName: record.type,
|
|
593
596
|
runId: record.runId,
|
|
594
597
|
taskId,
|
|
595
598
|
status: record.status,
|
|
@@ -666,7 +669,14 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
|
|
|
666
669
|
agents,
|
|
667
670
|
error: record.error,
|
|
668
671
|
});
|
|
669
|
-
|
|
672
|
+
// W2: carry identity in the partial payload too — the renderer's
|
|
673
|
+
// streaming branch reads `result.details.agentName` to label the
|
|
674
|
+
// card. Pi's onUpdate TYPE is content-only; extra keys pass through
|
|
675
|
+
// to renderResult at runtime, hence the deliberate cast.
|
|
676
|
+
onUpdate({
|
|
677
|
+
content: [{ type: "text", text }],
|
|
678
|
+
details: { agentId: record.id, agentName: record.type, runId: record.runId, status: record.status },
|
|
679
|
+
} as never);
|
|
670
680
|
} catch (error) {
|
|
671
681
|
logInternalError("subagent-tools.progress", error, `agentId=${agentRecordId}`);
|
|
672
682
|
}
|
|
@@ -10,7 +10,7 @@ import type { createManifestCache } from "../../runtime/manifest-cache.ts";
|
|
|
10
10
|
import { TeamToolParams, type TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
11
11
|
import { updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
|
|
12
12
|
import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { type ToolRenderContext, teamToolRenderer } from "../../ui/tool-renderers/index.ts";
|
|
14
14
|
import type { CrewWidgetState } from "../../ui/widget/index.ts";
|
|
15
15
|
import { updateCrewWidget } from "../../ui/widget/index.ts";
|
|
16
16
|
import { safeAbort } from "../../utils/safe-abort.ts";
|
|
@@ -260,6 +260,11 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
|
|
|
260
260
|
onJsonEvent: deps.onJsonEvent,
|
|
261
261
|
getRunSnapshotCache: deps.getRunSnapshotCache,
|
|
262
262
|
});
|
|
263
|
+
// W5: surface the team name on the collapsed run card — the renderer
|
|
264
|
+
// reads `details.team`; enrich (non-destructively) for run results.
|
|
265
|
+
if (resolved.action === "run" && !output.isError && output.details && typeof output.details === "object") {
|
|
266
|
+
(output.details as unknown as Record<string, unknown>).team ??= resolved.team ?? resolved.agent ?? "";
|
|
267
|
+
}
|
|
263
268
|
if (resolved.action === "run" && !output.isError && typeof output.details?.runId === "string") {
|
|
264
269
|
pi.appendEntry("crew:run-started", {
|
|
265
270
|
runId: output.details.runId,
|
|
@@ -296,8 +301,10 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
|
|
|
296
301
|
theme,
|
|
297
302
|
context as ToolRenderContext,
|
|
298
303
|
);
|
|
299
|
-
} catch {
|
|
300
|
-
|
|
304
|
+
} catch (e) {
|
|
305
|
+
// W1: fail-visible at the adapter layer too — never fake a "done".
|
|
306
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
307
|
+
return new Text(theme.fg("error", `✖ team render error: ${msg.slice(0, 60)}`), 0, 0);
|
|
301
308
|
}
|
|
302
309
|
},
|
|
303
310
|
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M3-1 (UI-AUDIT P0-2/P1-1): wire the terminal-status controller (tab title +
|
|
3
|
+
* Ghostty OSC 9;4 progress) to the runEventBus.
|
|
4
|
+
*
|
|
5
|
+
* History: `src/ui/terminal-status.ts` shipped in v0.8.3 (819d71ad) with unit
|
|
6
|
+
* tests but NO construction site — `ctx.terminalStatus` was never assigned, so
|
|
7
|
+
* the feature never ran. This module is the missing wire, installed once from
|
|
8
|
+
* `register.ts` in place of the old no-op cleanup handler.
|
|
9
|
+
*
|
|
10
|
+
* Design:
|
|
11
|
+
* - Subscribe to `runEventBus.onAny` and debounce evaluation (250ms) so a
|
|
12
|
+
* burst of task events collapses into one state transition.
|
|
13
|
+
* - State is derived from `listLiveAgents()`: >0 live → active; 0 → idle.
|
|
14
|
+
* `ctx.terminalStatusActive` (already in RegistrationContext) is the single
|
|
15
|
+
* source of truth for the current phase, so `runtime-cleanup.ts` resetting
|
|
16
|
+
* it also resets this state machine on the next event.
|
|
17
|
+
* - On the idle transition, `onRunCompleted()` fires the green Ghostty flash
|
|
18
|
+
* and `onIdle()` runs COMPLETE_FLASH_MS later (title restore + re-assert
|
|
19
|
+
* loop), so the flash is visible before the bar clears.
|
|
20
|
+
* - The `TerminalStatusUi` adapter reads `ctx.currentCtx` dynamically (getter
|
|
21
|
+
* + call-time lookup), so a session swap never leaves the controller
|
|
22
|
+
* holding a stale session's `ui.setTitle` (the P1-10 class of bug).
|
|
23
|
+
* - Everything is best-effort and guarded by `hasUI`: headless / subagent
|
|
24
|
+
* contexts evaluate to a no-op and write nothing.
|
|
25
|
+
* - All timers are `unref()`-ed; dispose unsubscribes, clears timers and
|
|
26
|
+
* disposes the controller (registered via `registerCleanupHandler`, which
|
|
27
|
+
* replaces the old no-op `disposeTerminalStatus` handler in register.ts).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
|
|
32
|
+
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
33
|
+
import {
|
|
34
|
+
buildCrewTitleSegment,
|
|
35
|
+
COMPLETE_FLASH_MS,
|
|
36
|
+
createTerminalStatusController,
|
|
37
|
+
setTerminalTitle,
|
|
38
|
+
type TerminalStatusController,
|
|
39
|
+
type TerminalStatusUi,
|
|
40
|
+
} from "../../ui/terminal-status.ts";
|
|
41
|
+
import { registerCleanupHandler } from "../crew-cleanup.ts";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Structural subset of RegistrationContext this module needs. Declared
|
|
45
|
+
* locally (not imported) so tests can pass a 3-field fake without building
|
|
46
|
+
* the full context, while `register.ts` passes the real context unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export interface TerminalStatusWiringDeps {
|
|
49
|
+
currentCtx: { hasUI: boolean; ui: { setTitle(title: string): void } } | undefined;
|
|
50
|
+
terminalStatus: TerminalStatusController | undefined;
|
|
51
|
+
terminalStatusActive: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Debounce window that collapses an event burst into one evaluation. */
|
|
55
|
+
const EVAL_DEBOUNCE_MS = 250;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Live-agent probe (dependency inversion for tests). Production reads the real
|
|
59
|
+
* registry; tests inject a deterministic count source.
|
|
60
|
+
*/
|
|
61
|
+
let liveAgentProbe: () => number = () => listLiveAgents().length;
|
|
62
|
+
|
|
63
|
+
/** @internal Re-subscribe guard: unsubscribe fn from a previous registration. */
|
|
64
|
+
let currentUnsubscribe: (() => void) | undefined;
|
|
65
|
+
/** @internal Latest install's full dispose (unsubscribe + timers + controller). */
|
|
66
|
+
let currentDispose: (() => void) | undefined;
|
|
67
|
+
|
|
68
|
+
/** Test seam: override the live-agent source (restore with `() => default`). */
|
|
69
|
+
export function __test__setLiveAgentProbe(probe: () => number): void {
|
|
70
|
+
liveAgentProbe = probe;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Test seam: fully dispose any wiring left by a previous install in this process. */
|
|
74
|
+
export function __test__resetTerminalStatusWiring(): void {
|
|
75
|
+
currentDispose?.();
|
|
76
|
+
currentDispose = undefined;
|
|
77
|
+
currentUnsubscribe = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function installTerminalStatus(pi: ExtensionAPI, deps: TerminalStatusWiringDeps): void {
|
|
81
|
+
// A previous registration in this process must not leave a second
|
|
82
|
+
// subscription behind (registerCleanupHandler only keeps the latest
|
|
83
|
+
// dispose fn, so an older subscription would leak otherwise).
|
|
84
|
+
currentUnsubscribe?.();
|
|
85
|
+
|
|
86
|
+
// Dynamic adapter: resolves the CURRENT session's UI at call time, so the
|
|
87
|
+
// controller never captures a session-scoped reference.
|
|
88
|
+
const adapter: TerminalStatusUi = {
|
|
89
|
+
get hasUI(): boolean {
|
|
90
|
+
return deps.currentCtx?.hasUI ?? false;
|
|
91
|
+
},
|
|
92
|
+
ui: {
|
|
93
|
+
setTitle(title: string): void {
|
|
94
|
+
deps.currentCtx?.ui.setTitle(title);
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let evalTimer: ReturnType<typeof setTimeout> | undefined;
|
|
100
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
+
let lastSegment = "";
|
|
102
|
+
|
|
103
|
+
const clearTimers = (): void => {
|
|
104
|
+
if (evalTimer) {
|
|
105
|
+
clearTimeout(evalTimer);
|
|
106
|
+
evalTimer = undefined;
|
|
107
|
+
}
|
|
108
|
+
if (idleTimer) {
|
|
109
|
+
clearTimeout(idleTimer);
|
|
110
|
+
idleTimer = undefined;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const evaluate = (): void => {
|
|
115
|
+
evalTimer = undefined;
|
|
116
|
+
if (!adapter.hasUI) return;
|
|
117
|
+
if (!deps.terminalStatus) deps.terminalStatus = createTerminalStatusController(adapter);
|
|
118
|
+
const controller = deps.terminalStatus;
|
|
119
|
+
|
|
120
|
+
if (liveAgentProbe() > 0) {
|
|
121
|
+
// Active: cancel any pending idle transition, then (re)assert.
|
|
122
|
+
if (idleTimer) {
|
|
123
|
+
clearTimeout(idleTimer);
|
|
124
|
+
idleTimer = undefined;
|
|
125
|
+
}
|
|
126
|
+
if (!deps.terminalStatusActive) {
|
|
127
|
+
deps.terminalStatusActive = true;
|
|
128
|
+
controller.onRunsActive(); // title (best-effort) + Ghostty INDETERMINATE
|
|
129
|
+
lastSegment = buildCrewTitleSegment();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Still active: refresh the title only when the role summary changed
|
|
133
|
+
// (never re-emit the OSC sequence — state 3 persists on its own).
|
|
134
|
+
const segment = buildCrewTitleSegment();
|
|
135
|
+
if (segment && segment !== lastSegment) {
|
|
136
|
+
lastSegment = segment;
|
|
137
|
+
setTerminalTitle(adapter, segment);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (deps.terminalStatusActive) {
|
|
143
|
+
deps.terminalStatusActive = false;
|
|
144
|
+
lastSegment = "";
|
|
145
|
+
controller.onRunCompleted(); // green flash, self-re-evaluates after COMPLETE_FLASH_MS
|
|
146
|
+
idleTimer = setTimeout(() => {
|
|
147
|
+
idleTimer = undefined;
|
|
148
|
+
controller.onIdle(); // title restore + idle re-assert loop + Ghostty CLEAR
|
|
149
|
+
}, COMPLETE_FLASH_MS);
|
|
150
|
+
idleTimer.unref?.();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const unsubscribe = runEventBus.onAny(() => {
|
|
155
|
+
if (evalTimer) return;
|
|
156
|
+
evalTimer = setTimeout(evaluate, EVAL_DEBOUNCE_MS);
|
|
157
|
+
evalTimer.unref?.();
|
|
158
|
+
});
|
|
159
|
+
currentUnsubscribe = unsubscribe;
|
|
160
|
+
const dispose = (): void => {
|
|
161
|
+
unsubscribe();
|
|
162
|
+
currentUnsubscribe = undefined;
|
|
163
|
+
currentDispose = undefined;
|
|
164
|
+
clearTimers();
|
|
165
|
+
deps.terminalStatus?.dispose();
|
|
166
|
+
deps.terminalStatus = undefined;
|
|
167
|
+
deps.terminalStatusActive = false;
|
|
168
|
+
};
|
|
169
|
+
currentDispose = dispose;
|
|
170
|
+
|
|
171
|
+
registerCleanupHandler(pi, { disposeTerminalStatus: dispose });
|
|
172
|
+
}
|
|
@@ -90,10 +90,16 @@ export async function openLiveConversation(
|
|
|
90
90
|
return overlay.render(width);
|
|
91
91
|
},
|
|
92
92
|
handleInput(data: string) {
|
|
93
|
+
// Close keys stay exactly as they were: close the overlay and
|
|
94
|
+
// resolve the custom() promise.
|
|
93
95
|
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
94
96
|
overlay.close();
|
|
95
97
|
done(undefined);
|
|
98
|
+
return;
|
|
96
99
|
}
|
|
100
|
+
// Everything else scrolls the live transcript (P1-3):
|
|
101
|
+
// j/k, ↓/↑, g/G, PgUp/PgDn and the `a` auto-scroll toggle.
|
|
102
|
+
overlay.handleInput(data);
|
|
97
103
|
},
|
|
98
104
|
invalidate() {
|
|
99
105
|
/* overlay polls */
|
|
@@ -35,3 +35,31 @@ export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationC
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* F13 (RR-018): refresh the extension-lifetime cross-extension wiring for a
|
|
41
|
+
* new session. Called on every `session_start`.
|
|
42
|
+
*
|
|
43
|
+
* The RPC handle is installed ONCE at registration and survives session
|
|
44
|
+
* switches — its `getCtx` closure resolves the CURRENT session context at
|
|
45
|
+
* request time, so nothing needs reinstalling (re-registering would double
|
|
46
|
+
* the pi-crew:rpc:* subscriptions). The crew global registry IS rebound to
|
|
47
|
+
* the current session's manifest cache so a cross-project switch never
|
|
48
|
+
* leaves it reading a stale project's runs. Idempotent and cheap (a plain
|
|
49
|
+
* object construction — installCrewGlobalRegistry swaps the module-scoped
|
|
50
|
+
* singleton atomically).
|
|
51
|
+
*/
|
|
52
|
+
export function refreshCrossExtensionWiringForSession(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
53
|
+
if (!ctx.rpcHandle) {
|
|
54
|
+
// Edge: registration never installed the handle (or a full shutdown
|
|
55
|
+
// removed it) — install it once, idempotently.
|
|
56
|
+
installCrossExtensionWiring(pi, ctx);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
|
|
60
|
+
installCrewGlobalRegistry({
|
|
61
|
+
manifestCache: ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd()),
|
|
62
|
+
cwdProvider: () => ctx.currentCtx?.cwd ?? process.cwd(),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readEvents } from "../state/event-log/event-log.ts";
|
|
2
|
+
import { writeArtifact } from "../state/stores/artifact-store.ts";
|
|
3
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
4
|
+
import { formatCost, formatTokens } from "../state/usage.ts";
|
|
5
|
+
import { formatDuration } from "../ui/format-helpers.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* US-021 (2026-09-22) — run comparison (before/after diff of two runs).
|
|
9
|
+
*
|
|
10
|
+
* Scope per spec docs/specs/US-021.md: status delta, per-task
|
|
11
|
+
* status/duration/tokens/cost deltas, event-type set diff, model routing diff.
|
|
12
|
+
* NOT in scope: visual UI, git integration.
|
|
13
|
+
*
|
|
14
|
+
* Determinism: the markdown body contains NO wall-clock (the spec pins this so
|
|
15
|
+
* comparisons are byte-diffable — same pattern as US-022's injectable `now`).
|
|
16
|
+
* Everything rendered derives from run data; the artifact's own write time is
|
|
17
|
+
* filesystem metadata, not content. The output also deliberately contains NO
|
|
18
|
+
* absolute paths (ids, statuses and deltas only), so the run-export
|
|
19
|
+
* `redactHomePaths` pass is unnecessary here — noted because the spec asked.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface TaskDelta {
|
|
23
|
+
id: string;
|
|
24
|
+
statusA?: string;
|
|
25
|
+
statusB?: string;
|
|
26
|
+
/** b − a; undefined when either side lacks the data (task missing, or no timestamps). */
|
|
27
|
+
durationDeltaMs?: number;
|
|
28
|
+
/** b − a; undefined when either side lacks usage. */
|
|
29
|
+
tokensDelta?: number;
|
|
30
|
+
/** b − a; undefined when either side lacks cost. */
|
|
31
|
+
costDelta?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ModelRoutingDiff {
|
|
35
|
+
taskId: string;
|
|
36
|
+
modelA?: string;
|
|
37
|
+
modelB?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RunComparison {
|
|
41
|
+
runA: string;
|
|
42
|
+
runB: string;
|
|
43
|
+
status: { a: string; b: string };
|
|
44
|
+
/** True when every compared field matches (the self-diff invariant). */
|
|
45
|
+
identical: boolean;
|
|
46
|
+
/** One row per task id present in EITHER run. */
|
|
47
|
+
tasks: TaskDelta[];
|
|
48
|
+
tasksOnlyInA: string[];
|
|
49
|
+
tasksOnlyInB: string[];
|
|
50
|
+
events: { addedTypes: string[]; removedTypes: string[] };
|
|
51
|
+
models: ModelRoutingDiff[];
|
|
52
|
+
usage: { tokensDelta: number; costDelta: number; tokensA: number; tokensB: number; costA: number; costB: number };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function taskDurationMs(task: TeamTaskState): number | undefined {
|
|
56
|
+
if (!task.startedAt || !task.finishedAt) return undefined;
|
|
57
|
+
const ms = new Date(task.finishedAt).getTime() - new Date(task.startedAt).getTime();
|
|
58
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function taskTokens(task: TeamTaskState): number | undefined {
|
|
62
|
+
if (!task.usage) return undefined;
|
|
63
|
+
const { input, output } = task.usage;
|
|
64
|
+
if (input === undefined && output === undefined) return undefined;
|
|
65
|
+
return (input ?? 0) + (output ?? 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function taskCost(task: TeamTaskState): number | undefined {
|
|
69
|
+
const cost = task.usage?.cost;
|
|
70
|
+
return typeof cost === "number" && Number.isFinite(cost) ? cost : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sumTokens(tasks: TeamTaskState[]): number {
|
|
74
|
+
return tasks.reduce((acc, task) => acc + (taskTokens(task) ?? 0), 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sumCost(tasks: TeamTaskState[]): number {
|
|
78
|
+
return tasks.reduce((acc, task) => acc + (taskCost(task) ?? 0), 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function eventTypeSet(eventsPath: string): Set<string> {
|
|
82
|
+
const types = new Set<string>();
|
|
83
|
+
for (const event of readEvents(eventsPath)) {
|
|
84
|
+
if (event.type) types.add(event.type);
|
|
85
|
+
}
|
|
86
|
+
return types;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function compareRuns(
|
|
90
|
+
manifestA: TeamRunManifest,
|
|
91
|
+
tasksA: TeamTaskState[],
|
|
92
|
+
manifestB: TeamRunManifest,
|
|
93
|
+
tasksB: TeamTaskState[],
|
|
94
|
+
): RunComparison {
|
|
95
|
+
const byIdA = new Map(tasksA.map((task) => [task.id, task] as const));
|
|
96
|
+
const byIdB = new Map(tasksB.map((task) => [task.id, task] as const));
|
|
97
|
+
const allIds = [...new Set([...byIdA.keys(), ...byIdB.keys()])].sort();
|
|
98
|
+
|
|
99
|
+
const tasks: TaskDelta[] = allIds.map((id) => {
|
|
100
|
+
const a = byIdA.get(id);
|
|
101
|
+
const b = byIdB.get(id);
|
|
102
|
+
const delta: TaskDelta = { id };
|
|
103
|
+
if (a) delta.statusA = a.status;
|
|
104
|
+
if (b) delta.statusB = b.status;
|
|
105
|
+
const durA = a ? taskDurationMs(a) : undefined;
|
|
106
|
+
const durB = b ? taskDurationMs(b) : undefined;
|
|
107
|
+
if (durA !== undefined && durB !== undefined) delta.durationDeltaMs = durB - durA;
|
|
108
|
+
const tokA = a ? taskTokens(a) : undefined;
|
|
109
|
+
const tokB = b ? taskTokens(b) : undefined;
|
|
110
|
+
if (tokA !== undefined && tokB !== undefined) delta.tokensDelta = tokB - tokA;
|
|
111
|
+
const costA = a ? taskCost(a) : undefined;
|
|
112
|
+
const costB = b ? taskCost(b) : undefined;
|
|
113
|
+
if (costA !== undefined && costB !== undefined) delta.costDelta = costB - costA;
|
|
114
|
+
return delta;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const typesA = eventTypeSet(manifestA.eventsPath);
|
|
118
|
+
const typesB = eventTypeSet(manifestB.eventsPath);
|
|
119
|
+
|
|
120
|
+
const models: ModelRoutingDiff[] = allIds
|
|
121
|
+
.filter((id) => {
|
|
122
|
+
const a = byIdA.get(id)?.model;
|
|
123
|
+
const b = byIdB.get(id)?.model;
|
|
124
|
+
return a !== undefined || b !== undefined;
|
|
125
|
+
})
|
|
126
|
+
.map((id) => ({ taskId: id, modelA: byIdA.get(id)?.model, modelB: byIdB.get(id)?.model }))
|
|
127
|
+
.filter((diff) => diff.modelA !== diff.modelB);
|
|
128
|
+
|
|
129
|
+
const statusEqual = manifestA.status === manifestB.status;
|
|
130
|
+
const noTaskDeltas = tasks.every(
|
|
131
|
+
(delta) =>
|
|
132
|
+
delta.statusA === delta.statusB &&
|
|
133
|
+
(delta.durationDeltaMs ?? 0) === 0 &&
|
|
134
|
+
(delta.tokensDelta ?? 0) === 0 &&
|
|
135
|
+
(delta.costDelta ?? 0) === 0,
|
|
136
|
+
);
|
|
137
|
+
const tokensA = sumTokens(tasksA);
|
|
138
|
+
const tokensB = sumTokens(tasksB);
|
|
139
|
+
const costA = sumCost(tasksA);
|
|
140
|
+
const costB = sumCost(tasksB);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
runA: manifestA.runId,
|
|
144
|
+
runB: manifestB.runId,
|
|
145
|
+
status: { a: manifestA.status, b: manifestB.status },
|
|
146
|
+
identical:
|
|
147
|
+
statusEqual && noTaskDeltas && tasksA.length === tasksB.length && tokensA === tokensB && costA === costB && models.length === 0,
|
|
148
|
+
tasks,
|
|
149
|
+
tasksOnlyInA: tasksA.map((t) => t.id).filter((id) => !byIdB.has(id)),
|
|
150
|
+
tasksOnlyInB: tasksB.map((t) => t.id).filter((id) => !byIdA.has(id)),
|
|
151
|
+
events: {
|
|
152
|
+
addedTypes: [...typesB].filter((t) => !typesA.has(t)).sort(),
|
|
153
|
+
removedTypes: [...typesA].filter((t) => !typesB.has(t)).sort(),
|
|
154
|
+
},
|
|
155
|
+
models,
|
|
156
|
+
usage: { tokensDelta: tokensB - tokensA, costDelta: costB - costA, tokensA, tokensB, costA, costB },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function signed(value: number, format: (abs: number) => string): string {
|
|
161
|
+
if (value === 0) return "0";
|
|
162
|
+
return value > 0 ? `+${format(value)}` : `-${format(-value)}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderComparisonMarkdown(comparison: RunComparison): string {
|
|
166
|
+
const lines: string[] = [
|
|
167
|
+
`# pi-crew comparison ${comparison.runA} vs ${comparison.runB}`,
|
|
168
|
+
"",
|
|
169
|
+
`Status: a=${comparison.status.a} b=${comparison.status.b}`,
|
|
170
|
+
`Comparison: ${comparison.identical ? "identical" : "different"}`,
|
|
171
|
+
"",
|
|
172
|
+
"## Tasks",
|
|
173
|
+
...(comparison.tasks.length
|
|
174
|
+
? comparison.tasks.map((delta) => {
|
|
175
|
+
const parts = [`- ${delta.id}: a=${delta.statusA ?? "(absent)"} b=${delta.statusB ?? "(absent)"}`];
|
|
176
|
+
if (delta.durationDeltaMs !== undefined) parts.push(`Δdur=${signed(delta.durationDeltaMs, formatDuration)}`);
|
|
177
|
+
if (delta.tokensDelta !== undefined) parts.push(`Δtok=${signed(delta.tokensDelta, formatTokens)}`);
|
|
178
|
+
if (delta.costDelta !== undefined) parts.push(`Δcost=${signed(delta.costDelta, formatCost)}`);
|
|
179
|
+
return parts.join(" ");
|
|
180
|
+
})
|
|
181
|
+
: ["- (none)"]),
|
|
182
|
+
`Only in a: ${comparison.tasksOnlyInA.length ? comparison.tasksOnlyInA.join(", ") : "(none)"}`,
|
|
183
|
+
`Only in b: ${comparison.tasksOnlyInB.length ? comparison.tasksOnlyInB.join(", ") : "(none)"}`,
|
|
184
|
+
"",
|
|
185
|
+
"## Models",
|
|
186
|
+
...(comparison.models.length
|
|
187
|
+
? comparison.models.map((m) => `- ${m.taskId}: ${m.modelA ?? "(none)"} -> ${m.modelB ?? "(none)"}`)
|
|
188
|
+
: ["- (no routing differences)"]),
|
|
189
|
+
"",
|
|
190
|
+
"## Events",
|
|
191
|
+
`Added types: ${comparison.events.addedTypes.length ? comparison.events.addedTypes.join(", ") : "(none)"}`,
|
|
192
|
+
`Removed types: ${comparison.events.removedTypes.length ? comparison.events.removedTypes.join(", ") : "(none)"}`,
|
|
193
|
+
"",
|
|
194
|
+
"## Totals",
|
|
195
|
+
`Tokens: a=${formatTokens(comparison.usage.tokensA)} b=${formatTokens(comparison.usage.tokensB)} Δ=${signed(comparison.usage.tokensDelta, formatTokens)}`,
|
|
196
|
+
`Cost: a=${formatCost(comparison.usage.costA)} b=${formatCost(comparison.usage.costB)} Δ=${signed(comparison.usage.costDelta, formatCost)}`,
|
|
197
|
+
"",
|
|
198
|
+
];
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Compare two runs and write the markdown artifact under the FIRST run's
|
|
204
|
+
* artifactsRoot (spec AC-5) at `compare/<runA>__<runB>.md`.
|
|
205
|
+
*/
|
|
206
|
+
export function runCompareBundle(
|
|
207
|
+
manifestA: TeamRunManifest,
|
|
208
|
+
tasksA: TeamTaskState[],
|
|
209
|
+
manifestB: TeamRunManifest,
|
|
210
|
+
tasksB: TeamTaskState[],
|
|
211
|
+
): { comparison: RunComparison; markdownPath: string } {
|
|
212
|
+
const comparison = compareRuns(manifestA, tasksA, manifestB, tasksB);
|
|
213
|
+
const artifact = writeArtifact(manifestA.artifactsRoot, {
|
|
214
|
+
kind: "summary",
|
|
215
|
+
relativePath: `compare/${manifestA.runId}__${manifestB.runId}.md`,
|
|
216
|
+
producer: "run-compare",
|
|
217
|
+
content: renderComparisonMarkdown(comparison),
|
|
218
|
+
});
|
|
219
|
+
return { comparison, markdownPath: artifact.path };
|
|
220
|
+
}
|