pi-crew 0.10.1 → 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 (127) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/executor.md +1 -1
  5. package/agents/test-engineer.md +1 -1
  6. package/dist/index.mjs +2555 -932
  7. package/package.json +2 -1
  8. package/schema.json +503 -94
  9. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  10. package/skills/real-test-pi-crew/SKILL.md +278 -79
  11. package/src/config/config-merge.ts +11 -1
  12. package/src/config/config-validation.ts +47 -2
  13. package/src/config/config.ts +28 -6
  14. package/src/config/defaults.ts +43 -11
  15. package/src/config/env-vars.ts +27 -2
  16. package/src/config/role-tools.ts +4 -2
  17. package/src/config/types.ts +55 -1
  18. package/src/extension/crew-cleanup.ts +13 -0
  19. package/src/extension/crew-vibes/footer.ts +19 -0
  20. package/src/extension/crew-vibes/index.ts +11 -1
  21. package/src/extension/register.ts +8 -0
  22. package/src/extension/registration/command-registration.ts +1 -0
  23. package/src/extension/registration/commands/run.ts +15 -1
  24. package/src/extension/registration/commands/shared.ts +8 -0
  25. package/src/extension/registration/foreground-run-controller.ts +10 -2
  26. package/src/extension/registration/lifecycle-handlers.ts +92 -11
  27. package/src/extension/registration/runtime-cleanup.ts +23 -5
  28. package/src/extension/registration/team-tool.ts +58 -6
  29. package/src/extension/registration/ui.ts +5 -4
  30. package/src/extension/team-tool/doctor.ts +364 -7
  31. package/src/extension/team-tool/handle-settings.ts +19 -0
  32. package/src/extension/team-tool/inspect.ts +10 -2
  33. package/src/extension/team-tool/run-deadline.ts +20 -3
  34. package/src/extension/team-tool/run.ts +26 -2
  35. package/src/extension/team-tool/status.ts +7 -0
  36. package/src/extension/team-tool.ts +35 -2
  37. package/src/hooks/registry.ts +59 -56
  38. package/src/prompt/inbox-poll.ts +90 -0
  39. package/src/prompt/message-tool.ts +166 -0
  40. package/src/prompt/prompt-runtime.ts +201 -18
  41. package/src/prompt/surface-worker.ts +720 -0
  42. package/src/prompt/worker-events-channel.ts +49 -3
  43. package/src/runtime/async-runner.ts +29 -1
  44. package/src/runtime/background-runner.ts +13 -7
  45. package/src/runtime/broker/broker-issuer.ts +27 -2
  46. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  47. package/src/runtime/broker/crew-broker.ts +261 -41
  48. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  49. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  50. package/src/runtime/child-pi/child-pi-streams.ts +30 -2
  51. package/src/runtime/child-pi/child-pi.ts +353 -5
  52. package/src/runtime/crew-agent-records.ts +13 -1
  53. package/src/runtime/detached-run-results.ts +90 -0
  54. package/src/runtime/dispatch-batch.ts +12 -1
  55. package/src/runtime/event-log-tail-source.ts +374 -0
  56. package/src/runtime/finalize-run.ts +4 -0
  57. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  58. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  59. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  60. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  61. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  62. package/src/runtime/manifest-cache.ts +128 -17
  63. package/src/runtime/merge-gate.ts +25 -9
  64. package/src/runtime/model/model-fallback.ts +7 -3
  65. package/src/runtime/model/pi-args.ts +54 -65
  66. package/src/runtime/output/sidechain-output.ts +61 -6
  67. package/src/runtime/process/proc-stat.ts +46 -0
  68. package/src/runtime/process/zombie-scanner.ts +32 -19
  69. package/src/runtime/process-status.ts +16 -1
  70. package/src/runtime/recovery/crash-recovery.ts +16 -0
  71. package/src/runtime/run-tracker.ts +77 -10
  72. package/src/runtime/spawn-policy.ts +27 -41
  73. package/src/runtime/surface/degrade.ts +776 -0
  74. package/src/runtime/surface/herdr-provider.ts +546 -0
  75. package/src/runtime/surface/launch-script.ts +172 -0
  76. package/src/runtime/surface/resolve-surface.ts +274 -0
  77. package/src/runtime/surface/surface-provider.ts +129 -0
  78. package/src/runtime/surface/surface-spawn.ts +475 -0
  79. package/src/runtime/surface/tmux-provider.ts +400 -0
  80. package/src/runtime/task-runner/child-executor.ts +47 -0
  81. package/src/runtime/task-runner/post-execution.ts +57 -2
  82. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  83. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  84. package/src/runtime/task-runner/state-helpers.ts +54 -30
  85. package/src/runtime/task-runner.ts +4 -2
  86. package/src/runtime/team-runner.ts +104 -2
  87. package/src/schema/config-schema.ts +25 -1
  88. package/src/state/atomic-write.ts +219 -40
  89. package/src/state/coordination/locks.ts +7 -5
  90. package/src/state/coordination/mailbox.ts +56 -10
  91. package/src/state/event-log/cursor.ts +413 -23
  92. package/src/state/event-log/event-log.ts +120 -113
  93. package/src/state/event-log/sequence-cache.ts +21 -3
  94. package/src/state/stores/plan-store.ts +1 -1
  95. package/src/state/stores/state-store.ts +171 -9
  96. package/src/state/types.ts +53 -0
  97. package/src/ui/dock-footer.ts +49 -0
  98. package/src/ui/inline-panel/agent-pane.ts +378 -0
  99. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  100. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  101. package/src/ui/inline-panel/crew-editor.ts +192 -0
  102. package/src/ui/inline-panel/index.ts +290 -0
  103. package/src/ui/inline-panel/panel-rows.ts +37 -0
  104. package/src/ui/inline-panel/panel-selection.ts +157 -0
  105. package/src/ui/inline-panel/panel-store.ts +111 -0
  106. package/src/ui/inline-panel/view-session-store.ts +36 -0
  107. package/src/ui/pi-ui-compat.ts +9 -0
  108. package/src/ui/render-diff.ts +16 -8
  109. package/src/ui/run-dashboard.ts +87 -42
  110. package/src/ui/run-event-bus.ts +10 -1
  111. package/src/ui/run-snapshot-cache.ts +83 -35
  112. package/src/ui/transcript-cache.ts +101 -13
  113. package/src/ui/transcript-viewer.ts +92 -24
  114. package/src/ui/widget/index.ts +203 -25
  115. package/src/ui/widget/task-list.ts +198 -0
  116. package/src/ui/widget/widget-formatters.ts +240 -4
  117. package/src/ui/widget/widget-renderer.ts +234 -37
  118. package/src/ui/widget/widget-types.ts +11 -0
  119. package/src/utils/child-process-shield.ts +106 -0
  120. package/src/utils/redaction.ts +7 -0
  121. package/src/utils/safe-abort.ts +45 -0
  122. package/src/utils/visual.ts +43 -0
  123. package/src/workflows/discover-workflows.ts +1 -0
  124. package/src/workflows/workflow-config.ts +7 -0
  125. package/src/worktree/worktree-manager.ts +65 -4
  126. package/workflows/default.workflow.md +36 -26
  127. package/workflows/strict-fast-fix.workflow.md +26 -0
@@ -10,6 +10,7 @@ import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../../utils/paths.t
10
10
  import { assertSafePathId, resolveContainedRelativePath, resolveRealContainedPath } from "../../utils/safe-paths.ts";
11
11
  import { toPiSessionId } from "../../utils/session-utils.ts";
12
12
  import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
13
+ import type { WriteDurability } from "../atomic-write.ts";
13
14
  import {
14
15
  atomicWriteJson,
15
16
  atomicWriteJsonAsync,
@@ -207,6 +208,26 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
207
208
  return scopedPath;
208
209
  }
209
210
 
211
+ // PERF (2026-08-24): the artifacts containment verdict (existsSync + lstat +
212
+ // resolveRealContainedPath ≈ 10-25 syscalls) cannot change for a run dir that
213
+ // is not replaced — mirror the P1-12 runStateRootCache tradeoff: positive
214
+ // verdicts cached 10s, negatives never cached (a newly created artifacts dir
215
+ // must be found promptly). A stale positive is safe the same way P1-12 is:
216
+ // downstream manifest stat catches deleted runs, and any write through
217
+ // atomic-write re-runs isSymlinkSafeDirCached independently.
218
+ const artifactsVerdictCache = new Map<string, { expiresAt: number }>();
219
+ const ARTIFACTS_VERDICT_TTL_MS = 10_000;
220
+ const ARTIFACTS_VERDICT_CACHE_MAX = 256;
221
+
222
+ /** @internal — artifacts verdict cache introspection for unit tests. */
223
+ export function __test__artifactsVerdictCacheSize(): number {
224
+ return artifactsVerdictCache.size;
225
+ }
226
+ /** @internal */
227
+ export function __test__clearArtifactsVerdictCache(): void {
228
+ artifactsVerdictCache.clear();
229
+ }
230
+
210
231
  function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunManifest, stateRoot: string, tasksPath: string): boolean {
211
232
  // Issue 2 fix: Reject manifests missing status field to prevent undefined
212
233
  // behavior in callers like canTransitionRunStatus(manifest.status, newStatus).
@@ -221,6 +242,12 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
221
242
  const artifactsParent = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.artifactsSubdir);
222
243
  const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
223
244
  if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
245
+ // PERF (2026-08-24): memoized verdict — see artifactsVerdictCache above.
246
+ // Hit only after the cheap manifest-identity checks above, so a tampered
247
+ // manifest (wrong paths/status) is still rejected without touching the memo.
248
+ const verdictKey = `${cwd}\0${runId}`;
249
+ const cachedVerdict = artifactsVerdictCache.get(verdictKey);
250
+ if (cachedVerdict && cachedVerdict.expiresAt > Date.now()) return true;
224
251
  // Always validate artifactsRoot is not a symlink, even when manifest has
225
252
  // no artifacts entries. A symlinked artifactsRoot pointing outside the
226
253
  // artifacts parent is a security violation (could write to attacker-
@@ -237,8 +264,18 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
237
264
  } else if (manifest.artifacts && manifest.artifacts.length > 0) {
238
265
  // Has artifacts entries but directory doesn't exist - benign state for
239
266
  // runs still in progress.
267
+ if (artifactsVerdictCache.size >= ARTIFACTS_VERDICT_CACHE_MAX) {
268
+ const oldest = artifactsVerdictCache.keys().next().value;
269
+ if (oldest !== undefined) artifactsVerdictCache.delete(oldest);
270
+ }
271
+ artifactsVerdictCache.set(verdictKey, { expiresAt: Date.now() + ARTIFACTS_VERDICT_TTL_MS });
240
272
  return true;
241
273
  }
274
+ if (artifactsVerdictCache.size >= ARTIFACTS_VERDICT_CACHE_MAX) {
275
+ const oldest = artifactsVerdictCache.keys().next().value;
276
+ if (oldest !== undefined) artifactsVerdictCache.delete(oldest);
277
+ }
278
+ artifactsVerdictCache.set(verdictKey, { expiresAt: Date.now() + ARTIFACTS_VERDICT_TTL_MS });
242
279
  return true;
243
280
  }
244
281
 
@@ -260,7 +297,42 @@ export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
260
297
  };
261
298
  }
262
299
 
263
- export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig, team: TeamConfig, cwd: string): TeamTaskState[] {
300
+ /**
301
+ * Trailing phrases a step template leaves in front of `{goal}` — "Explore
302
+ * the codebase for the goal: {goal}" should name the task ("Explore the
303
+ * codebase"), not quote the whole run goal after every verb. The run goal is
304
+ * run-level context (statusline shows it once); a task list that repeats it
305
+ * per row reads like a roster of workers, not a list of work.
306
+ */
307
+ const GOAL_TAILS = ["for the goal", "for goal", "the goal", "goal", "cho mục tiêu", "của mục tiêu", "mục tiêu", "for", "of", "cho", "của"];
308
+
309
+ function stripGoalTail(prefix: string): string {
310
+ let name = prefix.replace(/[\s:—-]+$/, "").trim();
311
+ let changed = true;
312
+ while (changed) {
313
+ changed = false;
314
+ const lowered = name.toLowerCase();
315
+ for (const tail of GOAL_TAILS) {
316
+ if (lowered.endsWith(tail) && lowered.length > tail.length) {
317
+ name = name
318
+ .slice(0, name.length - tail.length)
319
+ .replace(/[\s:—-]+$/, "")
320
+ .trim();
321
+ changed = true;
322
+ break;
323
+ }
324
+ }
325
+ }
326
+ return name;
327
+ }
328
+
329
+ export function createTasksFromWorkflow(
330
+ runId: string,
331
+ workflow: WorkflowConfig,
332
+ team: TeamConfig,
333
+ cwd: string,
334
+ goal?: string,
335
+ ): TeamTaskState[] {
264
336
  const stepToTaskId = new Map(workflow.steps.map((step, index) => [step.id, createTaskId(step.id, index)]));
265
337
  return workflow.steps.map((step, index) => {
266
338
  const role = team.roles.find((candidate) => candidate.name === step.role);
@@ -270,13 +342,48 @@ export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig,
270
342
  .filter((candidate) => candidate.dependsOn?.includes(step.id))
271
343
  .map((candidate) => stepToTaskId.get(candidate.id))
272
344
  .filter((childId): childId is string => childId !== undefined);
345
+ // The plan's own words become the task's display identity: a heading
346
+ // from the step body if present, else its first content line; the
347
+ // remaining body is kept as `description` for detail surfaces (task
348
+ // list detail line, view). Falls back to the step id when the body
349
+ // carries no text of its own — `task` is optional on literal
350
+ // WorkflowStep objects.
351
+ //
352
+ // `{goal}` in the title line is the RUN's goal, context for every
353
+ // task, not this task's name. The template prefix before the
354
+ // placeholder names the task; a placeholder-only body ("{goal}",
355
+ // direct-run) adopts the goal itself as the title. `description`
356
+ // lines substitute the placeholder (the prompt path substitutes
357
+ // separately — unchanged).
358
+ const bodyLines =
359
+ typeof step.task === "string" && step.task.trim()
360
+ ? step.task
361
+ .split("\n")
362
+ .map((line) => line.trim())
363
+ .filter(Boolean)
364
+ : [];
365
+ const heading = bodyLines.find((line) => line.startsWith("#"));
366
+ const titleLine = heading ?? bodyLines[0];
367
+ const substitute = (text: string): string => (goal ? text.replaceAll("{goal}", goal) : text);
368
+ let title: string;
369
+ let restLines: string[];
370
+ const placeholderAt = titleLine?.indexOf("{goal}") ?? -1;
371
+ if (titleLine && placeholderAt >= 0) {
372
+ title = stripGoalTail(titleLine.slice(0, placeholderAt)) || goal || step.id;
373
+ restLines = bodyLines.filter((line) => line !== titleLine);
374
+ } else {
375
+ title = (titleLine ?? step.id).replace(/^#+\s*/, "");
376
+ restLines = heading ? bodyLines.filter((line) => line !== titleLine) : bodyLines.slice(1);
377
+ }
378
+ const description = restLines.map(substitute).join("\n");
273
379
  return {
274
380
  id,
275
381
  runId,
276
382
  stepId: step.id,
277
383
  role: step.role,
278
384
  agent: role?.agent ?? step.role,
279
- title: step.id,
385
+ title: title.slice(0, 120),
386
+ description: description && description !== title ? description : undefined,
280
387
  status: "queued",
281
388
  dependsOn: dependencies,
282
389
  cwd,
@@ -305,7 +412,7 @@ export function createRunManifest(params: {
305
412
  }): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
306
413
  const paths = createRunPaths(params.cwd);
307
414
  const now = new Date().toISOString();
308
- const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd) : [];
415
+ const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
309
416
  const manifest: TeamRunManifest = {
310
417
  schemaVersion: CURRENT_SCHEMA_VERSION,
311
418
  runId: paths.runId,
@@ -554,19 +661,52 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
554
661
  * tasks.json stale (showing "running") while events.jsonl already shows
555
662
  * the terminal event, causing false zombie detection / double-execution
556
663
  * on crash recovery.
664
+ *
665
+ * PERF round 2, Task 3: optional `durability` (default "full") overrides the
666
+ * coalesced entry's durability. When the caller wants a non-terminal checkpoint
667
+ * written without fsync (persistence.skipTasksFsync opt-in), pass
668
+ * `durability: "best-effort"` — the coalesced entry stores it and the flush
669
+ * forwards it to atomicWriteFile (see atomic-write.ts:997). Terminal
670
+ * transitions (skipCoalesce=true) always fall to atomicWriteJson with full
671
+ * durability regardless of this param.
557
672
  */
558
673
  /** @internal */
559
- export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[], skipCoalesce: boolean = false): void {
674
+ export function saveRunTasksCoalesced(
675
+ manifest: TeamRunManifest,
676
+ tasks: TeamTaskState[],
677
+ skipCoalesce: boolean = false,
678
+ durability: WriteDurability = "full",
679
+ ): void {
560
680
  // ST-4: refuse to persist [] over a previously-non-empty tasks file.
561
681
  if (!shouldPersistTasks(manifest, tasks)) return;
562
- // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
563
- invalidateRunCache(manifest.stateRoot);
682
+ // PERF (2026-08-24, Task 12): invalidating the WHOLE entry made every
683
+ // loadRunManifestById after a coalesced save re-read + re-parse
684
+ // manifest.json (24KB+) even though the manifest file did not change —
685
+ // persistSingleTaskUpdate's next call (~500ms later) always paid it. Keep
686
+ // the manifest half of the entry and zero only the tasks stamps, which is
687
+ // the exact pre-existing signal for "tasks on disk may be stale" (coalesced
688
+ // write not landed yet). The load path cooperates: the zeroed tasks stamps
689
+ // force the slow path (tasks are always re-read from disk), but when the
690
+ // retained manifest stamps still match a fresh stat of manifest.json — the
691
+ // same mtime/size verification the fast path performs — the retry loop
692
+ // reuses the cached manifest object and skips the re-read + re-parse.
693
+ // Crash safety is unchanged: a zeroed tasks stamp can only cause a miss,
694
+ // never a stale hit, and a concurrent manifest rewrite changes mtime/size
695
+ // so the manifest reuse never serves stale content. Generation semantics
696
+ // unchanged — setManifestCache stamps the CURRENT generation, so a
697
+ // concurrent writer's bump still invalidates us.
698
+ const cached = manifestCache.get(manifest.stateRoot);
699
+ if (cached) {
700
+ setManifestCache(manifest.stateRoot, { ...cached, tasks, tasksMtimeMs: 0, tasksSize: 0 });
701
+ } else {
702
+ invalidateRunCache(manifest.stateRoot);
703
+ }
564
704
  try {
565
705
  fs.statSync(manifest.stateRoot);
566
706
  } catch {
567
707
  return;
568
708
  }
569
- atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, { compact: true }, skipCoalesce);
709
+ atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, { compact: true, durability }, skipCoalesce);
570
710
  }
571
711
 
572
712
  export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
@@ -873,7 +1013,19 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
873
1013
  let tasks: TeamTaskState[] | undefined;
874
1014
  while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
875
1015
  const freshStat = fs.statSync(manifestPath);
876
- manifest = readJsonFile<TeamRunManifest>(manifestPath);
1016
+ // PERF (2026-08-24, Task 12 realized): after saveRunTasksCoalesced the
1017
+ // cache keeps the manifest half of the entry while zeroing only the
1018
+ // tasks stamps, so this slow path runs solely to refresh tasks. When the
1019
+ // retained manifest stamps still match the fresh stat — the exact
1020
+ // mtime/size verification the fast path performs — reuse the cached
1021
+ // manifest object instead of re-reading + re-parsing manifest.json.
1022
+ // Stamp verification is NOT bypassed: any manifest.json rewrite changes
1023
+ // mtime/size and falls back to the disk read. The tasks re-read below
1024
+ // still always happens — zeroed tasks stamps must force it.
1025
+ manifest =
1026
+ cached && cached.manifestMtimeMs === freshStat.mtimeMs && cached.manifestSize === freshStat.size
1027
+ ? cached.manifest
1028
+ : readJsonFile<TeamRunManifest>(manifestPath);
877
1029
  const freshTasksStat = fs.existsSync(tasksPath) ? fs.statSync(tasksPath) : undefined;
878
1030
  tasks = loadTasksWithRecovery(tasksPath, manifest?.eventsPath ?? path.join(stateRoot, "events.jsonl"), manifest?.runId ?? runId);
879
1031
  // If size/mtime didn't change between stat and read, we're consistent.
@@ -1015,7 +1167,17 @@ export async function loadRunManifestByIdAsync(
1015
1167
  let attempts = 0;
1016
1168
  while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
1017
1169
  const freshStat = await fs.promises.stat(manifestPath);
1018
- manifest = await readJsonFileAsync<TeamRunManifest>(manifestPath);
1170
+ // PERF (2026-08-24, Task 12 realized): async twin of the sync reuse —
1171
+ // after saveRunTasksCoalesced only the tasks stamps are zeroed, so when
1172
+ // the retained manifest stamps match the fresh stat (same verification
1173
+ // the fast path performs), reuse the cached manifest object instead of
1174
+ // re-reading + re-parsing manifest.json. A manifest.json rewrite changes
1175
+ // mtime/size and falls back to the disk read; the tasks re-read below
1176
+ // always happens.
1177
+ manifest =
1178
+ cached && cached.manifestMtimeMs === freshStat.mtimeMs && cached.manifestSize === freshStat.size
1179
+ ? cached.manifest
1180
+ : await readJsonFileAsync<TeamRunManifest>(manifestPath);
1019
1181
  const freshTasksStat = await fs.promises.stat(tasksPath).catch(() => undefined);
1020
1182
  tasks = await loadTasksWithRecoveryAsync(
1021
1183
  tasksPath,
@@ -408,6 +408,9 @@ export interface TeamRunManifest {
408
408
  * models.json happens to list first.
409
409
  */
410
410
  modelContext?: RunModelContext;
411
+ /** MuxSurface A1 (spec §8.3): pane/pid/lockout state of this run. Absent on
412
+ * runs that never booted a surface worker and on older manifests. */
413
+ surface?: ManifestSurfaceState;
411
414
  }
412
415
 
413
416
  export interface RunModelContext {
@@ -421,6 +424,54 @@ export interface RunModelContext {
421
424
  availableModels?: string[];
422
425
  }
423
426
 
427
+ /**
428
+ * MuxSurface A1 run-scope surface state (spec §8.3 / §12.3). Written by the
429
+ * team-runner's surface-degrade controller (runtime/surface/degrade.ts) and
430
+ * read by doctor (T12). Plain JSON — manifests parse without a schema, so an
431
+ * absent field means "no surface worker ever booted" and older writers simply
432
+ * don't emit it (backward-compatible in both directions).
433
+ *
434
+ * Per-run scope: every new run starts without `surface`, which IS the
435
+ * "reset ở run sau" of the anti-flap policy (spec §7 step 3).
436
+ */
437
+ export interface ManifestSurfaceState {
438
+ provider: "tmux" | "herdr" | null;
439
+ /** taskId → pane id of the LIVE pane (removed when the pane is released). */
440
+ panes: Record<string, string>;
441
+ /** taskId → pid, captured from the worker's own `worker.started` event. */
442
+ workerPids: Record<string, number>;
443
+ /**
444
+ * taskId → session file path when the worker self-reports one (optional
445
+ * field of §12.2 — never populated in A1 because pi does not expose its
446
+ * session path to extensions; kept for the degrade-resume seam).
447
+ */
448
+ sessionPaths: Record<string, string>;
449
+ /**
450
+ * Tab-layout (spec 2026-08-27-surface-tab-layout §5): tabKey (runId) →
451
+ * tab/window ids của run — ghi khi worker spawn trong tab-flow (handle.tabId
452
+ * qua outcome), KHÔNG gỡ khi từng worker xong (tab sống tới run end). Entry
453
+ * được clear (giữ key rỗng) khi run end đã đóng tab qua closeTabForRun.
454
+ * Run dài vượt MAX_PANES_PER_TAB tích lũy nhiều id cùng key.
455
+ *
456
+ * Manifest TRÊN ĐĨA giữ nguyên tabIds sau run end (evidence cho doctor) —
457
+ * tabs non-empty KHÔNG đồng nghĩa orphan; doctor phải liveness-check từng
458
+ * tabId qua mux rồi close-by-ID idempotent (cleanupOrphanSurfacePanes,
459
+ * provider.closeTabById), không đóng mù theo số entry.
460
+ */
461
+ tabs?: Record<string, string[]>;
462
+ /**
463
+ * Surface is OFF for the rest of this run since `since`:
464
+ * - cause "degrade": ≥1 degrade entry after the classify timeout
465
+ * (anti-flap; counts keep per-cause evidence for doctor).
466
+ * - cause "spawn-fail": 3 consecutive spawn failures.
467
+ */
468
+ lockout?: {
469
+ since: string;
470
+ counts: { pane: number; mux: number };
471
+ cause: "degrade" | "spawn-fail";
472
+ };
473
+ }
474
+
424
475
  export interface UsageState {
425
476
  input?: number;
426
477
  output?: number;
@@ -554,6 +605,8 @@ export interface TeamTaskState {
554
605
  role: string;
555
606
  agent: string;
556
607
  title: string;
608
+ /** Full step body (the detailed plan text behind `title`). */
609
+ description?: string;
557
610
  displayName?: string;
558
611
  status: TeamTaskStatus;
559
612
  dependsOn: string[];
@@ -0,0 +1,49 @@
1
+ /**
2
+ * dock-footer.ts — dependency-free bridge between the crew dock and the
3
+ * crew-vibes footer.
4
+ *
5
+ * When `ui.widgetPlacement` is `"bottom"`, the dock's rendered lines are
6
+ * produced by the crew widget but PAINTED by the crew-vibes footer, below the
7
+ * quota/meter lines — the very bottom of the terminal. The two halves are
8
+ * mounted by different modules (widget/index.ts owns the widget lifecycle,
9
+ * crew-vibes/footer.ts owns the footer), so the connection lives here as a
10
+ * tiny observable registry with no imports:
11
+ * - the widget registers a per-render line provider;
12
+ * - crew-vibes flips the "sink" flag when its footer is (un)installed;
13
+ * - the footer pulls the provider's lines on every render.
14
+ *
15
+ * Safety: the widget falls back to pi's `belowEditor` widget slot whenever no
16
+ * footer sink is active (crew-vibes disabled), so `"bottom"` never silently
17
+ * hides the dock.
18
+ */
19
+
20
+ export type FooterDockLinesProvider = (width: number) => string[];
21
+
22
+ let dockProvider: FooterDockLinesProvider | undefined;
23
+ let sinkActive = false;
24
+
25
+ /** Register the dock line provider (widget side). Pass `undefined` to detach. */
26
+ export function setFooterDockProvider(provider: FooterDockLinesProvider | undefined): void {
27
+ dockProvider = provider;
28
+ }
29
+
30
+ /** Current dock line provider, or undefined when the dock is not in the footer. */
31
+ export function getFooterDockProvider(): FooterDockLinesProvider | undefined {
32
+ return dockProvider;
33
+ }
34
+
35
+ /** Mark whether a footer sink (crew-vibes) is currently installed. */
36
+ export function setFooterDockSinkActive(active: boolean): void {
37
+ sinkActive = active;
38
+ }
39
+
40
+ /** True while crew-vibes' custom footer is installed and can host the dock. */
41
+ export function isFooterDockSinkActive(): boolean {
42
+ return sinkActive;
43
+ }
44
+
45
+ /** Test isolation. */
46
+ export function resetFooterDockRegistry(): void {
47
+ dockProvider = undefined;
48
+ sinkActive = false;
49
+ }