pi-crew 0.10.2 → 0.10.4

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 (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -20,7 +20,7 @@ import { createRequire } from "node:module";
20
20
  import type { AgentConfig } from "../../agents/agent-config.ts";
21
21
  import { loadConfig } from "../../config/config.ts";
22
22
  import { effectiveRunConfig } from "../../extension/team-tool/config-patch.ts";
23
- import { appendEvent } from "../../state/event-log/event-log.ts";
23
+ import { appendEventBuffered } from "../../state/event-log/event-log.ts";
24
24
  import { registerActiveRun, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
25
25
  import { collectRunMetrics } from "../../state/stores/run-metrics.ts";
26
26
  import { createRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
@@ -121,7 +121,7 @@ export const realGoalEvaluator = async (
121
121
  const drift = compareSnapshot(snapshot.snapshot, current);
122
122
  if (drift.length > 0) {
123
123
  verificationCompromised = drift;
124
- appendEvent(turnManifest.eventsPath, {
124
+ appendEventBuffered(turnManifest.eventsPath, {
125
125
  type: "goal.verification_compromised",
126
126
  runId: turnRunId,
127
127
  data: {
@@ -129,7 +129,7 @@ export const realGoalEvaluator = async (
129
129
  driftedFiles: drift,
130
130
  phase: "T_snap",
131
131
  },
132
- });
132
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.verification_compromised phase=T_snap"));
133
133
  }
134
134
  } catch (error) {
135
135
  logInternalError("goal-loop.integritySnap", error, `goalId=${goal.goalId} phase=T_snap`);
@@ -201,7 +201,7 @@ export const realGoalEvaluator = async (
201
201
  const postDrift = compareSnapshot(snapshot.snapshot, post);
202
202
  if (postDrift.length > 0) {
203
203
  verificationCompromised = postDrift;
204
- appendEvent(turnManifest.eventsPath, {
204
+ appendEventBuffered(turnManifest.eventsPath, {
205
205
  type: "goal.verification_compromised",
206
206
  runId: turnRunId,
207
207
  data: {
@@ -209,7 +209,9 @@ export const realGoalEvaluator = async (
209
209
  driftedFiles: postDrift,
210
210
  phase: "T_verify_done",
211
211
  },
212
- });
212
+ }).catch((e) =>
213
+ logInternalError("goal-loop.buffered", e, "type=goal.verification_compromised phase=T_verify_done"),
214
+ );
213
215
  }
214
216
  } catch (error) {
215
217
  logInternalError("goal-loop.integritySnap", error, `goalId=${goal.goalId} phase=T_verify_done`);
@@ -521,7 +523,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
521
523
  const workflow = resolveGoalTurnWorkflow(goal);
522
524
  const agents = input.deps.discoverAgents(goal.cwd);
523
525
 
524
- appendEvent(eventsPath, {
526
+ appendEventBuffered(eventsPath, {
525
527
  type: "goal.loop_start",
526
528
  runId: manifest.runId,
527
529
  data: {
@@ -529,7 +531,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
529
531
  objective: goal.objective,
530
532
  maxTurns: goal.maxTurns,
531
533
  },
532
- });
534
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_start"));
533
535
 
534
536
  // P1g (RFC v0.5 §P1g, cold-review #2 BLOCKING fix): acquire the workspace lock for the
535
537
  // goal's lifetime. This serializes concurrent goals targeting the same cwd (workspaceMode:
@@ -545,14 +547,14 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
545
547
  } catch (error) {
546
548
  logInternalError("goal-loop.workspaceLock", error, `goalId=${goal.goalId} cwd=${goal.cwd}`);
547
549
  goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
548
- appendEvent(eventsPath, {
550
+ appendEventBuffered(eventsPath, {
549
551
  type: "goal.workspace_lock_failed",
550
552
  runId: manifest.runId,
551
553
  data: {
552
554
  goalId: goal.goalId,
553
555
  error: error instanceof Error ? error.message : String(error),
554
556
  },
555
- });
557
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.workspace_lock_failed"));
556
558
  return { manifest, tasks: [], goalState: goal };
557
559
  }
558
560
 
@@ -570,7 +572,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
570
572
  if (goal.budgetUnlimited !== true && goal.budgetTotal !== undefined && goal.budgetTotal > 0 && goal.budgetAbort !== undefined) {
571
573
  if (goal.budgetUsed >= goal.budgetAbort * goal.budgetTotal) {
572
574
  goal = safeSetStatus(store, goal.goalId, "budget_exceeded", goal, eventsPath);
573
- appendEvent(eventsPath, {
575
+ appendEventBuffered(eventsPath, {
574
576
  type: "goal.budget_warning",
575
577
  runId: manifest.runId,
576
578
  data: {
@@ -579,11 +581,11 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
579
581
  budgetTotal: goal.budgetTotal,
580
582
  threshold: "abort",
581
583
  },
582
- });
584
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.budget_warning threshold=abort"));
583
585
  break;
584
586
  }
585
587
  if (goal.budgetUsed >= (goal.budgetWarning ?? 0.8) * goal.budgetTotal) {
586
- appendEvent(eventsPath, {
588
+ appendEventBuffered(eventsPath, {
587
589
  type: "goal.budget_warning",
588
590
  runId: manifest.runId,
589
591
  data: {
@@ -592,12 +594,12 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
592
594
  budgetTotal: goal.budgetTotal,
593
595
  threshold: "warning",
594
596
  },
595
- });
597
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.budget_warning threshold=warning"));
596
598
  }
597
599
  }
598
600
 
599
601
  const turnIndex = goal.turnsUsed + 1;
600
- appendEvent(eventsPath, {
602
+ appendEventBuffered(eventsPath, {
601
603
  type: "goal.turn_start",
602
604
  runId: manifest.runId,
603
605
  data: {
@@ -605,7 +607,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
605
607
  turn: turnIndex,
606
608
  maxTurns: goal.maxTurns,
607
609
  },
608
- });
610
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_start"));
609
611
 
610
612
  // ── TURN: fresh manifest per turn (G2) + executeTeamRun ──────────────────
611
613
  const turnGoalText = composeGoalPrompt(goal);
@@ -630,7 +632,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
630
632
  // Fix round-6: re-check state AFTER patching (user may have paused/stopped in the inter-turn gap).
631
633
  // Without this, a pause that lands between store.patch and executeTeamRun lets one extra turn run.
632
634
  if (goal.state !== "running") {
633
- appendEvent(eventsPath, {
635
+ appendEventBuffered(eventsPath, {
634
636
  type: "goal.loop_end",
635
637
  runId: manifest.runId,
636
638
  data: {
@@ -638,7 +640,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
638
640
  state: goal.state,
639
641
  reason: "state changed before turn spawn",
640
642
  },
641
- });
643
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_end reason=state_changed"));
642
644
  break;
643
645
  }
644
646
  registerActiveRun(created.manifest);
@@ -688,7 +690,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
688
690
  const turnStatus = turnResult.manifest.status;
689
691
  if (turnStatus === "blocked" || turnStatus === "failed") {
690
692
  goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
691
- appendEvent(eventsPath, {
693
+ appendEventBuffered(eventsPath, {
692
694
  type: "goal.turn_terminal_status",
693
695
  runId: manifest.runId,
694
696
  data: {
@@ -697,7 +699,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
697
699
  turnRunId: created.manifest.runId,
698
700
  turnStatus,
699
701
  },
700
- });
702
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_terminal_status"));
701
703
  break;
702
704
  }
703
705
 
@@ -735,7 +737,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
735
737
  eventsPath,
736
738
  ) ?? goal;
737
739
 
738
- appendEvent(eventsPath, {
740
+ appendEventBuffered(eventsPath, {
739
741
  type: "goal.turn_evaluated",
740
742
  runId: manifest.runId,
741
743
  data: {
@@ -744,9 +746,9 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
744
746
  achieved: verdict.achieved,
745
747
  reason: verdict.reason,
746
748
  },
747
- });
749
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_evaluated"));
748
750
  if (!verdict.achieved && goal.nextTurnFeedback) {
749
- appendEvent(eventsPath, {
751
+ appendEventBuffered(eventsPath, {
750
752
  type: "goal.feedback_steered",
751
753
  runId: manifest.runId,
752
754
  data: {
@@ -754,7 +756,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
754
756
  turn: turnIndex,
755
757
  feedback: goal.nextTurnFeedback,
756
758
  },
757
- });
759
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.feedback_steered"));
758
760
  }
759
761
 
760
762
  // ── STOP CONDITIONS (round-7: re-read disk before applying — external cancel/pause wins) ─
@@ -781,7 +783,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
781
783
  const stuck = store.compareAndSetStatus(goal.goalId, "running", "stuck", eventsPath);
782
784
  if (stuck) {
783
785
  goal = stuck;
784
- appendEvent(eventsPath, {
786
+ appendEventBuffered(eventsPath, {
785
787
  type: "goal.stuck",
786
788
  runId: manifest.runId,
787
789
  data: {
@@ -789,7 +791,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
789
791
  turn: goal.turnsUsed,
790
792
  lastReasons: goal.verdicts.slice(-3).map((v) => v.reason.slice(0, 200)),
791
793
  },
792
- });
794
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.stuck"));
793
795
  break;
794
796
  }
795
797
  }
@@ -811,7 +813,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
811
813
  } catch {
812
814
  /* best-effort */
813
815
  }
814
- appendEvent(eventsPath, {
816
+ appendEventBuffered(eventsPath, {
815
817
  type: "goal.loop_end",
816
818
  runId: manifest.runId,
817
819
  data: {
@@ -820,7 +822,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
820
822
  turnsUsed: goal.turnsUsed,
821
823
  budgetUsed: goal.budgetUsed,
822
824
  },
823
- });
825
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_end reason=normal"));
824
826
  }
825
827
 
826
828
  return { manifest, tasks: [], goalState: goal };
@@ -81,6 +81,9 @@ export class GoalStore {
81
81
  mkdirSync(dirname(path), { recursive: true });
82
82
  atomicWriteJson(path, next);
83
83
  if (eventsPath) {
84
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
85
+ // goal.state_changed is read back synchronously by CAS tests and
86
+ // goal status display; low-frequency transition event.
84
87
  appendEvent(eventsPath, {
85
88
  type: "goal.state_changed",
86
89
  runId: state.goalId,
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { NotificationDescriptor } from "../../extension/notification-router.ts";
4
4
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
5
- import { appendEvent } from "../../state/event-log/event-log.ts";
5
+ import { appendEventBuffered } from "../../state/event-log/event-log.ts";
6
6
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
7
7
  import type { TeamRunManifest } from "../../state/types.ts";
8
8
  import { logInternalError } from "../../utils/internal-error.ts";
@@ -175,7 +175,7 @@ export class HeartbeatWatcher {
175
175
  this.lastLevel.set(key, level);
176
176
  if (level === "dead" && previous !== "dead") {
177
177
  this.opts.registry.counter("crew.heartbeat.dead_total", "Dead heartbeat detections").inc({ runId: run.runId });
178
- appendEvent(loaded.manifest.eventsPath, {
178
+ appendEventBuffered(loaded.manifest.eventsPath, {
179
179
  type: "crew.task.heartbeat_dead",
180
180
  runId: run.runId,
181
181
  taskId: task.id,
@@ -183,7 +183,7 @@ export class HeartbeatWatcher {
183
183
  data: {
184
184
  elapsedMs: Number.isFinite(elapsed) ? elapsed : undefined,
185
185
  },
186
- });
186
+ }).catch((e) => logInternalError("heartbeat_watcher.buffered", e, "type=crew.task.heartbeat_dead"));
187
187
  // W9 fix — prefix title with short run label (first 8 chars of runId)
188
188
  // so ambient notifications are scannable when multiple runs are
189
189
  // in flight. Full runId remains in the notification object.
@@ -119,15 +119,18 @@ export function registerLiveAgent(
119
119
  const completed = [...liveAgents.entries()].find(([, h]) => h.activity.completedAtMs > 0);
120
120
  if (completed) {
121
121
  liveAgents.delete(completed[0]);
122
+ invalidateSortedLiveAgents();
122
123
  } else {
123
124
  const oldestKey = liveAgents.keys().next().value;
124
125
  if (oldestKey !== undefined) {
125
126
  logInternalError("live-agent-manager.cap", new Error(`liveAgents at cap ${MAX_LIVE_AGENTS}; evicting oldest ${oldestKey}`));
126
127
  liveAgents.delete(oldestKey);
128
+ invalidateSortedLiveAgents();
127
129
  }
128
130
  }
129
131
  }
130
132
  liveAgents.set(input.agentId, handle);
133
+ invalidateSortedLiveAgents();
131
134
  try {
132
135
  if (eventLogFn && eventsPath)
133
136
  eventLogFn(eventsPath, {
@@ -172,6 +175,7 @@ export function updateLiveAgentStatus(agentId: string, status: CrewAgentRecord["
172
175
  if (!handle) return;
173
176
  handle.status = status;
174
177
  handle.updatedAt = new Date().toISOString();
178
+ invalidateSortedLiveAgents();
175
179
  }
176
180
 
177
181
  function safeDisposeLiveSession(handle: LiveAgentHandle): void {
@@ -198,6 +202,7 @@ export async function terminateLiveAgent(
198
202
  if (!handle) return undefined;
199
203
  handle.status = status;
200
204
  handle.updatedAt = new Date().toISOString();
205
+ invalidateSortedLiveAgents();
201
206
  try {
202
207
  if (eventLogFn && eventsPath)
203
208
  eventLogFn(eventsPath, {
@@ -220,6 +225,7 @@ export async function terminateLiveAgent(
220
225
  } finally {
221
226
  safeDisposeLiveSession(handle);
222
227
  liveAgents.delete(handle.agentId); // Move AFTER abort completes to prevent race
228
+ invalidateSortedLiveAgents();
223
229
  }
224
230
  return handle;
225
231
  }
@@ -260,6 +266,7 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
260
266
  // Terminal handle — evict after grace period
261
267
  if (age > STALE_HANDLE_MS) {
262
268
  liveAgents.delete(agentId);
269
+ invalidateSortedLiveAgents();
263
270
  safeDisposeLiveSession(handle);
264
271
  evicted++;
265
272
  }
@@ -289,6 +296,7 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
289
296
  const liveness = checkProcessLiveness(sessionPid);
290
297
  if (!liveness.alive) {
291
298
  liveAgents.delete(agentId);
299
+ invalidateSortedLiveAgents();
292
300
  safeDisposeLiveSession(handle);
293
301
  evicted++;
294
302
  }
@@ -297,8 +305,24 @@ export function evictStaleLiveAgentHandles(now = Date.now()): number {
297
305
  return evicted;
298
306
  }
299
307
 
308
+ // PERF (2026-08-24): listLiveAgents is called 4-6x per render frame and used to
309
+ // copy + sort with ICU localeCompare every call. ISO-8601 strings are fixed-width
310
+ // and sort correctly with plain operators; the sorted array is memoized and
311
+ // invalidated at every map mutation. NOTE: callers receive the SHARED memoized
312
+ // array — never sort or mutate it in place; copy (slice/spread) first.
313
+ let sortedLiveAgents: LiveAgentHandle[] | undefined;
314
+ function invalidateSortedLiveAgents(): void {
315
+ sortedLiveAgents = undefined;
316
+ }
300
317
  export function listLiveAgents(): LiveAgentHandle[] {
301
- return [...liveAgents.values()].sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
318
+ if (!sortedLiveAgents) {
319
+ sortedLiveAgents = [...liveAgents.values()].sort((a, b) => {
320
+ const au = a.updatedAt ?? "";
321
+ const bu = b.updatedAt ?? "";
322
+ return au < bu ? 1 : au > bu ? -1 : 0;
323
+ });
324
+ }
325
+ return sortedLiveAgents;
302
326
  }
303
327
 
304
328
  export function listActiveLiveAgents(): LiveAgentHandle[] {
@@ -324,6 +348,7 @@ export async function steerLiveAgent(agentIdOrTaskId: string, message: string):
324
348
  }
325
349
  await handle.session.steer(message);
326
350
  handle.updatedAt = new Date().toISOString();
351
+ invalidateSortedLiveAgents();
327
352
  return handle;
328
353
  }
329
354
 
@@ -339,6 +364,7 @@ export async function followUpLiveAgent(agentIdOrTaskId: string, prompt: string)
339
364
  expandPromptTemplates: false,
340
365
  });
341
366
  handle.updatedAt = new Date().toISOString();
367
+ invalidateSortedLiveAgents();
342
368
  return handle;
343
369
  }
344
370
 
@@ -359,6 +385,7 @@ export async function resumeLiveAgent(agentIdOrTaskId: string, prompt: string):
359
385
  });
360
386
  handle.status = "completed";
361
387
  handle.updatedAt = new Date().toISOString();
388
+ invalidateSortedLiveAgents();
362
389
  return handle;
363
390
  }
364
391
 
@@ -377,6 +404,7 @@ export function trackLiveAgentToolStart(agentIdOrTaskId: string, toolName: strin
377
404
  handle.activity.activeTools.set(toolName, toolName);
378
405
  handle.activity.toolUses++;
379
406
  handle.updatedAt = new Date().toISOString();
407
+ invalidateSortedLiveAgents();
380
408
  }
381
409
 
382
410
  /** G2: Track tool end for a live agent. */
@@ -394,6 +422,7 @@ export function trackLiveAgentTurnEnd(agentIdOrTaskId: string, compaction = fals
394
422
  if (compaction) handle.activity.compactionCount++;
395
423
  handle.activity.activeTools.clear();
396
424
  handle.updatedAt = new Date().toISOString();
425
+ invalidateSortedLiveAgents();
397
426
  }
398
427
 
399
428
  /** G2: Track assistant response text. */
@@ -413,6 +442,7 @@ export function markLiveAgentCompleted(agentIdOrTaskId: string): void {
413
442
 
414
443
  export function clearLiveAgentsForTest(): void {
415
444
  liveAgents.clear();
445
+ invalidateSortedLiveAgents();
416
446
  }
417
447
 
418
448
  /** Phase 7/G4: Send an IRC message to a specific live agent (DM).
@@ -427,6 +457,7 @@ export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void
427
457
  }
428
458
  handle.pendingMessages.push(message);
429
459
  handle.updatedAt = new Date().toISOString();
460
+ invalidateSortedLiveAgents();
430
461
  // G4: Try non-blocking delivery via sendCustomMessage
431
462
  const session = handle.session as Record<string, unknown>;
432
463
  if (typeof session.sendCustomMessage === "function") {
@@ -467,6 +498,7 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
467
498
  }
468
499
  handle.pendingMessages.push(message);
469
500
  handle.updatedAt = new Date().toISOString();
501
+ invalidateSortedLiveAgents();
470
502
  // G4: Try non-blocking delivery
471
503
  const session = handle.session as Record<string, unknown>;
472
504
  if (typeof session.sendCustomMessage === "function") {
@@ -600,6 +632,7 @@ export async function respondAsBackground(
600
632
  error: `Target '${targetAgentId}' has no message channel.`,
601
633
  };
602
634
  handle.updatedAt = new Date().toISOString();
635
+ invalidateSortedLiveAgents();
603
636
 
604
637
  if (!awaitReply) return { ok: true, corrId };
605
638
 
@@ -19,6 +19,16 @@ export function subscribeLiveControlRealtime(listener: Listener): () => void {
19
19
  return () => listeners.delete(listener);
20
20
  }
21
21
 
22
+ /**
23
+ * Task 26 (2026-08-24): poll-interval gate for live-session control polling.
24
+ * True while at least one realtime listener is registered — in that mode
25
+ * in-process control requests are delivered immediately and the file poll is
26
+ * only a cross-process fallback, so the poller can run slower.
27
+ */
28
+ export function hasLiveControlRealtimeListeners(): boolean {
29
+ return listeners.size > 0;
30
+ }
31
+
22
32
  export function liveControlRealtimeMessage(request: LiveAgentControlRequest): LiveControlRealtimeMessage {
23
33
  return { type: "live-control", version: 1, request };
24
34
  }
@@ -1,5 +1,3 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
1
  import type { AgentConfig } from "../../agents/agent-config.ts";
4
2
  import { resolveToolPolicy } from "../../agents/agent-config.ts";
5
3
  import type { CrewRuntimeConfig } from "../../config/config.ts";
@@ -11,6 +9,7 @@ import type { TeamRunManifest, TeamTaskState, UsageState } from "../../state/typ
11
9
  import { logInternalError } from "../../utils/internal-error.ts";
12
10
  import { redactSecrets } from "../../utils/redaction.ts";
13
11
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
12
+ import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
14
13
  import { createIrcTool } from "../custom-tools/irc-tool.ts";
15
14
  import { createSubmitResultTool } from "../custom-tools/submit-result-tool.ts";
16
15
  import { buildMcpProxyFromSession } from "../mcp-proxy.ts";
@@ -28,7 +27,13 @@ import { readEnabledModelsPatterns } from "../model/model-scope.ts";
28
27
  import { isLiveSessionRuntimeAvailable } from "../model/runtime-resolver.ts";
29
28
  import { awaitRuntimeWarmup } from "../model/runtime-warmup.ts";
30
29
  import { liveAgentContext, registerLiveAgentModel, unregisterLiveAgentModel } from "../model/session-model.ts";
31
- import { eventToSidechainType, sidechainOutputPath, writeSidechainEntry } from "../output/sidechain-output.ts";
30
+ import {
31
+ appendBatchedJsonlLine,
32
+ eventToSidechainType,
33
+ flushPendingSidechainWrites,
34
+ sidechainOutputPath,
35
+ writeSidechainEntry,
36
+ } from "../output/sidechain-output.ts";
32
37
  // NOTE: buildMemoryBlock is intentionally NOT imported here. The agent memory
33
38
  // block is injected via renderTaskPrompt().full (the USER prompt), which is
34
39
  // shared by both the child-pi path (no system prompt) and the live-session
@@ -60,7 +65,7 @@ import {
60
65
  trackLiveAgentTurnEnd,
61
66
  updateLiveAgentStatus,
62
67
  } from "./live-agent-manager.ts";
63
- import { subscribeLiveControlRealtime } from "./live-control-realtime.ts";
68
+ import { hasLiveControlRealtimeListeners, subscribeLiveControlRealtime } from "./live-control-realtime.ts";
64
69
  import { buildExtensionBridge } from "./live-extension-bridge.ts";
65
70
  import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
66
71
 
@@ -178,20 +183,13 @@ type LiveSessionLike = {
178
183
 
179
184
  function appendTranscript(filePath: string | undefined, event: unknown): void {
180
185
  if (!filePath) return;
181
- // Fire-and-forget async write to avoid blocking the event loop.
182
- // Transcript writes are best-effort telemetry — callers do not need to await.
183
- void appendTranscriptAsync(filePath, event);
184
- }
185
-
186
- /** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
187
- async function appendTranscriptAsync(filePath: string, event: unknown): Promise<void> {
188
- try {
189
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
190
- const content = `${JSON.stringify(redactSecrets(event))}\n`;
191
- await fs.promises.appendFile(filePath, content, "utf-8");
192
- } catch (error) {
193
- logInternalError("live-session.transcript-write-failed", error as Error, `path=${filePath}`);
194
- }
186
+ // Task 26 (2026-08-24): serialize + redact at queue time, then route
187
+ // through the shared 50ms batched JSONL writer (one mkdir + appendFileSync
188
+ // per path per window instead of one async mkdir+appendFile PER streaming
189
+ // event). child-pi-transcript's batched writer is not generic over plain
190
+ // paths (ChildPiRunInput + artifactsRoot containment + re-redaction), so
191
+ // the identical map lives in sidechain-output.ts.
192
+ appendBatchedJsonlLine(filePath, `${JSON.stringify(redactSecrets(event))}\n`);
195
193
  }
196
194
 
197
195
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -630,6 +628,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
630
628
  message: event,
631
629
  cwd: input.task.cwd,
632
630
  });
631
+ // Task 26: the mock path returns before the try/finally below, so drain
632
+ // the batched writers here — tests read these files right after return.
633
+ flushPendingSidechainWrites();
633
634
  if (isCurrent()) input.onEvent?.(event);
634
635
  const stdout = `Mock live-session success for ${input.agent.name}${inherited}`;
635
636
  if (isCurrent()) input.onOutput?.(stdout);
@@ -678,7 +679,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
678
679
  let unsubscribe: (() => void) | undefined;
679
680
  let unsubscribeControlRealtime: (() => void) | undefined;
680
681
  let controlTimer: ReturnType<typeof setInterval> | undefined;
681
- let stdout = "";
682
+ // PERF (2026-08-24): unbounded string concat re-copies the whole transcript
683
+ // on every chunk; bounded tail keeps whatever the consumer actually reads.
684
+ // Consumer sizing: stdout becomes parsedOutput.finalText + the
685
+ // results/{taskId}.txt artifact — the same downstream consumers the
686
+ // child-pi path already caps at maxCaptureBytes (512 KiB) via BoundedTail,
687
+ // so we match that cap for parity (this is NOT a tail-only preview).
688
+ const stdoutTail = new BoundedTail();
682
689
  let jsonEvents = 0;
683
690
  const collectedJsonEvents: Record<string, unknown>[] | undefined = yieldEnabled ? [] : undefined;
684
691
  const maxCollectedJsonEvents = 1000;
@@ -898,9 +905,18 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
898
905
  });
899
906
  });
900
907
  await pollControl();
901
- controlTimer = setInterval(() => {
902
- if (isCurrent()) void pollControl();
903
- }, 500);
908
+ // PERF (2026-08-24): each poll does existsSync + readFileSync + JSON.parse
909
+ // of the whole control JSONL. With a realtime subscription active for this
910
+ // agent, in-process control requests (team-tool / cross-extension RPC
911
+ // publish) are delivered immediately and the poll only covers cross-process
912
+ // writers → 1000ms. Without realtime, the file poll is the sole delivery
913
+ // path → keep the faster 500ms cadence.
914
+ controlTimer = setInterval(
915
+ () => {
916
+ if (isCurrent()) void pollControl();
917
+ },
918
+ hasLiveControlRealtimeListeners() ? 1000 : 500,
919
+ );
904
920
  let turnCount = 0;
905
921
  let softLimitReached = false;
906
922
  const maxTurns = input.runtimeConfig?.maxTurns;
@@ -953,7 +969,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
953
969
  input.onEvent?.(event);
954
970
  const text = [...eventText(event), ...finalAssistantText(event)].join("\n");
955
971
  if (text.trim()) {
956
- stdout += `${text}\n`;
972
+ stdoutTail.push(`${text}\n`);
957
973
  streamOut?.write(text + "\n");
958
974
  trackLiveAgentResponseText(agentId, text);
959
975
  input.onOutput?.(text);
@@ -1034,7 +1050,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1034
1050
  return {
1035
1051
  available: true,
1036
1052
  exitCode: 1,
1037
- stdout: stdout.trim(),
1053
+ stdout: stdoutTail.value().trim(),
1038
1054
  stderr: msg,
1039
1055
  jsonEvents,
1040
1056
  error: msg,
@@ -1050,7 +1066,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1050
1066
  data: {
1051
1067
  elapsedMs: Date.now() - promptStart,
1052
1068
  jsonEvents,
1053
- outputLength: stdout.length,
1069
+ outputLength: stdoutTail.value().length,
1054
1070
  },
1055
1071
  });
1056
1072
 
@@ -1171,7 +1187,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1171
1187
  return {
1172
1188
  available: true,
1173
1189
  exitCode: 0,
1174
- stdout: stdout.trim(),
1190
+ stdout: stdoutTail.value().trim(),
1175
1191
  stderr: created.modelFallbackMessage ?? "",
1176
1192
  jsonEvents,
1177
1193
  usage,
@@ -1205,7 +1221,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1205
1221
  return {
1206
1222
  available: true,
1207
1223
  exitCode: 1,
1208
- stdout: stdout.trim(),
1224
+ stdout: stdoutTail.value().trim(),
1209
1225
  stderr: message,
1210
1226
  jsonEvents,
1211
1227
  error: message,
@@ -1220,6 +1236,10 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1220
1236
  // H6: Unsubscribe listeners FIRST before clearing timer to prevent race
1221
1237
  unsubscribe?.();
1222
1238
  unsubscribeControlRealtime?.();
1239
+ // Task 26 (2026-08-24): drain the batched sidechain/transcript writers
1240
+ // now that no more session events can enqueue, so callers that read the
1241
+ // files right after this task settles see complete content.
1242
+ flushPendingSidechainWrites();
1223
1243
  // Round 27 (BUG 4): remove the named abort listener to avoid leaking it
1224
1244
  // on the shared AbortSignal across many live-session tasks.
1225
1245
  if (onSignalAbort) input.signal?.removeEventListener("abort", onSignalAbort);