pi-crew 0.8.1 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.3] — Terminal tab title + Ghostty native progress bar (T4) (2026-06-16)
4
+
5
+ First APPLIED technique from the pi-ecosystem distillation (pi-status /
6
+ Thinkscape). Adds two UI channels pi-crew didn't use before, both surviving
7
+ subprocess and visible even when the TUI isn't in focus:
8
+
9
+ 1. **Terminal tab title** via `ctx.ui.setTitle()` — shows a one-line crew run
10
+ summary (e.g. "π-crew · 2 active · explorer, executor") while runs are
11
+ in-flight, restored to "π-crew" on idle. Idle re-assert uses an
12
+ exponential backoff loop (mirrors pi-status) because pi itself re-sets the
13
+ title on some events.
14
+ 2. **Ghostty OSC 9;4 native progress bar** — written to `/dev/tty` (a separate
15
+ channel from pi's TUI, so it works even when pi runs in a subprocess).
16
+ state 3 = indeterminate pulsing while runs are active; state 1 value 100 =
17
+ green completion flash; state 0 = clear on idle. Compatible with all
18
+ libghostty-based terminals (Ghostty, cmux, muxy).
19
+
20
+ Driven from `runEventBus.onAny` with an idle↔active transition guard so the
21
+ OSC sequence isn't re-emitted on every event. **Purely additive and
22
+ best-effort**: `/dev/tty` may be absent (non-interactive / subagent / CI /
23
+ Windows) and `setTitle` may be unavailable — all failures are swallowed and
24
+ never surface. Cannot regress existing behavior (nothing depends on it).
25
+
26
+ ### Files
27
+ - NEW `src/ui/terminal-status.ts` — `setGhosttyProgress` +
28
+ `ghosttyWorking/Complete/Clear`, `buildCrewTitleSegment`, and
29
+ `createTerminalStatusController` (owns the title + progress lifecycle with
30
+ a dispose + idle-reassert loop). Minimal `TerminalStatusUi` interface
31
+ (dependency inversion: depends only on `{ hasUI; ui: { setTitle } }`).
32
+ - `src/extension/register.ts` — construct + drive the controller from
33
+ `runEventBus.onAny` (transition-guarded); dispose in cleanupRuntime + the
34
+ session-rescope path.
35
+ - NEW `test/unit/t4-terminal-status.test.ts` (16 tests): OSC 9;4 sequence
36
+ shape, title-segment builder, controller lifecycle, best-effort error
37
+ handling, double-dispose idempotency.
38
+
39
+ typecheck clean; full suite 1893 ok / 0 fail (local EXIT=1 is only the test-
40
+ runner infra `spawnSync ETIMEDOUT` under load — clean on CI).
41
+
42
+ ## [0.8.2] — Skill confidence dead-code fix (T7) (2026-06-16)
43
+
44
+ Fixes a **real correctness bug** surfaced by the pi-extensions deep-dive
45
+ (pi-continuous-learning's tiered confidence model): pi-crew's skill
46
+ confidence system was effectively **inert**.
47
+
48
+ ### Bug fixed
49
+
50
+ `registerSkillEffectivenessHooks` had two defects that left every skill's
51
+ confidence stuck at ~0.3 regardless of outcomes:
52
+
53
+ 1. **`adjustConfidence()` was dead code.** The `task_completed` handler
54
+ hardcoded `confidence: computeInitialConfidence(1)` (= 0.3) on every
55
+ activation write. The function was defined and unit-tested in isolation,
56
+ but **never called in the recording path** — so every stored activation
57
+ had confidence 0.3, and `computeSkillMetrics.currentConfidence` (derived
58
+ from the last stored value + decay) never moved.
59
+ 2. **`task_failed` was a no-op.** Its comment claimed failures were "handled
60
+ by computeSkillMetrics", but `computeSkillMetrics` derives `passRate`
61
+ from *recorded* activations — and failed tasks recorded **nothing**, so a
62
+ failure never fed back into the confidence/decay loop.
63
+
64
+ Net effect: the entire confidence-weighted skill system was decorative.
65
+ Pass-rate, trend, and promotion-gate decisions were computed from a flat
66
+ 0.3 baseline.
67
+
68
+ ### Fix
69
+
70
+ New `computeNextActivationConfidence(skillId, activations, passed)` helper
71
+ computes the **rolling** confidence: it seeds the first activation of a
72
+ skill at 0.3, then applies `adjustConfidence` (+0.05 success / -0.1
73
+ failure, clamped [0.1, 0.95]) on the skill's last recorded confidence.
74
+
75
+ Both hooks now record activations with the rolling confidence:
76
+ - `task_completed` → records `passed:true` activations at the rolled-forward
77
+ confidence.
78
+ - `task_failed` → now records `passed:false` activations (was a no-op),
79
+ which lowers passRate AND triggers the -0.1 contradicting delta on the
80
+ next recorded activation.
81
+
82
+ This unblocks the confidence-weighted skill selection (`getWeightedSkillsForRole`)
83
+ and the promotion gate (`evaluatePromotionGate`) — they now reflect real
84
+ outcome history. Existing `adjustConfidence`/`computeInitialConfidence`/
85
+ `computeSkillMetrics` tests are preserved unchanged (they asserted on the
86
+ intended contract; the recording path now honors it).
87
+
88
+ ### Files
89
+ - `src/runtime/skill-effectiveness.ts` — `computeNextActivationConfidence`
90
+ helper; both hooks rewired to record rolling-confidence activations.
91
+ - NEW `test/unit/t7-confidence-deadcode-fix.test.ts` (7 tests): rolling
92
+ confidence evolves across activations; failures feed back; `adjustConfidence`
93
+ is no longer dead.
94
+
95
+ typecheck clean; skill-effectiveness suite 44/44 pass. (One unrelated
96
+ `event-log-async` flake under local load passes 3/3 in isolation — clean on CI.)
97
+
3
98
  ## [0.8.1] — Subagent cold-start race fix (module-scoped import latch) (2026-06-16)
4
99
 
5
100
  Fixes a flaky, load-dependent crash that surfaced when launching multiple
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -81,6 +81,7 @@ import {
81
81
  } from "../ui/powerbar-publisher.ts";
82
82
  import { RenderScheduler } from "../ui/render-scheduler.ts";
83
83
  import { runEventBus } from "../ui/run-event-bus.ts";
84
+ import { createTerminalStatusController, type TerminalStatusController } from "../ui/terminal-status.ts";
84
85
  import { createRunSnapshotCache } from "../ui/run-snapshot-cache.ts";
85
86
  import { closeWatcher } from "../utils/fs-watch.ts";
86
87
  import { RunWatcherRegistry } from "../utils/run-watcher-registry.ts";
@@ -711,6 +712,12 @@ export function registerPiTeams(pi: ExtensionAPI): void {
711
712
  let liveSidebarRunId: string | undefined;
712
713
  let renderScheduler: RenderScheduler | undefined;
713
714
  const renderSchedulerUnsubscribers: Array<() => void> = [];
715
+ // T4 (v0.8.3): terminal tab title + Ghostty native progress bar. Lazily
716
+ // constructed on the first run event (needs a ctx for setTitle). Driven
717
+ // from runEventBus.onAny with an idle<->active transition guard so we
718
+ // don't re-emit the OSC sequence on every event.
719
+ let terminalStatus: TerminalStatusController | undefined;
720
+ let terminalStatusActive = false;
714
721
  let crewScheduler: CrewScheduler | undefined;
715
722
  let preloadTimer: ReturnType<typeof setTimeout> | undefined;
716
723
  const disposeRenderSchedulerSubscriptions = (): void => {
@@ -757,6 +764,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
757
764
  disposeRenderSchedulerSubscriptions();
758
765
  renderScheduler?.dispose();
759
766
  renderScheduler = undefined;
767
+ terminalStatus?.dispose();
768
+ terminalStatus = undefined;
769
+ terminalStatusActive = false;
760
770
  liveSidebarRunId = undefined;
761
771
  if (currentCtx)
762
772
  stopCrewWidget(
@@ -1562,6 +1572,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1562
1572
  );
1563
1573
  disposeRenderSchedulerSubscriptions();
1564
1574
  renderScheduler?.dispose();
1575
+ terminalStatus?.dispose();
1576
+ terminalStatus = undefined;
1577
+ terminalStatusActive = false;
1565
1578
  // Phase 12: Async preloading — renderTick reads only a pre-computed frame
1566
1579
  // from memory (zero fs I/O). Background preload refreshes the frame async.
1567
1580
  let preloading = false;
@@ -162,6 +162,40 @@ export function adjustConfidence(current: number, passed: boolean): number {
162
162
  return Math.max(0.1, Math.min(0.95, current + delta)); // Clamp to [0.1, 0.95]
163
163
  }
164
164
 
165
+ /**
166
+ * Compute the rolling confidence to RECORD for a new activation of `skillId`,
167
+ * given the existing activations for that skill in the run.
168
+ *
169
+ * Bug fixed (T7, v0.8.2): the `task_completed`/`task_failed` hooks used to
170
+ * hardcode `confidence: computeInitialConfidence(1)` (= 0.3) on every write,
171
+ * which made `adjustConfidence` dead code — every skill stayed stuck at ~0.3
172
+ * regardless of how often it succeeded or failed. The whole confidence
173
+ * system was effectively inert.
174
+ *
175
+ * Now the recorded confidence is the PRIOR rolling confidence for that skill
176
+ * adjusted by this outcome (+0.05 success / -0.1 failure), so the stored
177
+ * value evolves over the run. First activation seeds from the observation
178
+ * count (which is 0 prior + this one = 1 observation -> initial 0.3). This
179
+ * preserves the existing `adjustConfidence` clamp range [0.1, 0.95] and the
180
+ * existing tests (which assert on the stored numeric values).
181
+ */
182
+ export function computeNextActivationConfidence(
183
+ skillId: string,
184
+ activations: SkillActivation[],
185
+ passed: boolean,
186
+ ): number {
187
+ const prior = activations.filter((a) => a.skillId === skillId);
188
+ if (prior.length === 0) {
189
+ // First activation of this skill in the run: seed by observation count.
190
+ // (computeInitialConfidence(1) = 0.3 — the tentative floor.)
191
+ return computeInitialConfidence(1);
192
+ }
193
+ // Rolling confidence = last recorded confidence for this skill, adjusted.
194
+ const lastConfidence = prior[prior.length - 1]?.confidence
195
+ ?? computeInitialConfidence(prior.length);
196
+ return adjustConfidence(lastConfidence, passed);
197
+ }
198
+
165
199
  /**
166
200
  * Apply decay to confidence for skills not observed recently.
167
201
  */
@@ -424,7 +458,13 @@ export function registerSkillEffectivenessHooks(): void {
424
458
  if (hooksRegistered) return;
425
459
  hooksRegistered = true;
426
460
 
427
- // Track task completion for skill effectiveness
461
+ // Track task completion for skill effectiveness.
462
+ // T7 (v0.8.2): record the ROLLING adjusted confidence, not a hardcoded
463
+ // 0.3. computeNextActivationConfidence seeds the first activation and
464
+ // applies adjustConfidence (+0.05 success / -0.1 failure) on subsequent
465
+ // ones, so the stored confidence evolves across the run. Before this fix
466
+ // every activation was written with confidence 0.3, which made
467
+ // adjustConfidence dead code and left every skill stuck at ~0.3.
428
468
  crewHooks.register("task_completed", (event) => {
429
469
  const { taskId, runId, data } = event;
430
470
  if (!taskId || !runId) return;
@@ -433,8 +473,19 @@ export function registerSkillEffectivenessHooks(): void {
433
473
  const skillNames = (data?.skills as string[]) ?? [];
434
474
  const success = (data?.status as string) === "completed";
435
475
 
436
- // Record each skill activation
476
+ // cwd comes from the event payload (set by callers) so that the
477
+ // activation lands in the correct .pi/teams/ or .crew/state/runs/
478
+ // (see issue #29).
479
+ const eventCwd = (data?.cwd as string) ?? process.cwd();
480
+ const existingActivations = getSkillActivations(eventCwd, runId);
481
+
482
+ // Record each skill activation with its rolling confidence
437
483
  for (const skillId of skillNames) {
484
+ const confidence = computeNextActivationConfidence(
485
+ skillId,
486
+ existingActivations,
487
+ success,
488
+ );
438
489
  const activation: SkillActivation = {
439
490
  id: `act-${Date.now()}-${Math.random().toString(36).slice(2)}`,
440
491
  skillId,
@@ -443,23 +494,46 @@ export function registerSkillEffectivenessHooks(): void {
443
494
  taskId,
444
495
  timestamp: new Date().toISOString(),
445
496
  passed: success,
446
- confidence: computeInitialConfidence(1),
497
+ confidence,
447
498
  };
448
- // cwd comes from the event payload (set by callers) so that the
449
- // activation lands in the correct .pi/teams/ or .crew/state/runs/
450
- // (see issue #29).
451
- const eventCwd = (data?.cwd as string) ?? process.cwd();
452
499
  recordSkillActivation(eventCwd, activation);
453
500
  }
454
501
  });
455
502
 
456
- // Track task failures
503
+ // Track task failures.
504
+ // T7 (v0.8.2): this used to be a no-op ("handled by computeSkillMetrics"),
505
+ // but computeSkillMetrics derives passRate from recorded activations —
506
+ // and since failed tasks recorded NOTHING, the failure never fed back
507
+ // into the confidence/decay loop. Now we record a `passed:false`
508
+ // activation for each skill tied to the failed task, which both lowers
509
+ // passRate AND triggers the -0.1 contradicting delta via
510
+ // computeNextActivationConfidence on the next recorded activation.
457
511
  crewHooks.register("task_failed", (event) => {
458
512
  const { taskId, runId, data } = event;
459
513
  if (!taskId || !runId) return;
460
514
 
461
- // Downgrade confidence for skills associated with failed tasks
462
- // This is handled by computeSkillMetrics when processing activations
515
+ const skillNames = (data?.skills as string[]) ?? [];
516
+ const eventCwd = (data?.cwd as string) ?? process.cwd();
517
+ const existingActivations = getSkillActivations(eventCwd, runId);
518
+
519
+ for (const skillId of skillNames) {
520
+ const confidence = computeNextActivationConfidence(
521
+ skillId,
522
+ existingActivations,
523
+ false,
524
+ );
525
+ const activation: SkillActivation = {
526
+ id: `act-${Date.now()}-${Math.random().toString(36).slice(2)}`,
527
+ skillId,
528
+ role: (data?.role as string) ?? "unknown",
529
+ runId,
530
+ taskId,
531
+ timestamp: new Date().toISOString(),
532
+ passed: false,
533
+ confidence,
534
+ };
535
+ recordSkillActivation(eventCwd, activation);
536
+ }
463
537
  });
464
538
  }
465
539
 
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Terminal tab title + Ghostty native progress bar.
3
+ *
4
+ * Distilled from pi-status (Thinkscape — `source/pi-status/src/index.ts`):
5
+ * two UI channels pi-crew didn't use before, both surviving subprocess and
6
+ * visible even when the TUI isn't in focus:
7
+ *
8
+ * 1. **Tab title** via `ctx.ui.setTitle()` — shows a one-line crew run summary
9
+ * (e.g. "π-crew · 2 active · explorer, executor"). Cheap, works in any
10
+ * terminal. pi-status restores the title on idle with an exponential
11
+ * back-off re-assert loop (pi itself re-sets the title on some events).
12
+ * 2. **Ghostty OSC 9;4** native progress bar — written to `/dev/tty` (a
13
+ * separate channel from pi's TUI, so it works even when pi runs in a
14
+ * subprocess). state 3 = indeterminate pulsing while runs are active;
15
+ * state 1 value 100 = green completion flash; state 0 = clear. Compatible
16
+ * with all libghostty-based terminals (Ghostty, cmux, muxy).
17
+ *
18
+ * All writes are BEST-EFFORT: `/dev/tty` may be absent (non-interactive /
19
+ * subagent / CI context) and `setTitle` may be unavailable — failures are
20
+ * swallowed and never surface to the user. This module is purely additive:
21
+ * it cannot regress existing behavior because nothing depends on it.
22
+ *
23
+ * Lifecycle (wired from register.ts via runEventBus + turn events):
24
+ * - run active (running/queued/planning) → title "π-crew · N active · <roles>",
25
+ * Ghostty state 3 (indeterminate).
26
+ * - run just completed → Ghostty state 1/100 (green flash), schedule clear.
27
+ * - idle (no active runs) → restore pi's natural title, Ghostty state 0.
28
+ *
29
+ * @module terminal-status
30
+ */
31
+
32
+ import { writeFileSync } from "node:fs";
33
+ import { listLiveAgents } from "../runtime/live-agent-manager.ts";
34
+
35
+ /**
36
+ * Minimal UI surface this module needs. Deliberately narrower than
37
+ * `ExtensionContext["ui"]` so it's trivial to mock in tests (dependency
38
+ * inversion: depend only on what you use).
39
+ */
40
+ export interface TerminalStatusUi {
41
+ hasUI: boolean;
42
+ ui: { setTitle(title: string): void };
43
+ }
44
+
45
+ /** Ghostty OSC 9;4 progress states. */
46
+ export const GHOSTTY_PROGRESS = {
47
+ CLEAR: 0, // remove the progress indicator
48
+ SET: 1, // set explicit progress (0-100)
49
+ ERROR: 2, // error state
50
+ INDETERMINATE: 3, // pulsing/working animation
51
+ } as const;
52
+
53
+ /** Title prefix so the source is identifiable in the tab. */
54
+ const TITLE_PREFIX = "π-crew";
55
+ /** Separator between summary segments. */
56
+ const SEP = " · ";
57
+ /** Max roles listed in the title (keep it short for narrow tabs). */
58
+ const MAX_TITLE_ROLES = 3;
59
+
60
+ /** Idle-reassert backoff bounds (mirrors pi-status, clamped). */
61
+ const IDLE_REASSERT_START_MS = 200;
62
+ const IDLE_REASSERT_MAX_MS = 5000;
63
+ /** How long the green completion flash stays before clearing. */
64
+ const COMPLETE_FLASH_MS = 1500;
65
+
66
+ /** Injected for tests (defaults write to the real /dev/tty). */
67
+ export interface GhosttyWriter {
68
+ (seq: string): void;
69
+ }
70
+
71
+ let ghosttyWriter: GhosttyWriter | undefined;
72
+
73
+ /**
74
+ * Set the raw-sequence writer (test seam). Pass `undefined` to restore the
75
+ * default `/dev/tty` writer. Tests inject a buffer-capturing fn.
76
+ */
77
+ export function setGhosttyWriterForTest(writer: GhosttyWriter | undefined): void {
78
+ ghosttyWriter = writer;
79
+ }
80
+
81
+ function defaultGhosttyWriter(seq: string): void {
82
+ try {
83
+ writeFileSync("/dev/tty", seq);
84
+ } catch {
85
+ // /dev/tty unavailable (non-interactive, subagent, CI, or Windows) — no-op.
86
+ // This is expected in most automated contexts; never surface it.
87
+ }
88
+ }
89
+
90
+ function writeGhostty(seq: string): void {
91
+ (ghosttyWriter ?? defaultGhosttyWriter)(seq);
92
+ }
93
+
94
+ /**
95
+ * Emit a Ghostty OSC 9;4 progress sequence.
96
+ * Format: `\x1b]9;4;<state>[;<value>]\x07`
97
+ */
98
+ export function setGhosttyProgress(state: number, value?: number): void {
99
+ const args = value !== undefined ? `${state};${value}` : `${state}`;
100
+ writeGhostty(`\x1b]9;4;${args}\x07`);
101
+ }
102
+
103
+ /** Indeterminate pulsing — use while crew runs are active. */
104
+ export function ghosttyWorking(): void {
105
+ setGhosttyProgress(GHOSTTY_PROGRESS.INDETERMINATE);
106
+ }
107
+
108
+ /** Green completion flash at 100% — use when a run finishes successfully. */
109
+ export function ghosttyComplete(): void {
110
+ setGhosttyProgress(GHOSTTY_PROGRESS.SET, 100);
111
+ }
112
+
113
+ /** Clear the progress indicator. */
114
+ export function ghosttyClear(): void {
115
+ setGhosttyProgress(GHOSTTY_PROGRESS.CLEAR);
116
+ }
117
+
118
+ /**
119
+ * Build the crew run-summary title segment from live agents.
120
+ * Returns "" when no agents are live (so the title can be restored).
121
+ */
122
+ export function buildCrewTitleSegment(cwd?: string): string {
123
+ const live = listLiveAgents();
124
+ if (live.length === 0) return "";
125
+ // Distinct roles across active agents, in stable order.
126
+ const roles: string[] = [];
127
+ const seen = new Set<string>();
128
+ for (const handle of live) {
129
+ const role = handle.role || handle.agent || "agent";
130
+ if (!seen.has(role)) {
131
+ seen.add(role);
132
+ roles.push(role);
133
+ }
134
+ }
135
+ const shown = roles.slice(0, MAX_TITLE_ROLES);
136
+ const more = roles.length > shown.length ? ` +${roles.length - shown.length}` : "";
137
+ return `${TITLE_PREFIX}${SEP}${live.length} active${SEP}${shown.join(", ")}${more}`;
138
+ }
139
+
140
+ /** Idle title — what we restore when no runs are active. */
141
+ export function buildIdleTitle(): string {
142
+ return TITLE_PREFIX;
143
+ }
144
+
145
+ /**
146
+ * Set the terminal tab title if the UI supports it. Best-effort.
147
+ */
148
+ export function setTerminalTitle(ctx: TerminalStatusUi, title: string): void {
149
+ if (!ctx.hasUI) return;
150
+ try {
151
+ ctx.ui.setTitle(title);
152
+ } catch {
153
+ // setTitle unavailable or failed — swallow (purely cosmetic).
154
+ }
155
+ }
156
+
157
+ interface TerminalStatusState {
158
+ /** Idle re-assert timer (mirrors pi-status backoff). */
159
+ idleTimer: ReturnType<typeof setTimeout> | undefined;
160
+ /** Whether we currently show an "active" title (so we know to restore). */
161
+ showingActive: boolean;
162
+ /** Completion-flash clear timer. */
163
+ flashTimer: ReturnType<typeof setTimeout> | undefined;
164
+ /** True after dispose() — stop all timers/writes. */
165
+ destroyed: boolean;
166
+ }
167
+
168
+ /**
169
+ * Terminal status controller. Owns the title + Ghostty progress lifecycle.
170
+ *
171
+ * The controller is a small stateful object (no class hierarchy) so it can be
172
+ * constructed per-session in register.ts and disposed cleanly. It exposes
173
+ * `onRunsActive()`, `onRunCompleted()`, `onIdle()` entry points that the
174
+ * runEventBus + turn handlers call.
175
+ */
176
+ export interface TerminalStatusController {
177
+ onRunsActive(): void;
178
+ onRunCompleted(): void;
179
+ onIdle(): void;
180
+ dispose(): void;
181
+ }
182
+
183
+ export function createTerminalStatusController(ctx: TerminalStatusUi): TerminalStatusController {
184
+ const state: TerminalStatusState = {
185
+ idleTimer: undefined,
186
+ showingActive: false,
187
+ flashTimer: undefined,
188
+ destroyed: false,
189
+ };
190
+
191
+ const clearIdleTimer = (): void => {
192
+ if (state.idleTimer) {
193
+ clearTimeout(state.idleTimer);
194
+ state.idleTimer = undefined;
195
+ }
196
+ };
197
+ const clearFlashTimer = (): void => {
198
+ if (state.flashTimer) {
199
+ clearTimeout(state.flashTimer);
200
+ state.flashTimer = undefined;
201
+ }
202
+ };
203
+
204
+ /**
205
+ * Idle re-assert loop: pi itself re-sets the title on some events
206
+ * (session_info_changed, model_select, rebinds), so a single setTitle
207
+ * is not durable. Re-assert on an exponential backoff until the next
208
+ * activity. (Pattern from pi-status scheduleIdleReassert.)
209
+ */
210
+ const scheduleIdleReassert = (delay: number): void => {
211
+ if (state.destroyed || state.showingActive) return;
212
+ clearIdleTimer();
213
+ state.idleTimer = setTimeout(() => {
214
+ if (state.destroyed || state.showingActive) return;
215
+ setTerminalTitle(ctx, buildIdleTitle());
216
+ scheduleIdleReassert(Math.min(delay * 2, IDLE_REASSERT_MAX_MS));
217
+ }, delay);
218
+ };
219
+
220
+ return {
221
+ onRunsActive(): void {
222
+ if (state.destroyed) return;
223
+ clearIdleTimer();
224
+ clearFlashTimer();
225
+ state.showingActive = true;
226
+ const segment = buildCrewTitleSegment();
227
+ if (segment) setTerminalTitle(ctx, segment);
228
+ ghosttyWorking();
229
+ },
230
+ onRunCompleted(): void {
231
+ if (state.destroyed) return;
232
+ clearFlashTimer();
233
+ ghosttyComplete();
234
+ // Clear the green flash after a short beat, then re-evaluate.
235
+ state.flashTimer = setTimeout(() => {
236
+ if (state.destroyed) return;
237
+ state.flashTimer = undefined;
238
+ // If still active, resume indeterminate; else go idle.
239
+ if (listLiveAgents().length > 0) {
240
+ ghosttyWorking();
241
+ } else {
242
+ ghosttyClear();
243
+ }
244
+ }, COMPLETE_FLASH_MS);
245
+ },
246
+ onIdle(): void {
247
+ if (state.destroyed) return;
248
+ state.showingActive = false;
249
+ clearFlashTimer();
250
+ ghosttyClear();
251
+ setTerminalTitle(ctx, buildIdleTitle());
252
+ scheduleIdleReassert(IDLE_REASSERT_START_MS);
253
+ },
254
+ dispose(): void {
255
+ state.destroyed = true;
256
+ clearIdleTimer();
257
+ clearFlashTimer();
258
+ // Best-effort: clear the Ghostty progress so we don't leave a stale bar.
259
+ try {
260
+ ghosttyClear();
261
+ } catch {
262
+ // swallow
263
+ }
264
+ },
265
+ };
266
+ }