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
@@ -1,13 +1,23 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
+ import * as path from "node:path";
3
4
  import type { AgentConfig } from "../../agents/agent-config.ts";
5
+ import { loadConfig } from "../../config/config.ts";
4
6
  import { getCrewEnv } from "../../config/env-vars.ts";
7
+ import type { PiTeamsConfig } from "../../config/types.ts";
5
8
  import { registerChildProcess, unregisterChildProcess } from "../../extension/crew-cleanup.ts";
6
9
  import type { WorkerExitStatus } from "../../state/types.ts";
10
+ import { runEventBus } from "../../ui/run-event-bus.ts";
7
11
  import { logInternalError } from "../../utils/internal-error.ts";
8
12
  import { redactSecretString } from "../../utils/redaction.ts";
9
13
  import { getActiveBrokerIssuer } from "../broker/broker-issuer.ts";
10
14
  import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
15
+ import { EventLogTailSource } from "../event-log-tail-source.ts";
16
+ import { bridgeEventFromJsonEvent } from "../event-stream-bridge.ts";
17
+ import { classifyOnExit, getSurfaceRuntimeController, makeTerminalEventProbe } from "../surface/degrade.ts";
18
+ import type { SurfaceGateEnvSnapshot } from "../surface/resolve-surface.ts";
19
+ import type { SurfaceProvider } from "../surface/surface-provider.ts";
20
+ import { prepareSurfaceSpawn, type SurfaceExitInfo, type SurfaceSpawnOutcome, waitForSurfaceExit } from "../surface/surface-spawn.ts";
11
21
  import { FINAL_DRAIN_MS, HARD_KILL_MS, POST_EXIT_STDIO_GUARD_MS, RESPONSE_TIMEOUT_MS } from "./child-pi-constants.ts";
12
22
  import { clearHardKillTimer, killProcessTree, registerActiveChild, unregisterActiveChild } from "./child-pi-kill.ts";
13
23
  import { buildFinalChildPiSpawnOptions, prepareSpawnContext } from "./child-pi-spawn.ts";
@@ -71,10 +81,41 @@ export function redactStderrExcerpt(stderr: string, maxChars: number): string {
71
81
 
72
82
  /** Structured lifecycle event emitted by child-pi for critical transitions. */
73
83
  export interface ChildPiLifecycleEvent {
74
- /** Event discriminator. */
75
- type: "spawned" | "spawn_error" | "response_timeout" | "final_drain" | "hard_kill" | "exit" | "close";
84
+ /**
85
+ * Event discriminator. `surface_spawned` / `surface_closed` are emitted by
86
+ * the MuxSurface spawn branch (spec §13.1) — no stdio process exists, so
87
+ * `pid` is absent there and surface fields carry the pane identity.
88
+ */
89
+ type:
90
+ | "spawned"
91
+ | "spawn_error"
92
+ | "response_timeout"
93
+ | "final_drain"
94
+ | "hard_kill"
95
+ | "exit"
96
+ | "close"
97
+ | "surface_spawned"
98
+ | "surface_closed"
99
+ /** T11 (spec §7): a REAL surface spawn attempt failed → headless fallback
100
+ * + the run-scoped spawn-fail lockout counter (3 consecutive → OFF). */
101
+ | "surface_spawn_failed"
102
+ /** FINDING-3 (report 10tier 2026-08-27): a gate rejected surface BEFORE
103
+ * touching the mux (mode-off/async-run/depth/pane-cap/role-not-visible/
104
+ * no-mux). Emitted ONLY when the user opted into surface
105
+ * (visibleAgents non-empty) — default runs stay silent. */
106
+ | "surface_gate_blocked";
76
107
  /** Process ID when available. */
77
108
  pid?: number;
109
+ /** Pane id (surface events). */
110
+ paneId?: string;
111
+ /** Surface backend kind (surface events). */
112
+ surfaceKind?: "tmux" | "herdr";
113
+ /** Why the pane ended / degraded (surface_closed): "pane-closed" | "mux-dead" | "detached". */
114
+ paneExitReason?: string;
115
+ /** Which gate rejected surface (surface_gate_blocked): one of the SurfaceGateName values. */
116
+ gate?: string;
117
+ /** Env signals the gate saw (surface_gate_blocked) — bounded snapshot, never the whole env. */
118
+ env?: SurfaceGateEnvSnapshot;
78
119
  /** Exit code for exit/close events. */
79
120
  exitCode?: number | null;
80
121
  /** Error message for error events. */
@@ -116,6 +157,17 @@ export interface ChildPiRunInput {
116
157
  onSpawn?: (pid: number) => void;
117
158
  /** Structured lifecycle events for durable logging (spawn, crash, timeout, kill, exit). */
118
159
  onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
160
+ /** F1 fix (battery 2026-09-10): task-state activity bridge for SURFACE workers.
161
+ * Surface workers have no stdout pipe, so onJsonEvent/onStdoutLine never fire
162
+ * and task.heartbeat/agentProgress starve - the heartbeat-watcher then flags a
163
+ * healthy pane worker dead (~340s) and the stale-reconciler cancels the run
164
+ * (observed live: run team_20260910161234, worker productive 7min in pane
165
+ * w2:p8Z, 0 task.progress events). This callback receives every event tailed
166
+ * from the per-agent recorder log so the CALLER can update task liveness state.
167
+ * Contract: the callback MUST NOT append to the per-agent events file being
168
+ * tailed (watch->append loop - see event-log-tail-source.ts header); task-state
169
+ * persist + run-level task.progress are safe (different files). */
170
+ onSurfaceActivity?: (event: Record<string, unknown>) => void;
119
171
  maxDepth?: number;
120
172
  finalDrainMs?: number;
121
173
  /** F12: early-exit the drain when stdout has been silent for this many ms
@@ -192,6 +244,28 @@ export interface ChildPiRunInput {
192
244
  /** Base env for depth computation (defaults to process.env). Advanced —
193
245
  * used with depthOverride by the root-side delegate handler. */
194
246
  env?: NodeJS.ProcessEnv;
247
+ /**
248
+ * MuxSurface A1 (spec §13.1): opt-in/hint for booting this worker in a
249
+ * multiplexer pane instead of a stdio pipe. Undefined → the branch still
250
+ * consults the discovered project config, which defaults surface OFF
251
+ * (`visibleAgents: []`), so behaviour stays headless unless explicitly
252
+ * configured. Any failure degrades to the normal headless spawn path (§3
253
+ * fail-closed) — NEVER throws.
254
+ *
255
+ * - `providers`: pre-resolved provider instances — dispatch-owned (team-runner
256
+ * T11 passes its resolved provider + livePaneCount) and test injection.
257
+ * Hard gates (async run / host depth) still apply.
258
+ * - `config`: overrides discovered config (tests).
259
+ * - `livePaneCount`: panes already live for this run — `MAX_SURFACE_WORKERS`
260
+ * cap check. Defaults 0 until T11 threads manifest state.
261
+ * - `baseDir`: launch-script dir override (default getPiTempBase()).
262
+ */
263
+ surface?: {
264
+ providers?: { tmux?: SurfaceProvider; herdr?: SurfaceProvider };
265
+ config?: PiTeamsConfig;
266
+ livePaneCount?: number;
267
+ baseDir?: string;
268
+ };
195
269
  }
196
270
 
197
271
  export interface ChildPiRunResult {
@@ -217,6 +291,26 @@ export interface ChildPiRunResult {
217
291
  * text) still produce a non-empty result. Consumers should prefer rawFinalText
218
292
  * first — this is a last-resort fallback. */
219
293
  intermediateFindings?: string;
294
+ /**
295
+ * MuxSurface A1: present ONLY when the worker booted inside a multiplexer
296
+ * pane instead of a stdio pipe (spec §13.1). Downstream (T9 EventLogTailSource,
297
+ * T11 degrade) consumes the pane identity; the empty stdout/stderr is EXPECTED
298
+ * in surface mode — events arrive through the per-agent event log, not a pipe.
299
+ *
300
+ * `degraded` is set when classifyOnExit found NO worker.completed within the
301
+ * 2s window after the pane died (spec §7 D3) — task-runner hands that to
302
+ * finalize as `surfaceLost` instead of reading it as an empty/false result.
303
+ */
304
+ surface?: {
305
+ kind: "tmux" | "herdr";
306
+ paneId: string;
307
+ scriptPath: string;
308
+ degraded?: {
309
+ cause: "pane-closed" | "mux-dead";
310
+ exitReason: string;
311
+ classifiedAt: string;
312
+ };
313
+ };
220
314
  }
221
315
 
222
316
  // Base allowlist of non-provider env vars always passed to child workers.
@@ -259,6 +353,270 @@ function isFinalAssistantEvent(event: unknown): boolean {
259
353
  return !content.some((part) => asRecord(part)?.type === "toolCall");
260
354
  }
261
355
 
356
+ /**
357
+ * Discover the project crew config for the surface gate. The discovered config
358
+ * DEFAULTS surface off (`visibleAgents: []`) — headless unless explicitly opted
359
+ * in. Any discovery failure → default config (fail-closed §3), never throws.
360
+ */
361
+ function safeLoadSurfaceConfig(cwd: string): PiTeamsConfig {
362
+ try {
363
+ return loadConfig(cwd).config;
364
+ } catch (error) {
365
+ logInternalError(
366
+ "child-pi.surface-config",
367
+ error instanceof Error ? error : new Error(String(error)),
368
+ "surface gate uses default (off)",
369
+ );
370
+ return {};
371
+ }
372
+ }
373
+
374
+ /**
375
+ * MuxSurface A1 spawn branch (spec §13.1). Returns a ChildPiRunResult when the
376
+ * worker was booted INSIDE a multiplexer pane (no stdio process is spawned and
377
+ * completion is awaited through the pane's own lifetime), or null to fall
378
+ * through to the normal stdio spawn.
379
+ *
380
+ * NEVER throws: any preparation failure degrades to null (headless §3).
381
+ */
382
+ async function trySurfaceBranch(
383
+ input: ChildPiRunInput,
384
+ depthEnv: NodeJS.ProcessEnv | undefined,
385
+ builtArgs: string[],
386
+ mergedEnv: NodeJS.ProcessEnv,
387
+ builtEnv: Record<string, string | undefined>,
388
+ tempDir: string | undefined,
389
+ ): Promise<ChildPiRunResult | null> {
390
+ // A pane needs a task id for its title/event-log path — an anonymous worker
391
+ // has nothing meaningful to boot there, so stay on the headless road.
392
+ if (!input.agentId) return null;
393
+ const surfaceOpts = input.surface;
394
+ const preResolved = surfaceOpts?.providers?.tmux ?? surfaceOpts?.providers?.herdr;
395
+ // T11 (spec §7): per-run degrade controller — team-runner owns the policy
396
+ // (lockout counters, pane cap accounting); this layer only consults it.
397
+ // Absent controller (delegate spawn outside a team run, unit tests) keeps
398
+ // today's behavior untouched.
399
+ const taskId = input.agentId;
400
+ const degradeController = getSurfaceRuntimeController(input.runId);
401
+ if (degradeController && !degradeController.shouldAttemptSurface()) return null;
402
+ const surfaceConfig = surfaceOpts?.config ?? safeLoadSurfaceConfig(input.cwd);
403
+ let outcome: SurfaceSpawnOutcome;
404
+ try {
405
+ outcome = await prepareSurfaceSpawn({
406
+ // Detection env must be the HOST's base env (depth 0 tier-1), NOT the
407
+ // child's built env whose PI_CREW_DEPTH is parentDepth+1 — layer-1
408
+ // guard would misfire on our own tier-1 workers.
409
+ env: depthEnv ?? process.env,
410
+ workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model).env as Record<string, string>,
411
+ config: surfaceConfig,
412
+ role: input.role ?? input.agent.name,
413
+ livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
414
+ taskId,
415
+ cwd: input.cwd,
416
+ piArgs: builtArgs,
417
+ stateRoot: input.eventsPath ? path.dirname(input.eventsPath) : "",
418
+ baseDir: surfaceOpts?.baseDir,
419
+ deps: preResolved ? { provider: preResolved } : {},
420
+ });
421
+ } catch (error) {
422
+ // Defense in depth — prepareSurfaceSpawn is no-throw by contract (§3).
423
+ logInternalError("child-pi.surface-unexpected", error instanceof Error ? error : new Error(String(error)), `taskId=${taskId}`);
424
+ return null;
425
+ }
426
+ if (outcome.mode !== "surface") {
427
+ // Spawn-fail ≠ flap (spec §7) but it feeds ITS OWN lockout counter. Only
428
+ // `attempted` outcomes count — gate/resolve-null rejections are the mux
429
+ // simply being absent, not the multiplexer misbehaving.
430
+ if (outcome.mode === "headless" && outcome.attempted) {
431
+ degradeController?.notifySpawnFailed({ taskId, reason: outcome.reason ?? "surface spawn failed" });
432
+ input.onLifecycleEvent?.({
433
+ type: "surface_spawn_failed",
434
+ surfaceKind: preResolved?.kind,
435
+ error: outcome.reason,
436
+ ts: new Date().toISOString(),
437
+ });
438
+ }
439
+ // FINDING-3 (report 10tier 2026-08-27 adjudication): the gate-null path
440
+ // used to be completely silent — a misconfigured surface run (wrong
441
+ // visibleAgents, mode typo, …) was indistinguishable from a by-design
442
+ // headless run in events.jsonl. Emit ONLY when the user opted into
443
+ // surface (visibleAgents non-empty) so default runs gain zero noise.
444
+ // The lockout path above stays silent here — its cause already emitted
445
+ // surface.degraded events on the way to locking.
446
+ if (outcome.mode === "headless" && outcome.gateRejected) {
447
+ const visibleAgents = surfaceConfig.runtime?.surface?.visibleAgents ?? [];
448
+ if (visibleAgents.length > 0) {
449
+ input.onLifecycleEvent?.({
450
+ type: "surface_gate_blocked",
451
+ gate: outcome.gateRejected.gate,
452
+ error: outcome.gateRejected.reason,
453
+ env: outcome.gateRejected.env,
454
+ ts: new Date().toISOString(),
455
+ });
456
+ }
457
+ }
458
+ return null;
459
+ }
460
+
461
+ const surfaceMeta: NonNullable<ChildPiRunResult["surface"]> = {
462
+ kind: outcome.kind,
463
+ paneId: outcome.paneId,
464
+ scriptPath: outcome.scriptPath,
465
+ };
466
+ degradeController?.notifySpawned({
467
+ taskId,
468
+ paneId: outcome.paneId,
469
+ provider: outcome.kind,
470
+ // Task 5 (tab-layout): tab của run lên controller → manifest surface.tabs
471
+ // — run end (closeRunTabs) đóng đúng tab, worker xong KHÔNG đóng.
472
+ ...(outcome.tabKey && outcome.tabId ? { tabKey: outcome.tabKey, tabId: outcome.tabId } : {}),
473
+ });
474
+ input.onLifecycleEvent?.({
475
+ type: "surface_spawned",
476
+ surfaceKind: outcome.kind,
477
+ paneId: outcome.paneId,
478
+ ts: new Date().toISOString(),
479
+ });
480
+ // T9 (spec §5.3): surface worker không có stdout JSON stream — worker-side
481
+ // recorder ghi per-agent events.jsonl; host tail file đó và bridge thẳng ra
482
+ // run event bus cho dashboard/sidebar ("như cũ"). KHÔNG đưa vào
483
+ // input.onJsonEvent: callback task-runner gọi appendCrewAgentEventBuffered
484
+ // vào CHÍNH file này (worker đã ghi sẵn) → ghi đôi + vòng tự kích hoạt
485
+ // watch→append→watch không dừng (xem header event-log-tail-source.ts).
486
+ const eventSource = outcome.eventsPath ? new EventLogTailSource({ eventsPath: outcome.eventsPath }) : undefined;
487
+ if (eventSource && input.runId && input.agentId) {
488
+ const runId = input.runId;
489
+ const taskId = input.agentId;
490
+ const controllerForBridge = degradeController;
491
+ eventSource.onEvent((event) => {
492
+ // T11: the worker's own `worker.started` self-report carries the pid +
493
+ // session path that doctor/degrade-resume need (§12.2). Bridge it into
494
+ // the run-scoped controller BEFORE the dashboard shim — it is the only
495
+ // host-side consumer of that payload.
496
+ if ((event as { type?: unknown } | undefined)?.type === "worker.started") {
497
+ const started = event as { pid?: unknown; sessionPath?: unknown };
498
+ controllerForBridge?.notifyWorkerStarted({
499
+ taskId,
500
+ pid: typeof started.pid === "number" ? started.pid : undefined,
501
+ sessionPath: typeof started.sessionPath === "string" ? started.sessionPath : undefined,
502
+ });
503
+ }
504
+ const bridgeEvent = bridgeEventFromJsonEvent(runId, taskId, event);
505
+ if (bridgeEvent) runEventBus.emit({ type: "worker_status", runId, taskId, data: bridgeEvent });
506
+ // F1 fix (battery 2026-09-10): feed the same tailed event to the task-state
507
+ // bridge so child-executor can keep task.heartbeat/agentProgress fresh for
508
+ // surface workers (see ChildPiRunInput.onSurfaceActivity doc).
509
+ input.onSurfaceActivity?.(event as Record<string, unknown>);
510
+ });
511
+ }
512
+ let exitInfo: SurfaceExitInfo;
513
+ try {
514
+ // Pane lifetime IS the worker lifetime in A1: T8's auto-exit closes the
515
+ // pane at turn end (spec §13.2); cancel closes it here via AbortSignal and
516
+ // the response deadline force-closes a WEDGED worker (fix round 1/F2 —
517
+ // parity with headless minimum timeout so a stuck pane cannot hold its
518
+ // worker slot forever; A1 has no stream-activity signal so this is ONE
519
+ // hard deadline).
520
+ exitInfo = await waitForSurfaceExit(outcome, {
521
+ signal: input.signal,
522
+ deadlineMs: input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS,
523
+ });
524
+ } finally {
525
+ eventSource?.close();
526
+ }
527
+ // T11 classify (spec §7): D7 report-before-dying means a normally-finishing
528
+ // worker already wrote `worker.completed` to the RUN event log before its
529
+ // pane closed, so the probe finds it on the first scan. Nothing within the
530
+ // 2s window → the worker never finished → the pane death is a DEGRADE and
531
+ // team-runner re-dispatches this unit headless. Cancel/deadline force-closes
532
+ // are HOST-initiated — classify is skipped there (never counts as mux flap),
533
+ // but the pane release itself is reported in EVERY case so the run's live
534
+ // pane cap stays accurate.
535
+ let completedPayload: Record<string, unknown> | undefined;
536
+ if (!exitInfo.cancelledByAbort && !exitInfo.timedOut) {
537
+ const probe = makeTerminalEventProbe({ eventsPath: input.eventsPath ?? "", taskId, runId: input.runId ?? undefined });
538
+ const verdict = await classifyOnExit(outcome.handle, probe);
539
+ const completed = verdict === "completed";
540
+ if (completed) {
541
+ const payload = probe.foundPayload();
542
+ completedPayload = payload && typeof payload.result === "string" && payload.result.trim().length > 0 ? payload : undefined;
543
+ } else {
544
+ surfaceMeta.degraded = {
545
+ cause: exitInfo.reason === "mux-dead" ? "mux-dead" : "pane-closed",
546
+ exitReason: exitInfo.reason,
547
+ classifiedAt: new Date().toISOString(),
548
+ };
549
+ }
550
+ degradeController?.notifyPaneExited({
551
+ taskId,
552
+ paneId: outcome.paneId,
553
+ completed,
554
+ exitReason: exitInfo.reason,
555
+ });
556
+ } else {
557
+ degradeController?.notifyPaneExited({
558
+ taskId,
559
+ paneId: outcome.paneId,
560
+ completed: false,
561
+ exitReason: exitInfo.reason,
562
+ cancelledByAbort: exitInfo.cancelledByAbort,
563
+ timedOut: exitInfo.timedOut,
564
+ });
565
+ }
566
+ // Result text captured from `worker.completed` — WITHOUT it every surface
567
+ // task would look empty downstream (rawFinalText/stdout are the only result
568
+ // channels post-execution trusts) and flip to failed via the empty-output
569
+ // guard even though the worker SUCCEEDED.
570
+ const surfaceResultText = typeof completedPayload?.result === "string" ? completedPayload.result : "";
571
+ input.onLifecycleEvent?.({
572
+ type: "surface_closed",
573
+ surfaceKind: outcome.kind,
574
+ paneId: outcome.paneId,
575
+ paneExitReason: exitInfo.reason,
576
+ ts: new Date().toISOString(),
577
+ });
578
+ // F1: the pane is gone — nothing can still need the prompt/task temp dir.
579
+ // The headless branch cleans this up in settle(); surface returns early.
580
+ cleanupTempDir(tempDir);
581
+ // Empty stdout/stderr is EXPECTED — events flow through the per-agent event
582
+ // log (worker-side recorder, spec §5.3), not through a pipe we own. The only
583
+ // text the worker produced surfaces via `worker.completed` (surfaceResultText).
584
+ return {
585
+ exitCode: exitInfo.cancelledByAbort || exitInfo.timedOut ? null : 0,
586
+ stdout: "",
587
+ stderr: "",
588
+ ...(exitInfo.cancelledByAbort
589
+ ? { error: `Cancelled while running in ${outcome.kind} pane ${outcome.paneId} (${exitInfo.reason})` }
590
+ : {}),
591
+ ...(exitInfo.timedOut
592
+ ? {
593
+ error:
594
+ `Surface worker in ${outcome.kind} pane ${outcome.paneId} produced no completion within ` +
595
+ `${input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS}ms response timeout; pane was force-closed.`,
596
+ }
597
+ : {}),
598
+ rawFinalText: surfaceResultText,
599
+ intermediateFindings: "",
600
+ ...(exitInfo.cancelledByAbort ? { aborted: true } : {}),
601
+ surface: surfaceMeta,
602
+ exitStatus: {
603
+ exitCode: exitInfo.cancelledByAbort || exitInfo.timedOut ? null : 0,
604
+ cancelled: exitInfo.cancelledByAbort,
605
+ timedOut: exitInfo.timedOut,
606
+ killed: false,
607
+ cleanupErrors: [],
608
+ finalDrainMs: 0,
609
+ },
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Test seam — trySurfaceBranch là internal wiring giữa prepareSurfaceSpawn và
615
+ * lifecycle events (surface_gate_blocked). Mock mode intercepts runChildPi
616
+ * BEFORE this branch, so unit tests exercise the wiring through this export.
617
+ */
618
+ export const __test__trySurfaceBranch = trySurfaceBranch;
619
+
262
620
  export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
263
621
  // Phase 1 (live-session parity): prepend parent context when inheritContext is true.
264
622
  // This mirrors the effectivePrompt logic in live-session-runtime.ts so that
@@ -304,8 +662,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
304
662
  if (!brokerSpawn && brokerIssuer && input.runId) {
305
663
  try {
306
664
  // ADR-5 §4: thread the child depth so the issuer can contain broker
307
- // credentials at depths that may not delegate (depth-2 at default
308
- // maxDepth=2 gets NO socket/token — env containment AC).
665
+ // credentials at depths that may not delegate (a child at the default
666
+ // maxDepth=4 cap gets NO socket/token — env containment AC).
309
667
  brokerSpawn = await brokerIssuer(input.runId, input.agentId, input.depthOverride);
310
668
  } catch (error) {
311
669
  // H8 (2026-08-10): surface the silent degradation. Previously this
@@ -325,7 +683,12 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
325
683
  }
326
684
  const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...input, brokerSpawn } : input, effectiveTask, depthEnv);
327
685
  if (spawnPrep.kind === "aborted") return spawnPrep.result;
328
- const { spawnSpec, mergedEnv, tempDir, builtEnv } = spawnPrep.ctx;
686
+ const { spawnSpec, mergedEnv, tempDir, builtEnv, builtArgs } = spawnPrep.ctx;
687
+ // MuxSurface A1 (spec §13.1): attempt booting the worker in a mux pane BEFORE
688
+ // spawning a stdio pipe. Returns a finished result in surface mode (pane exit
689
+ // awaited) or null → fall through to the classic headless spawn below.
690
+ const surfaceResult = await trySurfaceBranch(input, depthEnv, builtArgs, mergedEnv, builtEnv, tempDir);
691
+ if (surfaceResult) return surfaceResult;
329
692
  try {
330
693
  return await new Promise<ChildPiRunResult>((resolve) => {
331
694
  // Compose the final SpawnOptions: canary + filter + spread are now
@@ -98,7 +98,19 @@ export function agentStatusPath(manifest: TeamRunManifest, taskId: string): stri
98
98
  }
99
99
 
100
100
  export function agentEventsPath(manifest: TeamRunManifest, taskId: string): string {
101
- return path.join(agentStateDir(manifest, taskId), "events.jsonl");
101
+ return agentEventsPathForStateRoot(manifest.stateRoot, taskId);
102
+ }
103
+
104
+ /**
105
+ * Per-agent events log dưới stateRoot bất kỳ (không cần manifest đầy đủ).
106
+ * MỘT công thức cho mọi bên: host writers/readers (qua agentEventsPath) và
107
+ * surface spawn (S2-T7 set PI_CREW_AGENT_EVENTS_PATH, S2-T9 tail) — tất cả
108
+ * qua safeAgentTaskId, nên taskId chứa ":" (delegate-spawn ids) rơi về CÙNG
109
+ * file cho cả worker recorder lẫn agent-view. Throw khi phần id sau sanitize
110
+ * không hợp lệ (assertSafePathId) — caller fail-closed tự xử lý.
111
+ */
112
+ export function agentEventsPathForStateRoot(stateRoot: string, taskId: string): string {
113
+ return path.join(stateRoot, "agents", safeAgentTaskId(taskId), "events.jsonl");
102
114
  }
103
115
 
104
116
  export function agentOutputPath(manifest: TeamRunManifest, taskId: string): string {
@@ -118,7 +118,16 @@ export async function sweepExpiredWaitingTasks(
118
118
  cwd: string,
119
119
  runId: string,
120
120
  now = Date.now(),
121
+ // PERF (2026-08-24): callers in the scheduler loop already hold a task view
122
+ // loaded this tick. Check the cheap expiry predicate against it BEFORE
123
+ // paying stat+parse of manifest.json + tasks.json — the sweep fires on every
124
+ // unit settle/dispatch and almost always finds nothing expired.
125
+ hintTasks?: TeamTaskState[],
121
126
  ): Promise<WaitingDeadlineSweepResult | undefined> {
127
+ if (hintTasks) {
128
+ const hintExpired = hintTasks.some((t) => t.status === "waiting" && t.waiting !== undefined && t.waiting.deadline <= now);
129
+ if (!hintExpired) return undefined;
130
+ }
122
131
  const initial = loadRunManifestById(cwd, runId);
123
132
  if (!initial) return undefined;
124
133
  const hasExpired = initial.tasks.some((t) => t.status === "waiting" && t.waiting !== undefined && t.waiting.deadline <= now);
@@ -375,7 +384,9 @@ function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): str
375
384
  * Mutates ctx.manifest/ctx.tasks from the sweeps' disk-reloaded results.
376
385
  */
377
386
  export async function runSchedulerSweeps(ctx: { manifest: TeamRunManifest; tasks: TeamTaskState[] }): Promise<void> {
378
- const sweep = await sweepExpiredWaitingTasks(ctx.manifest.cwd, ctx.manifest.runId);
387
+ // PERF (2026-08-24): pass this tick's task view as the expiry hint — the
388
+ // sweep then skips the manifest load entirely when nothing is expired.
389
+ const sweep = await sweepExpiredWaitingTasks(ctx.manifest.cwd, ctx.manifest.runId, Date.now(), ctx.tasks);
379
390
  if (sweep) {
380
391
  ctx.manifest = sweep.manifest;
381
392
  ctx.tasks = sweep.tasks;