pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. 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. */
@@ -192,6 +233,28 @@ export interface ChildPiRunInput {
192
233
  /** Base env for depth computation (defaults to process.env). Advanced —
193
234
  * used with depthOverride by the root-side delegate handler. */
194
235
  env?: NodeJS.ProcessEnv;
236
+ /**
237
+ * MuxSurface A1 (spec §13.1): opt-in/hint for booting this worker in a
238
+ * multiplexer pane instead of a stdio pipe. Undefined → the branch still
239
+ * consults the discovered project config, which defaults surface OFF
240
+ * (`visibleAgents: []`), so behaviour stays headless unless explicitly
241
+ * configured. Any failure degrades to the normal headless spawn path (§3
242
+ * fail-closed) — NEVER throws.
243
+ *
244
+ * - `providers`: pre-resolved provider instances — dispatch-owned (team-runner
245
+ * T11 passes its resolved provider + livePaneCount) and test injection.
246
+ * Hard gates (async run / host depth) still apply.
247
+ * - `config`: overrides discovered config (tests).
248
+ * - `livePaneCount`: panes already live for this run — `MAX_SURFACE_WORKERS`
249
+ * cap check. Defaults 0 until T11 threads manifest state.
250
+ * - `baseDir`: launch-script dir override (default getPiTempBase()).
251
+ */
252
+ surface?: {
253
+ providers?: { tmux?: SurfaceProvider; herdr?: SurfaceProvider };
254
+ config?: PiTeamsConfig;
255
+ livePaneCount?: number;
256
+ baseDir?: string;
257
+ };
195
258
  }
196
259
 
197
260
  export interface ChildPiRunResult {
@@ -217,6 +280,26 @@ export interface ChildPiRunResult {
217
280
  * text) still produce a non-empty result. Consumers should prefer rawFinalText
218
281
  * first — this is a last-resort fallback. */
219
282
  intermediateFindings?: string;
283
+ /**
284
+ * MuxSurface A1: present ONLY when the worker booted inside a multiplexer
285
+ * pane instead of a stdio pipe (spec §13.1). Downstream (T9 EventLogTailSource,
286
+ * T11 degrade) consumes the pane identity; the empty stdout/stderr is EXPECTED
287
+ * in surface mode — events arrive through the per-agent event log, not a pipe.
288
+ *
289
+ * `degraded` is set when classifyOnExit found NO worker.completed within the
290
+ * 2s window after the pane died (spec §7 D3) — task-runner hands that to
291
+ * finalize as `surfaceLost` instead of reading it as an empty/false result.
292
+ */
293
+ surface?: {
294
+ kind: "tmux" | "herdr";
295
+ paneId: string;
296
+ scriptPath: string;
297
+ degraded?: {
298
+ cause: "pane-closed" | "mux-dead";
299
+ exitReason: string;
300
+ classifiedAt: string;
301
+ };
302
+ };
220
303
  }
221
304
 
222
305
  // Base allowlist of non-provider env vars always passed to child workers.
@@ -259,6 +342,266 @@ function isFinalAssistantEvent(event: unknown): boolean {
259
342
  return !content.some((part) => asRecord(part)?.type === "toolCall");
260
343
  }
261
344
 
345
+ /**
346
+ * Discover the project crew config for the surface gate. The discovered config
347
+ * DEFAULTS surface off (`visibleAgents: []`) — headless unless explicitly opted
348
+ * in. Any discovery failure → default config (fail-closed §3), never throws.
349
+ */
350
+ function safeLoadSurfaceConfig(cwd: string): PiTeamsConfig {
351
+ try {
352
+ return loadConfig(cwd).config;
353
+ } catch (error) {
354
+ logInternalError(
355
+ "child-pi.surface-config",
356
+ error instanceof Error ? error : new Error(String(error)),
357
+ "surface gate uses default (off)",
358
+ );
359
+ return {};
360
+ }
361
+ }
362
+
363
+ /**
364
+ * MuxSurface A1 spawn branch (spec §13.1). Returns a ChildPiRunResult when the
365
+ * worker was booted INSIDE a multiplexer pane (no stdio process is spawned and
366
+ * completion is awaited through the pane's own lifetime), or null to fall
367
+ * through to the normal stdio spawn.
368
+ *
369
+ * NEVER throws: any preparation failure degrades to null (headless §3).
370
+ */
371
+ async function trySurfaceBranch(
372
+ input: ChildPiRunInput,
373
+ depthEnv: NodeJS.ProcessEnv | undefined,
374
+ builtArgs: string[],
375
+ mergedEnv: NodeJS.ProcessEnv,
376
+ builtEnv: Record<string, string | undefined>,
377
+ tempDir: string | undefined,
378
+ ): Promise<ChildPiRunResult | null> {
379
+ // A pane needs a task id for its title/event-log path — an anonymous worker
380
+ // has nothing meaningful to boot there, so stay on the headless road.
381
+ if (!input.agentId) return null;
382
+ const surfaceOpts = input.surface;
383
+ const preResolved = surfaceOpts?.providers?.tmux ?? surfaceOpts?.providers?.herdr;
384
+ // T11 (spec §7): per-run degrade controller — team-runner owns the policy
385
+ // (lockout counters, pane cap accounting); this layer only consults it.
386
+ // Absent controller (delegate spawn outside a team run, unit tests) keeps
387
+ // today's behavior untouched.
388
+ const taskId = input.agentId;
389
+ const degradeController = getSurfaceRuntimeController(input.runId);
390
+ if (degradeController && !degradeController.shouldAttemptSurface()) return null;
391
+ const surfaceConfig = surfaceOpts?.config ?? safeLoadSurfaceConfig(input.cwd);
392
+ let outcome: SurfaceSpawnOutcome;
393
+ try {
394
+ outcome = await prepareSurfaceSpawn({
395
+ // Detection env must be the HOST's base env (depth 0 tier-1), NOT the
396
+ // child's built env whose PI_CREW_DEPTH is parentDepth+1 — layer-1
397
+ // guard would misfire on our own tier-1 workers.
398
+ env: depthEnv ?? process.env,
399
+ workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model).env as Record<string, string>,
400
+ config: surfaceConfig,
401
+ role: input.role ?? input.agent.name,
402
+ livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
403
+ taskId,
404
+ cwd: input.cwd,
405
+ piArgs: builtArgs,
406
+ stateRoot: input.eventsPath ? path.dirname(input.eventsPath) : "",
407
+ baseDir: surfaceOpts?.baseDir,
408
+ deps: preResolved ? { provider: preResolved } : {},
409
+ });
410
+ } catch (error) {
411
+ // Defense in depth — prepareSurfaceSpawn is no-throw by contract (§3).
412
+ logInternalError("child-pi.surface-unexpected", error instanceof Error ? error : new Error(String(error)), `taskId=${taskId}`);
413
+ return null;
414
+ }
415
+ if (outcome.mode !== "surface") {
416
+ // Spawn-fail ≠ flap (spec §7) but it feeds ITS OWN lockout counter. Only
417
+ // `attempted` outcomes count — gate/resolve-null rejections are the mux
418
+ // simply being absent, not the multiplexer misbehaving.
419
+ if (outcome.mode === "headless" && outcome.attempted) {
420
+ degradeController?.notifySpawnFailed({ taskId, reason: outcome.reason ?? "surface spawn failed" });
421
+ input.onLifecycleEvent?.({
422
+ type: "surface_spawn_failed",
423
+ surfaceKind: preResolved?.kind,
424
+ error: outcome.reason,
425
+ ts: new Date().toISOString(),
426
+ });
427
+ }
428
+ // FINDING-3 (report 10tier 2026-08-27 adjudication): the gate-null path
429
+ // used to be completely silent — a misconfigured surface run (wrong
430
+ // visibleAgents, mode typo, …) was indistinguishable from a by-design
431
+ // headless run in events.jsonl. Emit ONLY when the user opted into
432
+ // surface (visibleAgents non-empty) so default runs gain zero noise.
433
+ // The lockout path above stays silent here — its cause already emitted
434
+ // surface.degraded events on the way to locking.
435
+ if (outcome.mode === "headless" && outcome.gateRejected) {
436
+ const visibleAgents = surfaceConfig.runtime?.surface?.visibleAgents ?? [];
437
+ if (visibleAgents.length > 0) {
438
+ input.onLifecycleEvent?.({
439
+ type: "surface_gate_blocked",
440
+ gate: outcome.gateRejected.gate,
441
+ error: outcome.gateRejected.reason,
442
+ env: outcome.gateRejected.env,
443
+ ts: new Date().toISOString(),
444
+ });
445
+ }
446
+ }
447
+ return null;
448
+ }
449
+
450
+ const surfaceMeta: NonNullable<ChildPiRunResult["surface"]> = {
451
+ kind: outcome.kind,
452
+ paneId: outcome.paneId,
453
+ scriptPath: outcome.scriptPath,
454
+ };
455
+ degradeController?.notifySpawned({
456
+ taskId,
457
+ paneId: outcome.paneId,
458
+ provider: outcome.kind,
459
+ // Task 5 (tab-layout): tab của run lên controller → manifest surface.tabs
460
+ // — run end (closeRunTabs) đóng đúng tab, worker xong KHÔNG đóng.
461
+ ...(outcome.tabKey && outcome.tabId ? { tabKey: outcome.tabKey, tabId: outcome.tabId } : {}),
462
+ });
463
+ input.onLifecycleEvent?.({
464
+ type: "surface_spawned",
465
+ surfaceKind: outcome.kind,
466
+ paneId: outcome.paneId,
467
+ ts: new Date().toISOString(),
468
+ });
469
+ // T9 (spec §5.3): surface worker không có stdout JSON stream — worker-side
470
+ // recorder ghi per-agent events.jsonl; host tail file đó và bridge thẳng ra
471
+ // run event bus cho dashboard/sidebar ("như cũ"). KHÔNG đưa vào
472
+ // input.onJsonEvent: callback task-runner gọi appendCrewAgentEventBuffered
473
+ // vào CHÍNH file này (worker đã ghi sẵn) → ghi đôi + vòng tự kích hoạt
474
+ // watch→append→watch không dừng (xem header event-log-tail-source.ts).
475
+ const eventSource = outcome.eventsPath ? new EventLogTailSource({ eventsPath: outcome.eventsPath }) : undefined;
476
+ if (eventSource && input.runId && input.agentId) {
477
+ const runId = input.runId;
478
+ const taskId = input.agentId;
479
+ const controllerForBridge = degradeController;
480
+ eventSource.onEvent((event) => {
481
+ // T11: the worker's own `worker.started` self-report carries the pid +
482
+ // session path that doctor/degrade-resume need (§12.2). Bridge it into
483
+ // the run-scoped controller BEFORE the dashboard shim — it is the only
484
+ // host-side consumer of that payload.
485
+ if ((event as { type?: unknown } | undefined)?.type === "worker.started") {
486
+ const started = event as { pid?: unknown; sessionPath?: unknown };
487
+ controllerForBridge?.notifyWorkerStarted({
488
+ taskId,
489
+ pid: typeof started.pid === "number" ? started.pid : undefined,
490
+ sessionPath: typeof started.sessionPath === "string" ? started.sessionPath : undefined,
491
+ });
492
+ }
493
+ const bridgeEvent = bridgeEventFromJsonEvent(runId, taskId, event);
494
+ if (bridgeEvent) runEventBus.emit({ type: "worker_status", runId, taskId, data: bridgeEvent });
495
+ });
496
+ }
497
+ let exitInfo: SurfaceExitInfo;
498
+ try {
499
+ // Pane lifetime IS the worker lifetime in A1: T8's auto-exit closes the
500
+ // pane at turn end (spec §13.2); cancel closes it here via AbortSignal and
501
+ // the response deadline force-closes a WEDGED worker (fix round 1/F2 —
502
+ // parity with headless minimum timeout so a stuck pane cannot hold its
503
+ // worker slot forever; A1 has no stream-activity signal so this is ONE
504
+ // hard deadline).
505
+ exitInfo = await waitForSurfaceExit(outcome, {
506
+ signal: input.signal,
507
+ deadlineMs: input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS,
508
+ });
509
+ } finally {
510
+ eventSource?.close();
511
+ }
512
+ // T11 classify (spec §7): D7 report-before-dying means a normally-finishing
513
+ // worker already wrote `worker.completed` to the RUN event log before its
514
+ // pane closed, so the probe finds it on the first scan. Nothing within the
515
+ // 2s window → the worker never finished → the pane death is a DEGRADE and
516
+ // team-runner re-dispatches this unit headless. Cancel/deadline force-closes
517
+ // are HOST-initiated — classify is skipped there (never counts as mux flap),
518
+ // but the pane release itself is reported in EVERY case so the run's live
519
+ // pane cap stays accurate.
520
+ let completedPayload: Record<string, unknown> | undefined;
521
+ if (!exitInfo.cancelledByAbort && !exitInfo.timedOut) {
522
+ const probe = makeTerminalEventProbe({ eventsPath: input.eventsPath ?? "", taskId, runId: input.runId ?? undefined });
523
+ const verdict = await classifyOnExit(outcome.handle, probe);
524
+ const completed = verdict === "completed";
525
+ if (completed) {
526
+ const payload = probe.foundPayload();
527
+ completedPayload = payload && typeof payload.result === "string" && payload.result.trim().length > 0 ? payload : undefined;
528
+ } else {
529
+ surfaceMeta.degraded = {
530
+ cause: exitInfo.reason === "mux-dead" ? "mux-dead" : "pane-closed",
531
+ exitReason: exitInfo.reason,
532
+ classifiedAt: new Date().toISOString(),
533
+ };
534
+ }
535
+ degradeController?.notifyPaneExited({
536
+ taskId,
537
+ paneId: outcome.paneId,
538
+ completed,
539
+ exitReason: exitInfo.reason,
540
+ });
541
+ } else {
542
+ degradeController?.notifyPaneExited({
543
+ taskId,
544
+ paneId: outcome.paneId,
545
+ completed: false,
546
+ exitReason: exitInfo.reason,
547
+ cancelledByAbort: exitInfo.cancelledByAbort,
548
+ timedOut: exitInfo.timedOut,
549
+ });
550
+ }
551
+ // Result text captured from `worker.completed` — WITHOUT it every surface
552
+ // task would look empty downstream (rawFinalText/stdout are the only result
553
+ // channels post-execution trusts) and flip to failed via the empty-output
554
+ // guard even though the worker SUCCEEDED.
555
+ const surfaceResultText = typeof completedPayload?.result === "string" ? completedPayload.result : "";
556
+ input.onLifecycleEvent?.({
557
+ type: "surface_closed",
558
+ surfaceKind: outcome.kind,
559
+ paneId: outcome.paneId,
560
+ paneExitReason: exitInfo.reason,
561
+ ts: new Date().toISOString(),
562
+ });
563
+ // F1: the pane is gone — nothing can still need the prompt/task temp dir.
564
+ // The headless branch cleans this up in settle(); surface returns early.
565
+ cleanupTempDir(tempDir);
566
+ // Empty stdout/stderr is EXPECTED — events flow through the per-agent event
567
+ // log (worker-side recorder, spec §5.3), not through a pipe we own. The only
568
+ // text the worker produced surfaces via `worker.completed` (surfaceResultText).
569
+ return {
570
+ exitCode: exitInfo.cancelledByAbort || exitInfo.timedOut ? null : 0,
571
+ stdout: "",
572
+ stderr: "",
573
+ ...(exitInfo.cancelledByAbort
574
+ ? { error: `Cancelled while running in ${outcome.kind} pane ${outcome.paneId} (${exitInfo.reason})` }
575
+ : {}),
576
+ ...(exitInfo.timedOut
577
+ ? {
578
+ error:
579
+ `Surface worker in ${outcome.kind} pane ${outcome.paneId} produced no completion within ` +
580
+ `${input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS}ms response timeout; pane was force-closed.`,
581
+ }
582
+ : {}),
583
+ rawFinalText: surfaceResultText,
584
+ intermediateFindings: "",
585
+ ...(exitInfo.cancelledByAbort ? { aborted: true } : {}),
586
+ surface: surfaceMeta,
587
+ exitStatus: {
588
+ exitCode: exitInfo.cancelledByAbort || exitInfo.timedOut ? null : 0,
589
+ cancelled: exitInfo.cancelledByAbort,
590
+ timedOut: exitInfo.timedOut,
591
+ killed: false,
592
+ cleanupErrors: [],
593
+ finalDrainMs: 0,
594
+ },
595
+ };
596
+ }
597
+
598
+ /**
599
+ * Test seam — trySurfaceBranch là internal wiring giữa prepareSurfaceSpawn và
600
+ * lifecycle events (surface_gate_blocked). Mock mode intercepts runChildPi
601
+ * BEFORE this branch, so unit tests exercise the wiring through this export.
602
+ */
603
+ export const __test__trySurfaceBranch = trySurfaceBranch;
604
+
262
605
  export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
263
606
  // Phase 1 (live-session parity): prepend parent context when inheritContext is true.
264
607
  // This mirrors the effectivePrompt logic in live-session-runtime.ts so that
@@ -304,8 +647,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
304
647
  if (!brokerSpawn && brokerIssuer && input.runId) {
305
648
  try {
306
649
  // 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).
650
+ // credentials at depths that may not delegate (a child at the default
651
+ // maxDepth=4 cap gets NO socket/token — env containment AC).
309
652
  brokerSpawn = await brokerIssuer(input.runId, input.agentId, input.depthOverride);
310
653
  } catch (error) {
311
654
  // H8 (2026-08-10): surface the silent degradation. Previously this
@@ -325,7 +668,12 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
325
668
  }
326
669
  const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...input, brokerSpawn } : input, effectiveTask, depthEnv);
327
670
  if (spawnPrep.kind === "aborted") return spawnPrep.result;
328
- const { spawnSpec, mergedEnv, tempDir, builtEnv } = spawnPrep.ctx;
671
+ const { spawnSpec, mergedEnv, tempDir, builtEnv, builtArgs } = spawnPrep.ctx;
672
+ // MuxSurface A1 (spec §13.1): attempt booting the worker in a mux pane BEFORE
673
+ // spawning a stdio pipe. Returns a finished result in surface mode (pane exit
674
+ // awaited) or null → fall through to the classic headless spawn below.
675
+ const surfaceResult = await trySurfaceBranch(input, depthEnv, builtArgs, mergedEnv, builtEnv, tempDir);
676
+ if (surfaceResult) return surfaceResult;
329
677
  try {
330
678
  return await new Promise<ChildPiRunResult>((resolve) => {
331
679
  // 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;