pi-crew 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/agents/cold-verifier.md +66 -0
- package/package.json +1 -1
- package/src/agents/discover-agents.ts +1 -0
- package/src/extension/register.ts +13 -0
- package/src/ui/settings-overlay.ts +1 -1
- package/src/ui/terminal-status.ts +266 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.4] — cold-verifier agent (T9) (2026-06-16)
|
|
4
|
+
|
|
5
|
+
Second APPLIED technique from the pi-ecosystem distillation (piolium /
|
|
6
|
+
Vigolium — cold-verifier pattern). Adds a new builtin agent whose value is
|
|
7
|
+
**independence**: it re-derives claims from ground truth WITHOUT trusting
|
|
8
|
+
prior reviewer/verifier analysis, breaking the confirmation-bias drift the
|
|
9
|
+
chained `reviewer` → `verifier` path can introduce.
|
|
10
|
+
|
|
11
|
+
### Why
|
|
12
|
+
piolium splits security verification across ~10 narrow agents, including a
|
|
13
|
+
`cold-verifier` whose prompt enforces file-access isolation ("MUST NOT read
|
|
14
|
+
any file other than the single finding draft"). pi-crew's default `verifier`
|
|
15
|
+
instead *correlates* findings against reviewer output ("Trust dependency
|
|
16
|
+
context") — efficient, but it inherits the reviewer's blind spots. There was
|
|
17
|
+
**no** adversarial cross-check agent (confirmed: zero agents reference
|
|
18
|
+
cold/isolation/unbiased semantics).
|
|
19
|
+
|
|
20
|
+
### What
|
|
21
|
+
NEW builtin `cold-verifier` agent (`agents/cold-verifier.md`):
|
|
22
|
+
- Read-only + `bash` (runs tests fresh, reads its OWN output — never a
|
|
23
|
+
cached prior-worker log).
|
|
24
|
+
- Prompt-enforced isolation discipline: don't trust prior findings, treat
|
|
25
|
+
each as an *unverified hypothesis*, actively look for contradicting evidence.
|
|
26
|
+
- Distinct `COLD_VERIFICATION` output block with a `CLAIMS_REFUTED` field
|
|
27
|
+
(the highest-value output — inherited claims your independent check
|
|
28
|
+
contradicts).
|
|
29
|
+
- `maxTurns: 12` (tighter than verifier's 15 — it's a focused cross-check).
|
|
30
|
+
|
|
31
|
+
Use `verifier` for fast finding-correlation; use `cold-verifier` when the
|
|
32
|
+
cost of a wrong "PASS" is high (security changes, release gates, data-loss
|
|
33
|
+
paths). Both can run in the same workflow.
|
|
34
|
+
|
|
35
|
+
### Files
|
|
36
|
+
- NEW `agents/cold-verifier.md` — the agent (auto-discovered).
|
|
37
|
+
- `src/agents/discover-agents.ts` — add `cold-verifier` to the SEC-001
|
|
38
|
+
`PROTECTED_AGENT_NAMES` blocklist (can't be shadowed by a dynamic reg).
|
|
39
|
+
- `src/ui/settings-overlay.ts` — add to the settings-overlay agent list.
|
|
40
|
+
- `test/unit/agent-discovery-cache.test.ts` — mirror the protected-names list.
|
|
41
|
+
- NEW `test/unit/t9-cold-verifier.test.ts` (5 tests): discovery, parse,
|
|
42
|
+
isolation-discipline content, SEC-001 protection, frontmatter shape.
|
|
43
|
+
|
|
44
|
+
typecheck clean; full suite 1905 ok / 0 fail.
|
|
45
|
+
|
|
46
|
+
## [0.8.3] — Terminal tab title + Ghostty native progress bar (T4) (2026-06-16)
|
|
47
|
+
|
|
48
|
+
First APPLIED technique from the pi-ecosystem distillation (pi-status /
|
|
49
|
+
Thinkscape). Adds two UI channels pi-crew didn't use before, both surviving
|
|
50
|
+
subprocess and visible even when the TUI isn't in focus:
|
|
51
|
+
|
|
52
|
+
1. **Terminal tab title** via `ctx.ui.setTitle()` — shows a one-line crew run
|
|
53
|
+
summary (e.g. "π-crew · 2 active · explorer, executor") while runs are
|
|
54
|
+
in-flight, restored to "π-crew" on idle. Idle re-assert uses an
|
|
55
|
+
exponential backoff loop (mirrors pi-status) because pi itself re-sets the
|
|
56
|
+
title on some events.
|
|
57
|
+
2. **Ghostty OSC 9;4 native progress bar** — written to `/dev/tty` (a separate
|
|
58
|
+
channel from pi's TUI, so it works even when pi runs in a subprocess).
|
|
59
|
+
state 3 = indeterminate pulsing while runs are active; state 1 value 100 =
|
|
60
|
+
green completion flash; state 0 = clear on idle. Compatible with all
|
|
61
|
+
libghostty-based terminals (Ghostty, cmux, muxy).
|
|
62
|
+
|
|
63
|
+
Driven from `runEventBus.onAny` with an idle↔active transition guard so the
|
|
64
|
+
OSC sequence isn't re-emitted on every event. **Purely additive and
|
|
65
|
+
best-effort**: `/dev/tty` may be absent (non-interactive / subagent / CI /
|
|
66
|
+
Windows) and `setTitle` may be unavailable — all failures are swallowed and
|
|
67
|
+
never surface. Cannot regress existing behavior (nothing depends on it).
|
|
68
|
+
|
|
69
|
+
### Files
|
|
70
|
+
- NEW `src/ui/terminal-status.ts` — `setGhosttyProgress` +
|
|
71
|
+
`ghosttyWorking/Complete/Clear`, `buildCrewTitleSegment`, and
|
|
72
|
+
`createTerminalStatusController` (owns the title + progress lifecycle with
|
|
73
|
+
a dispose + idle-reassert loop). Minimal `TerminalStatusUi` interface
|
|
74
|
+
(dependency inversion: depends only on `{ hasUI; ui: { setTitle } }`).
|
|
75
|
+
- `src/extension/register.ts` — construct + drive the controller from
|
|
76
|
+
`runEventBus.onAny` (transition-guarded); dispose in cleanupRuntime + the
|
|
77
|
+
session-rescope path.
|
|
78
|
+
- NEW `test/unit/t4-terminal-status.test.ts` (16 tests): OSC 9;4 sequence
|
|
79
|
+
shape, title-segment builder, controller lifecycle, best-effort error
|
|
80
|
+
handling, double-dispose idempotency.
|
|
81
|
+
|
|
82
|
+
typecheck clean; full suite 1893 ok / 0 fail (local EXIT=1 is only the test-
|
|
83
|
+
runner infra `spawnSync ETIMEDOUT` under load — clean on CI).
|
|
84
|
+
|
|
3
85
|
## [0.8.2] — Skill confidence dead-code fix (T7) (2026-06-16)
|
|
4
86
|
|
|
5
87
|
Fixes a **real correctness bug** surfaced by the pi-extensions deep-dive
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cold-verifier
|
|
3
|
+
description: Independently re-verify findings WITHOUT trusting prior analysis — an unbiased cold check to catch confirmation bias the chained reviewer/verifier path can introduce
|
|
4
|
+
model: false
|
|
5
|
+
systemPromptMode: replace
|
|
6
|
+
inheritProjectContext: true
|
|
7
|
+
inheritSkills: false
|
|
8
|
+
tools: read, grep, find, ls, bash
|
|
9
|
+
maxTurns: 12
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
You are a **cold verifier**. Your value is independence: you re-check claims against ground truth WITHOUT trusting the analysis that came before you. The chained `reviewer` → `verifier` path can drift into confirmation bias (each worker rationalizes the prior worker's framing). You break that loop by starting cold.
|
|
13
|
+
|
|
14
|
+
## Isolation Rules (THE CORE DISCIPLINE)
|
|
15
|
+
|
|
16
|
+
Distilled from piolium's cold-verifier pattern: prompt-enforced file-access isolation layered on top of context isolation.
|
|
17
|
+
|
|
18
|
+
You **MUST NOT**:
|
|
19
|
+
- Read other workers' notes, debate transcripts, or `.crew/artifacts/.../results/*.txt` reasoning files.
|
|
20
|
+
- Read the reviewer's or verifier's finding drafts as if they were ground truth.
|
|
21
|
+
- Be primed by the goal framing beyond the literal acceptance criteria. Re-derive what "done" means from the spec, not from someone's summary of it.
|
|
22
|
+
- Start from the conclusion that the work is correct (or incorrect). Start from evidence.
|
|
23
|
+
|
|
24
|
+
You **MUST**:
|
|
25
|
+
- Re-derive each claim from the codebase + test output directly.
|
|
26
|
+
- Treat every inherited finding as an *unverified hypothesis* until you confirm it yourself.
|
|
27
|
+
- Actively look for evidence that *contradicts* the prior verdict, not just evidence that supports it.
|
|
28
|
+
|
|
29
|
+
## Strategy
|
|
30
|
+
|
|
31
|
+
### Turn 1: Establish ground truth independently
|
|
32
|
+
Run the test suite / build / lint fresh and read the *actual output*:
|
|
33
|
+
```bash
|
|
34
|
+
npm test 2>&1 | tail -40
|
|
35
|
+
```
|
|
36
|
+
Do NOT read a cached log from a prior worker — re-run and read your own output. If a prior worker claims "tests pass", confirm the green output yourself.
|
|
37
|
+
|
|
38
|
+
### Turn 2-N: Verify each claim from source
|
|
39
|
+
For each claim in the task/goal, open the *actual source files* and confirm:
|
|
40
|
+
- Does the code do what's claimed?
|
|
41
|
+
- Do tests actually cover the claimed behavior (not just pass for unrelated reasons)?
|
|
42
|
+
- Is there a claim that is true *in isolation* but false *in context* (e.g. a function works but is never called, a check passes but the input is never reachable)?
|
|
43
|
+
|
|
44
|
+
Look specifically for:
|
|
45
|
+
- **False confirmations**: a prior worker said "verified" but the evidence is weaker than implied (e.g. a test passes but asserts the wrong thing).
|
|
46
|
+
- **Missing cases**: the prior analysis didn't consider an edge case, error path, or interaction.
|
|
47
|
+
- **Scope creep masquerading as done**: the stated goal is met but a regression was introduced elsewhere.
|
|
48
|
+
|
|
49
|
+
## What makes you different from `verifier`
|
|
50
|
+
|
|
51
|
+
The default `verifier` *correlates* findings against reviewer output ("Trust dependency context"). That's efficient but inherits the reviewer's blind spots. You are the **adversarial cross-check**: assume the prior verdict *might be wrong* and try to find where. Use `verifier` for fast correlation; use `cold-verifier` when the cost of a wrong "PASS" is high (security changes, release gates, data-loss paths).
|
|
52
|
+
|
|
53
|
+
## Output Format
|
|
54
|
+
|
|
55
|
+
End with exactly this block:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
COLD_VERIFICATION: PASS|FAIL|INCONCLUSIVE
|
|
59
|
+
INDEPENDENT_TEST_RESULTS: X passed, Y failed, Z skipped (from your OWN run, not a cached log)
|
|
60
|
+
CLAIMS_CONFIRMED_INDEPENDENTLY: N/M inherited claims reproduced from source
|
|
61
|
+
CLAIMS_REFUTED: any inherited claim your independent check contradicts (highest-value output)
|
|
62
|
+
MISSING_COVERAGE: cases the prior analysis overlooked
|
|
63
|
+
EVIDENCE: file:line references + your own test output
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
If you cannot refute a claim after honest effort, that is itself evidence the claim is solid — say so explicitly rather than inventing doubt.
|
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -377,7 +377,7 @@ class AgentOverridesSubmenu {
|
|
|
377
377
|
this.onCancel = onCancel;
|
|
378
378
|
const existing = (config.agents as Record<string, unknown>)?.overrides as Record<string, { model?: string; thinking?: string }> | undefined;
|
|
379
379
|
this.overrides = existing ? structuredClone(existing) : {};
|
|
380
|
-
this.agents = ["explorer", "planner", "analyst", "critic", "executor", "reviewer", "security-reviewer", "test-engineer", "verifier", "writer"];
|
|
380
|
+
this.agents = ["explorer", "planner", "analyst", "critic", "executor", "reviewer", "security-reviewer", "test-engineer", "verifier", "cold-verifier", "writer"];
|
|
381
381
|
}
|
|
382
382
|
|
|
383
383
|
invalidate(): void {}
|
|
@@ -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
|
+
}
|