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
@@ -11,6 +11,9 @@ import { DEFAULT_UI } from "../../config/defaults.ts";
11
11
  import type { ManifestCache } from "../../runtime/manifest-cache.ts";
12
12
  import type { TeamRunManifest } from "../../state/types.ts";
13
13
  import { truncate } from "../../utils/visual.ts";
14
+ import { isFooterDockSinkActive, setFooterDockProvider } from "../dock-footer.ts";
15
+ import { panelRowsFromRuns } from "../inline-panel/panel-rows.ts";
16
+ import { panelDisplayState, setPanelRowsProvider, subscribePanelChange } from "../inline-panel/panel-store.ts";
14
17
  import { requestRender, requestRenderTarget, setExtensionWidget } from "../pi-ui-compat.ts";
15
18
  import type { OverlaySchedulerHandle } from "../shared-overlay-scheduler.ts";
16
19
  import { registerOverlayScheduler } from "../shared-overlay-scheduler.ts";
@@ -18,6 +21,7 @@ import type { RunSnapshotCache } from "../snapshot-types.ts";
18
21
  import { spinnerBucket, spinnerFrame } from "../spinner.ts";
19
22
  import type { CrewTheme } from "../theme-adapter.ts";
20
23
  import { asCrewTheme, subscribeThemeChange } from "../theme-adapter.ts";
24
+ import { buildTaskListLines } from "./task-list.ts";
21
25
  import { activeWidgetRuns, statusSummary } from "./widget-model.ts";
22
26
  import { buildWidgetLines, colorWidgetLine, DEFAULT_WIDGET_WIDTH, renderLines } from "./widget-renderer.ts";
23
27
  import type { CrewWidgetModel, CrewWidgetState, WidgetRun } from "./widget-types.ts";
@@ -62,6 +66,8 @@ export {
62
66
  const MAX_LINES_DEFAULT = DEFAULT_UI.widgetMaxLines;
63
67
  const LEGACY_WIDGET_KEY = "pi-crew";
64
68
  const WIDGET_KEY = "pi-crew-active";
69
+ /** The run's plan progress, painted ABOVE the editor (task-list.ts). */
70
+ const TASKS_WIDGET_KEY = "pi-crew-tasks";
65
71
  const STATUS_KEY = "pi-crew";
66
72
 
67
73
  /**
@@ -78,11 +84,12 @@ const SIGNATURE_CACHE_TTL_MS = 100;
78
84
  // next invalidate; a mid-run resize could briefly paint a frame at the old
79
85
  // width. We register ONE debounced process-level listener (guarded so it never
80
86
  // accumulates across widget reinstalls) that busts the active widget's cache
81
- // and pokes Pi to repaint at the new width. The listener references a
82
- // module-level `activeResizeTarget` (the most-recently-mounted widget) rather
83
- // than a specific instance, so replaced widgets do not leak listeners.
87
+ // and pokes Pi to repaint at the new width. The listener references the
88
+ // module-level `activeResizeTargets` set (every mounted widget) rather than
89
+ // specific instances, so replaced widgets do not leak listeners.
84
90
  let resizeListenerInstalled = false;
85
- let activeResizeTarget: { invalidate(): void; requestRepaint(): void } | undefined;
91
+ /** All mounted widgets (dock + task list) — every one gets resize-busted. */
92
+ const activeResizeTargets = new Set<{ invalidate(): void; requestRepaint(): void }>();
86
93
  let resizeTimer: ReturnType<typeof setTimeout> | undefined;
87
94
 
88
95
  /**
@@ -95,8 +102,10 @@ const onResize = (): void => {
95
102
  // Debounce (~120ms) so a drag-resize doesn't thrash renders.
96
103
  resizeTimer = setTimeout(() => {
97
104
  resizeTimer = undefined;
98
- activeResizeTarget?.invalidate();
99
- activeResizeTarget?.requestRepaint();
105
+ for (const target of activeResizeTargets) {
106
+ target.invalidate();
107
+ target.requestRepaint();
108
+ }
100
109
  }, 120);
101
110
  };
102
111
 
@@ -134,7 +143,7 @@ export function uninstallResizeListener(): void {
134
143
  clearTimeout(resizeTimer);
135
144
  resizeTimer = undefined;
136
145
  }
137
- activeResizeTarget = undefined;
146
+ activeResizeTargets.clear();
138
147
  process.off("SIGWINCH", onResize);
139
148
  // Windows has no SIGWINCH; Node emits "resize" on stdout instead.
140
149
  // Remove via whichever API is present. The listener was only registered
@@ -159,6 +168,8 @@ interface WidgetComponent extends CrewComponent {}
159
168
  class CrewWidgetComponent implements WidgetComponent {
160
169
  private readonly model: CrewWidgetModel;
161
170
  private theme: CrewTheme;
171
+ /** Which surface this instance paints: the agent dock, or the task list. */
172
+ private readonly variant: "dock" | "tasks";
162
173
  private cacheSignature = "";
163
174
  /** C4 — invalidate-on-write cache for the buildSignature() result. */
164
175
  private cachedBuildSignature = "";
@@ -166,13 +177,23 @@ class CrewWidgetComponent implements WidgetComponent {
166
177
  private cachedWidth = 0;
167
178
  private cachedLines: string[] = [];
168
179
  private cachedBaseLines: string[] = [];
180
+ /**
181
+ * PERF (2026-08-24): truncate at build time, re-truncate only on width
182
+ * change — cache hits returned a fresh array + ANSI-aware width measurement
183
+ * per line on every frame. `cachedLines` stays raw (unclipped) so a width
184
+ * change can re-clip without a full rebuild; -1 marks "not truncated yet".
185
+ */
186
+ private truncatedLines: string[] = [];
187
+ private lastTruncateWidth = -1;
169
188
  private cachedTheme: CrewTheme;
170
189
  private readonly tui: unknown;
171
190
  private readonly unsubscribeTheme: () => void;
191
+ private readonly unsubscribePanel: () => void;
172
192
  private readonly schedulerHandle: OverlaySchedulerHandle;
173
193
 
174
- constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown) {
194
+ constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown, variant: "dock" | "tasks" = "dock") {
175
195
  this.model = model;
196
+ this.variant = variant;
176
197
  this.theme = asCrewTheme(themeLike);
177
198
  this.cachedTheme = this.theme;
178
199
  this.tui = tui;
@@ -180,9 +201,16 @@ class CrewWidgetComponent implements WidgetComponent {
180
201
  // terminal-resize listener is installed. On a resize the cached width
181
202
  // goes stale; busting the cache + requesting a repaint refreshes the
182
203
  // widget at the new width without waiting for the next event tick (T-2).
183
- activeResizeTarget = this;
204
+ activeResizeTargets.add(this);
184
205
  installResizeListener();
185
206
  this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
207
+ // Cursor movement is a keypress, not a run event, so it never reaches the
208
+ // shared scheduler. Repaint directly instead of waiting for the next host
209
+ // tick — a lagging cursor reads as a dropped keystroke.
210
+ this.unsubscribePanel = subscribePanelChange(() => {
211
+ this.invalidate();
212
+ this.requestRepaint();
213
+ });
186
214
  // 1.10 (UI-P1-1): route run:state / worker:lifecycle / ui:invalidate
187
215
  // through a RenderScheduler (debounce + fallback) instead of three
188
216
  // direct runEventBus.onChannel subscriptions. With 3 overlays
@@ -254,10 +282,23 @@ class CrewWidgetComponent implements WidgetComponent {
254
282
  this.cacheSignature = "";
255
283
  this.cachedBaseLines = [];
256
284
  this.cachedLines = [];
285
+ this.truncatedLines = [];
286
+ this.lastTruncateWidth = -1;
257
287
  this.cachedBuildSignature = "";
258
288
  this.cachedBuildSignatureAt = 0;
259
289
  }
260
290
 
291
+ /**
292
+ * PERF (2026-08-24): clip `cachedLines` to `width` only when the width
293
+ * changed since the last clip — the raw lines are stored unclipped so a
294
+ * resize re-clips them without rebuilding the whole cache.
295
+ */
296
+ private ensureTruncated(width: number): void {
297
+ if (width === this.lastTruncateWidth) return;
298
+ this.truncatedLines = this.cachedLines.map((line) => truncate(line, width));
299
+ this.lastTruncateWidth = width;
300
+ }
301
+
261
302
  /** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
262
303
  requestRepaint(): void {
263
304
  requestRenderTarget(this.tui);
@@ -265,8 +306,9 @@ class CrewWidgetComponent implements WidgetComponent {
265
306
 
266
307
  dispose(): void {
267
308
  this.unsubscribeTheme();
309
+ this.unsubscribePanel();
268
310
  this.schedulerHandle.dispose();
269
- if (activeResizeTarget === this) activeResizeTarget = undefined;
311
+ activeResizeTargets.delete(this);
270
312
  }
271
313
 
272
314
  render(width: number): string[] {
@@ -295,7 +337,37 @@ class CrewWidgetComponent implements WidgetComponent {
295
337
  const signature = `${sigBase}:${this.model.notificationCount ?? 0}`;
296
338
  const runningGlyph = spinnerFrame("widget-header");
297
339
 
298
- if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
340
+ // Task-list variant: the run's plan above the editor (task-list.ts).
341
+ // No panel state, no spinner glyph — the list changes only on task
342
+ // transitions, which the run signature already covers.
343
+ if (this.variant === "tasks") {
344
+ if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
345
+ this.cachedBaseLines = buildTaskListLines(runs, width);
346
+ this.cachedLines = this.colorize(this.cachedBaseLines, width);
347
+ this.cachedWidth = width;
348
+ this.cachedTheme = this.theme;
349
+ this.cacheSignature = signature;
350
+ this.lastTruncateWidth = -1;
351
+ }
352
+ if (runs.length === 0) {
353
+ this.invalidate();
354
+ return [];
355
+ }
356
+ this.ensureTruncated(width);
357
+ return this.truncatedLines;
358
+ }
359
+
360
+ // Panel cursor/pane state is part of the rendered output, so it belongs in
361
+ // the cache key — otherwise moving the cursor would not repaint.
362
+ const panel = panelDisplayState();
363
+ const signatureWithPanel = `${signature}|panel:${panel.selectedTaskId ?? ""}/${panel.viewedTaskId ?? ""}/${panel.focused ? 1 : 0}`;
364
+
365
+ // The spinner-frame swap only belongs on the LEGACY header, whose line 0
366
+ // already starts with a glyph position (`<frame> Crew agents …`). The
367
+ // compact dock's line 0 is the HINT text ("agents (N) — ↓ to select"):
368
+ // swapping would visibly eat its first character on every frame.
369
+ const compactDock = this.model.rowStyle === "compact";
370
+ if (this.cacheSignature !== signatureWithPanel || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
299
371
  this.cachedBaseLines = buildWidgetLines(
300
372
  this.model.cwd,
301
373
  0,
@@ -303,14 +375,16 @@ class CrewWidgetComponent implements WidgetComponent {
303
375
  runs,
304
376
  this.model.notificationCount ?? 0,
305
377
  width,
378
+ { rowStyle: this.model.rowStyle, ...panel },
306
379
  ).map((line, index) => {
307
- if (index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
380
+ if (!compactDock && index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
308
381
  return line;
309
382
  });
310
383
  this.cachedLines = this.colorize(this.cachedBaseLines, width);
311
384
  this.cachedWidth = width;
312
385
  this.cachedTheme = this.theme;
313
- this.cacheSignature = signature;
386
+ this.cacheSignature = signatureWithPanel;
387
+ this.lastTruncateWidth = -1;
314
388
  }
315
389
 
316
390
  if (runs.length === 0) {
@@ -323,9 +397,42 @@ class CrewWidgetComponent implements WidgetComponent {
323
397
  return [];
324
398
  }
325
399
 
326
- const updatedHeader = `${runningGlyph}${this.cachedBaseLines[0]?.slice(1) ?? ""}`;
327
- this.cachedLines[0] = truncate(colorWidgetLine(updatedHeader, 0, this.theme), width);
328
- return this.cachedLines.map((line) => truncate(line, width));
400
+ this.ensureTruncated(width);
401
+ if (!compactDock) {
402
+ const updatedHeader = `${runningGlyph}${this.cachedBaseLines[0]?.slice(1) ?? ""}`;
403
+ this.cachedLines[0] = truncate(colorWidgetLine(updatedHeader, 0, this.theme), width);
404
+ this.truncatedLines[0] = truncate(this.cachedLines[0], width);
405
+ }
406
+ return this.truncatedLines;
407
+ }
408
+ }
409
+
410
+ // ── Footer dock host (widgetPlacement: "bottom") ──────────────────────
411
+
412
+ /**
413
+ * Dock host for `widgetPlacement: "bottom"`. Keeps a single CrewWidgetComponent
414
+ * (theme-less → raw lines, no ANSI) per session and feeds its render output to
415
+ * the crew-vibes footer through the dock-footer registry. The footer colors the
416
+ * lines with ITS OWN theme so the dock matches the footer context. All caching,
417
+ * event wiring (panel changes, run events, resize) lives in the wrapped
418
+ * component; the footer is re-rendered by pi on every host repaint.
419
+ */
420
+ class FooterDockHost {
421
+ private component: CrewWidgetComponent | undefined;
422
+ private readonly model: CrewWidgetModel;
423
+
424
+ constructor(model: CrewWidgetModel) {
425
+ this.model = model;
426
+ }
427
+
428
+ render(width: number): string[] {
429
+ if (!this.component) this.component = new CrewWidgetComponent(this.model, undefined, undefined);
430
+ return this.component.render(width);
431
+ }
432
+
433
+ dispose(): void {
434
+ this.component?.dispose();
435
+ this.component = undefined;
329
436
  }
330
437
  }
331
438
 
@@ -355,20 +462,37 @@ export function updateCrewWidget(
355
462
  }
356
463
 
357
464
  const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId);
358
- const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth());
465
+ const rowStyle = config?.widgetRowStyle ?? DEFAULT_UI.widgetRowStyle;
466
+ // The inline panel navigates the same run list the widget paints, and this is
467
+ // the only place already holding the manifest/snapshot caches — so the row
468
+ // projection is registered here instead of re-reading state on every keypress.
469
+ setPanelRowsProvider(() => panelRowsFromRuns(activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId)));
359
470
  const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
471
+ // `bottom` is not a pi widget slot: the dock then renders inside the
472
+ // crew-vibes footer (dock-footer registry). pi's slot calls always use a
473
+ // real slot so legacy-clear/installs stay on maps pi understands.
474
+ const bottomMode = placement === "bottom";
475
+ const dockInFooter = bottomMode && isFooterDockSinkActive();
476
+ const piPlacement: "aboveEditor" | "belowEditor" = bottomMode ? "belowEditor" : placement;
360
477
 
361
- ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
478
+ // PERF (2026-08-24): the persistent CrewWidgetComponent renders itself from
479
+ // state.frame; the built line set was only ever used for its .length.
480
+ ctx.ui.setStatus(STATUS_KEY, runs.length > 0 ? statusSummary(runs) : undefined);
362
481
 
363
482
  const shouldClearLegacy = state.legacyCleared !== true || state.lastPlacement !== placement;
364
483
  if (shouldClearLegacy) {
365
- setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement });
484
+ setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement: piPlacement });
366
485
  state.legacyCleared = true;
367
486
  }
368
487
 
369
- if (!lines.length) {
488
+ if (runs.length === 0) {
370
489
  if (state.lastVisibility !== "hidden" || state.lastPlacement !== placement) {
371
- setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement });
490
+ setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
491
+ setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
492
+ state.lastTasksVisibility = "hidden";
493
+ state.footerDock?.dispose();
494
+ state.footerDock = undefined;
495
+ setFooterDockProvider(undefined);
372
496
  state.lastVisibility = "hidden";
373
497
  state.lastPlacement = placement;
374
498
  state.lastKey = WIDGET_KEY;
@@ -398,6 +522,7 @@ export function updateCrewWidget(
398
522
  snapshotCache,
399
523
  preloadManifests: preloadedManifests,
400
524
  workspaceId,
525
+ rowStyle,
401
526
  };
402
527
  else {
403
528
  state.model.cwd = ctx.cwd;
@@ -408,12 +533,33 @@ export function updateCrewWidget(
408
533
  state.model.snapshotCache = snapshotCache;
409
534
  state.model.preloadManifests = preloadedManifests;
410
535
  state.model.workspaceId = workspaceId;
536
+ state.model.rowStyle = rowStyle;
537
+ }
538
+
539
+ if (dockInFooter) {
540
+ // Keep pi's widget slot free: the crew-vibes footer paints the dock at
541
+ // the very bottom, below the quota/meter lines. A widget-slot install
542
+ // from a PREVIOUS placement (or a sink that was just enabled) must be
543
+ // removed first.
544
+ if (needsWidgetInstall && state.lastKey === WIDGET_KEY) {
545
+ setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
546
+ }
547
+ if (!state.footerDock) state.footerDock = new FooterDockHost(state.model);
548
+ setFooterDockProvider((width) => state.footerDock!.render(width));
549
+ } else {
550
+ // Widget-slot path (aboveEditor/belowEditor, or no footer sink for
551
+ // "bottom"): ensure any stale footer dock is detached first.
552
+ if (state.footerDock) {
553
+ state.footerDock.dispose();
554
+ state.footerDock = undefined;
555
+ }
556
+ setFooterDockProvider(undefined);
411
557
  }
412
558
 
413
- if (needsWidgetInstall) {
559
+ if (needsWidgetInstall && !dockInFooter) {
414
560
  const model = state.model;
415
561
  setExtensionWidget(ctx, WIDGET_KEY, ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui)) as never, {
416
- placement,
562
+ placement: piPlacement,
417
563
  persist: true,
418
564
  });
419
565
  state.lastVisibility = "visible";
@@ -421,6 +567,32 @@ export function updateCrewWidget(
421
567
  state.lastKey = WIDGET_KEY;
422
568
  state.lastMaxLines = maxLines;
423
569
  state.lastCwd = ctx.cwd;
570
+ } else if (dockInFooter) {
571
+ state.lastVisibility = "visible";
572
+ state.lastPlacement = placement;
573
+ state.lastKey = WIDGET_KEY;
574
+ state.lastMaxLines = maxLines;
575
+ state.lastCwd = ctx.cwd;
576
+ }
577
+
578
+ // Task list (aboveEditor): the run's plan progress — Claude Code / droid
579
+ // style, independent of the dock's placement (so it also shows when the
580
+ // dock renders in the crew-vibes footer). Installed once while any
581
+ // display-active run exists; the component paints nothing until a run
582
+ // carries a tasks slice.
583
+ const tasksVisible = runs.length > 0 && Boolean(state.model);
584
+ if (tasksVisible && state.lastTasksVisibility !== "visible") {
585
+ const model = state.model;
586
+ setExtensionWidget(
587
+ ctx,
588
+ TASKS_WIDGET_KEY,
589
+ ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(model, theme, _tui, "tasks")) as never,
590
+ { placement: "aboveEditor" },
591
+ );
592
+ state.lastTasksVisibility = "visible";
593
+ } else if (!tasksVisible && state.lastTasksVisibility === "visible") {
594
+ setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
595
+ state.lastTasksVisibility = "hidden";
424
596
  }
425
597
 
426
598
  requestRender(ctx);
@@ -436,9 +608,15 @@ export function stopCrewWidget(
436
608
  uninstallResizeListener();
437
609
  if (ctx?.hasUI) {
438
610
  const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
611
+ const piPlacement: "aboveEditor" | "belowEditor" = placement === "bottom" ? "belowEditor" : placement;
439
612
  ctx.ui.setStatus(STATUS_KEY, undefined);
440
- setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement });
441
- setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement });
613
+ setExtensionWidget(ctx, LEGACY_WIDGET_KEY, undefined, { placement: piPlacement });
614
+ setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
615
+ setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
616
+ state.lastTasksVisibility = "hidden";
617
+ state.footerDock?.dispose();
618
+ state.footerDock = undefined;
619
+ setFooterDockProvider(undefined);
442
620
  state.lastVisibility = "hidden";
443
621
  state.lastPlacement = placement;
444
622
  state.lastKey = WIDGET_KEY;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * task-list.ts — the run's plan, painted above the editor (pi-tasks /
3
+ * Claude Code style): a `● N tasks (…)` header, then one row per task in
4
+ * PLAN ORDER with task numbers (#1, #2, …) instead of technical ids.
5
+ * Completed rows are dimmed and struck through; the task actively executing
6
+ * carries a spinner frame with elapsed time and token counts; queued rows
7
+ * name the dependencies they wait on.
8
+ *
9
+ * Example:
10
+ * ```
11
+ * ● 4 tasks (1 done, 1 in progress, 2 open)
12
+ * ✔ #1 Design the flux capacitor
13
+ * ⠙ #2 Wire the overlay scroll (2m 49s · ↑ 4.1k ↓ 1.2k)
14
+ * ◻ #3 Run the suite › blocked by #2
15
+ * ◻ #4 Report the result
16
+ * ```
17
+ *
18
+ * Pure plan surface: which worker/agent/model runs a task is the dock's
19
+ * story below the editor, never the plan's. Pure display — the interactive
20
+ * surface stays that dock. Rows come from the run snapshot's `tasks` slice
21
+ * (plan order), so the list repaints on task transitions only; the running
22
+ * row's spinner/elapsed/token suffix rides the render signature's spinner
23
+ * bucket while any worker is alive.
24
+ */
25
+
26
+ import type { TeamTaskState } from "../../state/types.ts";
27
+ import { truncate } from "../../utils/visual.ts";
28
+ import { spinnerFrame } from "../spinner.ts";
29
+ import { shortRunLabel } from "./widget-model.ts";
30
+ import type { WidgetRun } from "./widget-types.ts";
31
+
32
+ /** Task rows painted before the "… and N more" overflow line (pi-tasks
33
+ * defaults its `maxVisible` to 10). */
34
+ const MAX_TASK_ROWS = 10;
35
+
36
+ const ACTIVE_TASK_STATUSES = new Set(["running", "queued", "waiting", "needs_attention"]);
37
+
38
+ // pi-tasks-style strikethrough + dim for completed rows: raw SGR because the
39
+ // theme adapter has no strike; the truncate/colorize paths are ANSI-aware.
40
+ const DIM_ON = "\x1b[2m";
41
+ const DIM_OFF = "\x1b[22m";
42
+ const STRIKE_ON = "\x1b[9m";
43
+ const STRIKE_OFF = "\x1b[29m";
44
+
45
+ function isDoneStatus(status: string): boolean {
46
+ return status === "completed" || status === "failed" || status === "cancelled" || status === "skipped";
47
+ }
48
+
49
+ function taskTitle(task: TeamTaskState): string {
50
+ return (task.displayName ?? task.title ?? "").replace(/\s+/g, " ").trim();
51
+ }
52
+
53
+ /** pi-tasks status glyph per row: ✔ done (struck through), spinner for the
54
+ * actively executing task, ◼ parked/needs input, ◻ queued, ✗ dead. */
55
+ function taskStatusIcon(task: TeamTaskState): string {
56
+ switch (task.status) {
57
+ case "running":
58
+ return spinnerFrame("crew-task-list");
59
+ case "queued":
60
+ return "◻";
61
+ case "waiting":
62
+ case "needs_attention":
63
+ return "◼";
64
+ case "completed":
65
+ return "✔";
66
+ case "failed":
67
+ case "cancelled":
68
+ case "skipped":
69
+ return "✗";
70
+ default:
71
+ return "?";
72
+ }
73
+ }
74
+
75
+ /** `45s` / `2m 49s` / `1h 03m`. */
76
+ function formatDuration(ms: number): string {
77
+ if (!Number.isFinite(ms) || ms < 0) return "";
78
+ const totalSeconds = Math.floor(ms / 1000);
79
+ if (totalSeconds < 60) return `${totalSeconds}s`;
80
+ const minutes = Math.floor(totalSeconds / 60);
81
+ if (minutes < 60) return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
82
+ const hours = Math.floor(minutes / 60);
83
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
84
+ }
85
+
86
+ /** `4.1k` / `1.2M` / `918` — no unit suffix; the arrow pair carries it. */
87
+ function compactTokens(count: number): string {
88
+ if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return "";
89
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
90
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
91
+ return `${count}`;
92
+ }
93
+
94
+ /** `(2m 49s · ↑ 4.1k ↓ 1.2k)` — elapsed plus tokens, pi-tasks style. Live
95
+ * totals come from agentProgress until the final usage lands. */
96
+ function runningSuffix(task: TeamTaskState): string {
97
+ const parts: string[] = [];
98
+ const start = Date.parse(task.startedAt ?? "");
99
+ if (Number.isFinite(start) && start > 0) {
100
+ const end = task.finishedAt ? Date.parse(task.finishedAt) : Date.now();
101
+ const elapsed = formatDuration(end - start);
102
+ if (elapsed) parts.push(elapsed);
103
+ }
104
+ const input = task.usage?.input ?? 0;
105
+ const output = task.usage?.output ?? 0;
106
+ if (input > 0 || output > 0) {
107
+ parts.push(`↑ ${compactTokens(input) || "0"} ↓ ${compactTokens(output) || "0"}`);
108
+ } else {
109
+ const live = compactTokens(task.agentProgress?.tokens ?? 0);
110
+ if (live) parts.push(live);
111
+ }
112
+ return parts.length ? ` (${parts.join(" · ")})` : "";
113
+ }
114
+
115
+ /** `› blocked by #2, #3` for queued tasks — open (non-completed)
116
+ * dependencies only, named by task number (pi-tasks display rule). */
117
+ function blockedSuffix(task: TeamTaskState, tasks: readonly TeamTaskState[], numberById: Map<string, number>): string {
118
+ if (task.status !== "queued") return "";
119
+ const openDeps = (task.dependsOn ?? [])
120
+ .map((depId) => tasks.find((entry) => entry.id === depId))
121
+ .filter((dep): dep is TeamTaskState => dep !== undefined && dep.status !== "completed");
122
+ if (openDeps.length === 0) return "";
123
+ const labels = openDeps.map((dep) => `#${numberById.get(dep.id) ?? "?"}`);
124
+ return ` › blocked by ${labels.join(", ")}`;
125
+ }
126
+
127
+ /** One line per task: `#n title` behind its status glyph — pi-tasks style.
128
+ * Completed titles are dimmed and struck through. */
129
+ function taskRow(
130
+ task: TeamTaskState,
131
+ taskNumber: number,
132
+ tasks: readonly TeamTaskState[],
133
+ numberById: Map<string, number>,
134
+ width: number,
135
+ ): string {
136
+ const title = taskTitle(task);
137
+ const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, tasks, numberById);
138
+ if (task.status === "completed") {
139
+ const struck = `${STRIKE_ON}${DIM_ON}#${taskNumber} ${title}${DIM_OFF}${STRIKE_OFF}`;
140
+ return truncate(` ✔ ${struck}`.trimEnd(), width);
141
+ }
142
+ return truncate(` ${taskStatusIcon(task)} #${taskNumber} ${title}${suffix}`.trimEnd(), width);
143
+ }
144
+
145
+ /** The run whose plan is shown: the first with unfinished work, else the first. */
146
+ function primaryRun(runs: readonly WidgetRun[]): WidgetRun | undefined {
147
+ return runs.find((entry) => (entry.snapshot?.tasks ?? []).some((task) => ACTIVE_TASK_STATUSES.has(task.status))) ?? runs[0];
148
+ }
149
+
150
+ /** Which rows fit the cap: plan order, but never drop an unfinished task —
151
+ * if active work falls outside the first MAX_TASK_ROWS rows it stays and
152
+ * finished rows give way instead. */
153
+ function visibleTasks(tasks: readonly TeamTaskState[]): TeamTaskState[] {
154
+ if (tasks.length <= MAX_TASK_ROWS) return [...tasks];
155
+ const active = tasks.filter((task) => ACTIVE_TASK_STATUSES.has(task.status));
156
+ if (active.length >= MAX_TASK_ROWS) return active.slice(0, MAX_TASK_ROWS);
157
+ const keep = new Set(active.map((task) => task.id));
158
+ for (const task of tasks) {
159
+ if (keep.size >= MAX_TASK_ROWS) break;
160
+ keep.add(task.id);
161
+ }
162
+ return tasks.filter((task) => keep.has(task.id));
163
+ }
164
+
165
+ /**
166
+ * Build the task-list lines for the current runs. Empty when no run carries a
167
+ * tasks slice (nothing to say — the dock alone stays).
168
+ */
169
+ export function buildTaskListLines(runs: readonly WidgetRun[], width: number): string[] {
170
+ const entry = primaryRun(runs);
171
+ if (!entry) return [];
172
+ const tasks = entry.snapshot?.tasks ?? [];
173
+ if (tasks.length === 0) return [];
174
+
175
+ const numberById = new Map(tasks.map((task, index) => [task.id, index + 1]));
176
+ const done = tasks.filter((task) => task.status === "completed").length;
177
+ const dead = tasks.filter((task) => isDoneStatus(task.status) && task.status !== "completed").length;
178
+ const inProgress = tasks.filter(
179
+ (task) => task.status === "running" || task.status === "waiting" || task.status === "needs_attention",
180
+ ).length;
181
+ const open = tasks.filter((task) => task.status === "queued").length;
182
+
183
+ const counts = [`${done} done`];
184
+ if (dead) counts.push(`${dead} failed`);
185
+ counts.push(`${inProgress} in progress`, `${open} open`);
186
+ const headerParts = [`● ${tasks.length} tasks (${counts.join(", ")})`];
187
+ if (runs.length > 1) headerParts.push(shortRunLabel(entry.run));
188
+ const lines = [truncate(headerParts.join(" · "), width)];
189
+
190
+ const visible = visibleTasks(tasks);
191
+ for (const task of visible) {
192
+ lines.push(taskRow(task, numberById.get(task.id) ?? 0, tasks, numberById, width));
193
+ }
194
+ if (tasks.length > visible.length) {
195
+ lines.push(truncate(` … and ${tasks.length - visible.length} more`, width));
196
+ }
197
+ return lines;
198
+ }