pi-crew 0.11.5 → 0.11.7

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 (68) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +1 -1
  3. package/dist/index.mjs +503 -493
  4. package/docs/dynamic-workflows.md +37 -0
  5. package/package.json +13 -9
  6. package/scripts/analyze-run.mjs +95 -11
  7. package/skills/model-routing-context/SKILL.md +4 -3
  8. package/skills/real-test-pi-crew/SKILL.md +9 -6
  9. package/src/config/config-validation.ts +28 -0
  10. package/src/config/env-vars.ts +29 -0
  11. package/src/config/types.ts +47 -0
  12. package/src/extension/crew-vibes/index.ts +29 -10
  13. package/src/extension/pi-api.ts +1 -0
  14. package/src/extension/registration/lifecycle-handlers.ts +65 -3
  15. package/src/extension/team-tool/respond.ts +54 -0
  16. package/src/extension/team-tool/status.ts +10 -0
  17. package/src/extension/team-tool.ts +393 -3
  18. package/src/extension/webhook-notify.ts +23 -1
  19. package/src/observability/exporters/otlp-exporter.ts +93 -11
  20. package/src/prompt/prompt-runtime.ts +46 -8
  21. package/src/runtime/agent-control.ts +1 -15
  22. package/src/runtime/background-runner.ts +3 -0
  23. package/src/runtime/broker/crew-broker.ts +12 -103
  24. package/src/runtime/broker/protocol/request-parsers.ts +9 -1
  25. package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
  26. package/src/runtime/child-pi/child-pi-spawn.ts +12 -4
  27. package/src/runtime/child-pi/child-pi.ts +93 -4
  28. package/src/runtime/child-pi/session-recovery.ts +334 -0
  29. package/src/runtime/dispatch-batch.ts +3 -1
  30. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
  31. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
  32. package/src/runtime/live-session/live-agent-manager.ts +35 -0
  33. package/src/runtime/live-session/live-session-runtime.ts +34 -5
  34. package/src/runtime/mcp-proxy.ts +110 -7
  35. package/src/runtime/model/model-scope.ts +141 -21
  36. package/src/runtime/model/pi-args.ts +115 -11
  37. package/src/runtime/model/provider-extensions.ts +67 -1
  38. package/src/runtime/peer-dep.ts +24 -0
  39. package/src/runtime/rpc/README.md +100 -0
  40. package/src/runtime/rpc/frame-client.ts +328 -0
  41. package/src/runtime/rpc/rpc-worker.ts +151 -0
  42. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  43. package/src/runtime/run-worker.ts +14 -0
  44. package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
  45. package/src/runtime/stale-reconciler.ts +47 -0
  46. package/src/runtime/surface/surface-spawn.ts +8 -1
  47. package/src/runtime/task-output-context.ts +191 -32
  48. package/src/runtime/task-packet.ts +3 -0
  49. package/src/runtime/task-runner/child-executor.ts +170 -6
  50. package/src/runtime/task-runner/output-splitter.ts +48 -5
  51. package/src/runtime/task-runner/post-execution.ts +5 -5
  52. package/src/runtime/task-runner/pre-execution.ts +32 -4
  53. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  54. package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
  55. package/src/schema/config-schema.ts +25 -0
  56. package/src/schema/team-tool-schema.ts +35 -0
  57. package/src/state/contracts.ts +16 -0
  58. package/src/state/crew-init.ts +114 -0
  59. package/src/state/stores/active-run-registry.ts +13 -6
  60. package/src/state/types.ts +0 -8
  61. package/src/ui/goal-flag.ts +44 -0
  62. package/src/ui/live-run-sidebar.ts +5 -1
  63. package/src/ui/pi-ui-compat.ts +37 -11
  64. package/src/ui/run-dashboard.ts +2 -1
  65. package/src/ui/widget/index.ts +0 -1
  66. package/src/ui/widget/widget-model.ts +6 -2
  67. package/src/utils/ip-literal.ts +145 -0
  68. package/src/workflows/workflow-config.ts +6 -0
@@ -34,6 +34,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
34
34
  let providerTimer: ReturnType<typeof setInterval> | undefined;
35
35
  let lastProviderUsage: ProviderUsage | null = null;
36
36
  let currentProvider: string | undefined;
37
+ // Latest ctx of the CURRENT session, re-fetched at every publish/tick.
38
+ // Never hold a ctx captured from an earlier event across a session
39
+ // replacement (newSession/fork/switchSession/reload): pi invalidates every
40
+ // prior ctx, and even reading ctx.hasUI then throws the stale-ctx error.
41
+ // session_shutdown clears this so in-flight fetch continuations no-op
42
+ // instead of publishing into a dead session (WI-1, SDD-4).
43
+ let sessionCtx: ExtensionContext | undefined;
37
44
 
38
45
  function themeOf(ctx: ExtensionContext) {
39
46
  return asCrewTheme(ctx.hasUI ? ctx.ui.theme : undefined);
@@ -57,29 +64,37 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
57
64
  /** Fetch provider usage for currentProvider and publish the quota status.
58
65
  * Called on start, on each timer tick, and immediately when the
59
66
  * provider changes (model_select) so the quota reflects the new
60
- * provider without waiting for the next tick. */
61
- async function fetchProviderAndRefresh(ctx: ExtensionContext): Promise<void> {
67
+ * provider without waiting for the next tick. The ctx is resolved at
68
+ * call time (never captured) and re-checked after the await: if the
69
+ * session was replaced while the fetch was in flight, the continuation
70
+ * is dropped — the new session re-arms its own publish. */
71
+ async function fetchProviderAndRefresh(): Promise<void> {
72
+ const ctx = sessionCtx;
73
+ if (!ctx) return; // session already shut down or replaced — nothing to publish to
62
74
  if (!config.enabled || !config.capacity.providerUsage) {
63
75
  lastProviderUsage = null;
64
76
  publishQuotaStatus(ctx);
65
77
  return;
66
78
  }
79
+ let usage: ProviderUsage | null = null;
67
80
  try {
68
- lastProviderUsage = await fetchProviderUsage(config.capacity.providerRefreshMs, currentProvider);
81
+ usage = await fetchProviderUsage(config.capacity.providerRefreshMs, currentProvider);
69
82
  } catch {
70
83
  // Never crash on provider fetch failure
71
- lastProviderUsage = null;
84
+ usage = null;
72
85
  }
86
+ if (sessionCtx !== ctx) return; // replaced mid-fetch: old ctx must never be used
87
+ lastProviderUsage = usage;
73
88
  publishQuotaStatus(ctx);
74
89
  }
75
90
 
76
- function startProviderTimer(ctx: ExtensionContext): void {
91
+ function startProviderTimer(): void {
77
92
  if (providerTimer) return;
78
93
  if (!config.capacity.providerUsage) return;
79
94
  const interval = Math.max(10000, config.capacity.providerRefreshMs);
80
95
 
81
- fetchProviderAndRefresh(ctx); // Fetch immediately on start
82
- providerTimer = setInterval(() => fetchProviderAndRefresh(ctx), interval);
96
+ fetchProviderAndRefresh(); // Fetch immediately on start
97
+ providerTimer = setInterval(() => fetchProviderAndRefresh(), interval);
83
98
  providerTimer.unref?.();
84
99
  }
85
100
 
@@ -90,12 +105,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
90
105
  clearVibesStatus(ctx);
91
106
  return;
92
107
  }
93
- if (config.capacity.providerUsage) startProviderTimer(ctx);
108
+ if (config.capacity.providerUsage) startProviderTimer();
94
109
  else publishQuotaStatus(ctx); // clears the stale status when disabled
95
110
  }
96
111
 
97
112
  pi.on("session_start", (_event, ctx) => {
98
113
  stopProviderTimer();
114
+ sessionCtx = ctx; // fresh ctx of the current session — any prior ctx is invalid now
99
115
  config = loadConfig();
100
116
  clearProviderUsageCache();
101
117
  // Initialize provider from current model — model_select only fires on manual switch
@@ -104,19 +120,22 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
104
120
  clearVibesStatus(ctx);
105
121
  return;
106
122
  }
107
- startProviderTimer(ctx);
123
+ startProviderTimer();
108
124
  });
109
125
 
110
126
  pi.on("model_select", (event, ctx) => {
127
+ sessionCtx = ctx;
111
128
  currentProvider = (event as { model?: { provider?: string } }).model?.provider;
112
129
  clearProviderUsageCache();
113
130
  // Fetch immediately so the quota reflects the new provider without
114
131
  // waiting for the next timer tick.
115
- fetchProviderAndRefresh(ctx);
132
+ fetchProviderAndRefresh();
116
133
  });
117
134
 
118
135
  pi.on("session_shutdown", (_event, ctx) => {
119
136
  stopProviderTimer();
137
+ sessionCtx = undefined; // later continuations must no-op, never touch a disposed ctx
138
+ // ctx is still valid here: pi emits session_shutdown before dispose().
120
139
  clearVibesStatus(ctx);
121
140
  });
122
141
 
@@ -34,6 +34,7 @@ export type {
34
34
  ExtensionAPI,
35
35
  ExtensionCommandContext,
36
36
  ExtensionContext,
37
+ ExtensionToolContext,
37
38
  ToolDefinition,
38
39
  } from "@earendil-works/pi-coding-agent";
39
40
 
@@ -42,6 +42,7 @@ import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduling/sched
42
42
  import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
43
43
  import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
44
44
  import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
45
+ import type { ReconcileResult } from "../../runtime/stale-reconciler.ts";
45
46
  import { loadTasksWithRecovery } from "../../state/stores/manifest-io.ts";
46
47
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
47
48
  import type { TeamRunManifest } from "../../state/types.ts";
@@ -88,6 +89,62 @@ export function installSessionLifecycleHandlers(pi: ExtensionAPI, ctx: Registrat
88
89
  installModelTrackingHandlers(pi);
89
90
  }
90
91
 
92
+ // ─── NEW-2 (SDD-4 follow-up, P3 review MAJOR 1): honest stale-reconcile notify ──
93
+
94
+ /**
95
+ * Repaired runIds already surfaced by the session-start stale-reconcile
96
+ * notify in THIS process, bounded so a long-lived session cannot grow the set
97
+ * unboundedly (overflow clears — a re-notify after 256+ distinct repairs is
98
+ * acceptable noise, an unbounded leak is not).
99
+ *
100
+ * Cross-restart dedupe is structural, not tracked here: a real repair persists
101
+ * a terminal run status, so the run drops out of the reconcile input and can
102
+ * never re-notify. The set guards the two in-process repeat windows — session
103
+ * reload/fork re-firing session_start, and a repair whose persistence failed
104
+ * (disk error / lock steal leaves the run reconcileable while the notify still
105
+ * claimed it). Observed pre-fix (2026-09-29): 77 dishonest notifies/day,
106
+ * ~3x per runId, because non-repaired verdicts (blocked_awaiting_approval,
107
+ * waiting_answer, result_exists) never persist anything and re-fired the
108
+ * "Found and repaired ghost runs" text on every session start.
109
+ */
110
+ const notifiedRepairedRunIds = new Set<string>();
111
+ const NOTIFIED_REPAIRED_RUN_IDS_CAP = 256;
112
+
113
+ /** What the session-start stale-reconcile notify should say (or null: say nothing). */
114
+ export interface StaleReconcileNotifyPlan {
115
+ title: string;
116
+ body: string;
117
+ }
118
+
119
+ /**
120
+ * NEW-2: decide the operator notification for a session-start reconcile
121
+ * batch. Honest by construction — a notify is emitted ONLY when reconcile
122
+ * actually repaired something (`repaired === true`): non-repaired verdicts
123
+ * are intentional states (plan approval pending, ask parked, result already
124
+ * exists) that the run dashboard already surfaces, so claiming "repaired"
125
+ * for them was a false system statement. The title says exactly what
126
+ * happened ("Repaired N stale run(s)"), the body names each repaired runId
127
+ * with its verdict, and each repaired runId notifies at most once per
128
+ * process (bounded set above).
129
+ */
130
+ export function decideStaleReconcileNotification(staleResults: ReconcileResult[]): StaleReconcileNotifyPlan | null {
131
+ const repaired = staleResults.filter((r) => r.repaired === true && !notifiedRepairedRunIds.has(r.runId));
132
+ if (repaired.length === 0) return null;
133
+ for (const r of repaired) {
134
+ if (notifiedRepairedRunIds.size >= NOTIFIED_REPAIRED_RUN_IDS_CAP) notifiedRepairedRunIds.clear();
135
+ notifiedRepairedRunIds.add(r.runId);
136
+ }
137
+ return {
138
+ title: `Repaired ${repaired.length} stale run(s)`,
139
+ body: `Repaired stale runs from previous sessions: ${repaired.map((r) => `${r.runId} (${r.verdict})`).join(", ")}`,
140
+ };
141
+ }
142
+
143
+ /** Test seam: reset the per-process dedupe set between unit tests. */
144
+ export function __test__resetNotifiedRepairedRunIds(): void {
145
+ notifiedRepairedRunIds.clear();
146
+ }
147
+
91
148
  /**
92
149
  * Hand over the outcome of runs detached by an agent-view switch.
93
150
  *
@@ -460,13 +517,18 @@ async function runDeferredSessionCleanup(
460
517
  // session_start callback synchronous; notify when the result lands.
461
518
  void reconcileAllStaleRuns(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd), Date.now(), currentSessionId)
462
519
  .then((staleResults) => {
463
- if ((staleResults ?? []).length > 0) {
520
+ // NEW-2 (SDD-4 follow-up): honest notify — only actual repairs are
521
+ // reported, each repaired runId at most once per process. Non-repaired
522
+ // verdicts (blocked_awaiting_approval / waiting_answer /
523
+ // result_exists) must NEVER be claimed as "repaired".
524
+ const plan = decideStaleReconcileNotification(staleResults ?? []);
525
+ if (plan) {
464
526
  ctx.notifyOperator({
465
527
  id: "stale_reconcile",
466
528
  severity: "info",
467
529
  source: "crash-recovery",
468
- title: `Reconciled ${staleResults.length} stale run(s)`,
469
- body: `Found and repaired ghost runs from previous sessions: ${staleResults.map((r) => r.runId).join(", ")}`,
530
+ title: plan.title,
531
+ body: plan.body,
470
532
  });
471
533
  }
472
534
  })
@@ -1,6 +1,7 @@
1
1
  import { renderAskAnswer } from "../../prompt/prompt-runtime.ts";
2
2
  import { readCrewAgents, recordFromTask, saveCrewAgents } from "../../runtime/crew-agent-records.ts";
3
3
  import { isWaitingWorkerAlive } from "../../runtime/dispatch-batch.ts";
4
+ import { checkProcessLiveness } from "../../runtime/process-status.ts";
4
5
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
5
6
  import { withRunLockSync } from "../../state/coordination/locks.ts";
6
7
  import { appendMailboxMessage, readMailbox, updateMailboxMessageReply } from "../../state/coordination/mailbox.ts";
@@ -142,6 +143,17 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
142
143
  /** Exactly-once no-ops: a mailbox response already exists for the questionId. */
143
144
  const noopIds: string[] = [];
144
145
  const answeredEvents: Array<{ taskId: string; questionId: string; delivery: "mailbox" | "requeue" }> = [];
146
+ /** G5 (deep-review 2026-10-01): DEAD parks — the questionId-tagged response
147
+ * can never be picked up (the parked poller is gone); recorded with the
148
+ * liveness pid evidence so the leader sees the respond fell into the void.
149
+ * Twin of the broker-side task.respond_delivered ack. */
150
+ const missedEvents: Array<{
151
+ taskId: string;
152
+ questionId: string;
153
+ workerPid: number | undefined;
154
+ pidAlive: boolean;
155
+ pidDetail: string;
156
+ }> = [];
145
157
  let updatedTasks = fresh.tasks;
146
158
 
147
159
  for (const task of targetTasks) {
@@ -206,6 +218,21 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
206
218
  updatedTasks = requeueWaitingTask(updatedTasks, task.id, message, renderAskAnswer(questionId, message));
207
219
  requeuedIds.push(task.id);
208
220
  answeredEvents.push({ taskId: task.id, questionId, delivery: "requeue" });
221
+ // G5: liveness evidence for respond_missed — the same pid source + probe
222
+ // the stale-reconciler / heartbeat-watcher use (heartbeat pid, fallback
223
+ // checkpoint childPid, then process.kill(pid,0) via checkProcessLiveness).
224
+ // A live pid here (stale heartbeat but process alive) is still recorded
225
+ // verbatim — the discriminator already ruled DEAD for delivery; the event
226
+ // reports what the pid probe saw, it does not re-decide the branch.
227
+ const missedPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
228
+ const liveness = checkProcessLiveness(missedPid);
229
+ missedEvents.push({
230
+ taskId: task.id,
231
+ questionId,
232
+ workerPid: missedPid,
233
+ pidAlive: liveness.alive,
234
+ pidDetail: liveness.detail,
235
+ });
209
236
  }
210
237
 
211
238
  // If this respond includes a replyTo, update the original message with reply metadata.
@@ -255,6 +282,33 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
255
282
  ),
256
283
  );
257
284
  }
285
+ // G5 (deep-review 2026-10-01): respond_missed — the DEAD-branch twin of
286
+ // the broker's respond_delivered ack. Same H1 fire-and-forget discipline
287
+ // (sync run-lock callback, never await/block); carries the questionId and
288
+ // the pid/liveness evidence the discrimination was based on.
289
+ for (const missed of missedEvents) {
290
+ void appendEventAsync(manifest.eventsPath, {
291
+ type: "task.respond_missed",
292
+ runId: manifest.runId,
293
+ taskId: missed.taskId,
294
+ message: `Leader respond missed: worker for question ${missed.questionId} is not alive to receive the mailbox response (pid ${
295
+ missed.workerPid ?? "none recorded"
296
+ }: ${missed.pidDetail}); delivery rides the requeue+inject path.`,
297
+ data: {
298
+ questionId: missed.questionId,
299
+ ...(missed.workerPid !== undefined ? { workerPid: missed.workerPid } : {}),
300
+ pidAlive: missed.pidAlive,
301
+ pidDetail: missed.pidDetail,
302
+ delivery: "requeue",
303
+ },
304
+ }).catch((error) =>
305
+ logInternalError(
306
+ "respond.respond-missed-event",
307
+ error instanceof Error ? error : new Error(String(error)),
308
+ `runId=${manifest.runId}`,
309
+ ),
310
+ );
311
+ }
258
312
  for (const requeuedId of requeuedIds) {
259
313
  void appendEventAsync(manifest.eventsPath, {
260
314
  type: "task.resumed",
@@ -15,6 +15,7 @@ import { appendEvent, readEventsCursor } from "../../state/event-log/event-log.t
15
15
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
16
16
  import { aggregateUsage, formatCost, formatUsage } from "../../state/usage.ts";
17
17
  import { formatDuration } from "../../ui/format-helpers.ts";
18
+ import { goalAchievedStatusLabel } from "../../ui/goal-flag.ts";
18
19
  import { locateRunCwd } from "../team-tool.ts";
19
20
  import type { PiTeamsToolResult } from "../tool-result.ts";
20
21
  import { result, type TeamContext } from "./context.ts";
@@ -236,11 +237,16 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
236
237
  const waitingTasks = tasks.filter((task) => task.status === "queued" || task.status === "waiting");
237
238
  const agentLine = (agent: (typeof crewAgents)[number]): string =>
238
239
  `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.usage?.cost ? ` cost=${formatCost(agent.usage.cost)}` : ""}${agent.progress?.activityState ? ` activityState=${agent.progress.activityState}` : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.outputPath ? ` output=${agent.outputPath}` : ""}${agent.transcriptPath ? ` transcript=${agent.transcriptPath}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
240
+ // G19 (W-E Phase 1): surface the goal-achievement verdict on terminal runs —
241
+ // ⚠ false-green warning line; silent while running, when achieved, or when
242
+ // the run predates the assessment (goalAchieved undefined).
243
+ const goalLine = goalAchievedStatusLabel(manifest);
239
244
  const lines = [
240
245
  `Run: ${manifest.runId}`,
241
246
  `Team: ${manifest.team}`,
242
247
  `Workflow: ${manifest.workflow ?? "(none)"}`,
243
248
  `Status: ${manifest.status}`,
249
+ ...(goalLine ? [goalLine] : []),
244
250
  `Progress: ${phaseProgress.overallPercentage}% (~${formatDuration(phaseProgress.estimatedRemainingMs)} remaining)`,
245
251
  `Workspace mode: ${manifest.workspaceMode}`,
246
252
  ...(manifest.runtimeResolution
@@ -347,6 +353,8 @@ export function buildCompactStatus(
347
353
  status: string;
348
354
  goal: string;
349
355
  workspaceMode?: string;
356
+ goalAchieved?: boolean | "unknown";
357
+ goalAchievementNote?: string;
350
358
  },
351
359
  tasks: Array<{
352
360
  id: string;
@@ -360,10 +368,12 @@ export function buildCompactStatus(
360
368
  progress?: { overallPercentage: number; estimatedRemainingMs: number },
361
369
  ): string[] {
362
370
  const failedOrAttention = tasks.filter((t) => t.status === "failed" || t.status === "needs_attention" || t.status === "cancelled");
371
+ const goalLine = goalAchievedStatusLabel(manifest);
363
372
  const lines = [
364
373
  `Run: ${manifest.runId}`,
365
374
  `Team: ${manifest.team}${manifest.workflow ? ` (${manifest.workflow})` : ""}`,
366
375
  `Status: ${manifest.status}`,
376
+ ...(goalLine ? [goalLine] : []),
367
377
  ...(progress ? [`Progress: ${progress.overallPercentage}% (~${formatDuration(progress.estimatedRemainingMs)} remaining)`] : []),
368
378
  `Goal: ${manifest.goal}`,
369
379
  ...(asyncLivenessLine ? [asyncLivenessLine] : []),