tmux-ide 2.6.1 → 2.7.0

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 (83) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1013 -516
  3. package/bin/cli.ts +62 -5
  4. package/package.json +9 -6
  5. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  6. package/packages/daemon/dist/agent-explain.js +19 -3
  7. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  8. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  9. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  10. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  11. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  12. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  13. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  14. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  15. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  16. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  17. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  18. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  19. package/packages/daemon/src/agent-explain.ts +34 -6
  20. package/packages/daemon/src/lib/app-config.ts +93 -2
  21. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  22. package/packages/daemon/src/tui/chrome/notify.ts +221 -34
  23. package/packages/daemon/src/tui/chrome/updater.ts +89 -7
  24. package/packages/daemon/src/tui/compiled.ts +11 -3
  25. package/packages/daemon/src/tui/detect/classify.ts +15 -0
  26. package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
  27. package/packages/daemon/src/tui/detect/manifest.ts +15 -0
  28. package/packages/daemon/src/tui/detect/manifests.ts +161 -33
  29. package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
  30. package/packages/daemon/src/tui/main.ts +13 -1
  31. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  32. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  33. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  34. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  35. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  36. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  37. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  38. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  39. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  40. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  41. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  42. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  43. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  44. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  45. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  46. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  47. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  48. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  49. package/packages/daemon/src/tui/mirror/pane-mirror.ts +499 -19
  50. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  51. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  52. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  53. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  54. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  55. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  56. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  57. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  58. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  59. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  60. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  61. package/packages/daemon/src/tui/team/entry.ts +34 -7
  62. package/packages/daemon/src/tui/team/report.ts +11 -1
  63. package/packages/daemon/src/tui/team/sessions.ts +102 -13
  64. package/scripts/build-tui.mjs +11 -4
  65. package/scripts/perf-mirror.mjs +313 -0
  66. package/skill/SKILL.md +22 -0
  67. package/templates/AGENTS.md +14 -7
  68. package/templates/agent-team-monorepo.yml +8 -0
  69. package/templates/agent-team-nextjs.yml +8 -0
  70. package/templates/agent-team.yml +10 -0
  71. package/templates/convex.yml +2 -0
  72. package/templates/default.yml +11 -5
  73. package/templates/go.yml +4 -0
  74. package/templates/missions.yml +6 -0
  75. package/templates/nextjs.yml +4 -0
  76. package/templates/python.yml +4 -0
  77. package/templates/skills/backend.md +5 -12
  78. package/templates/skills/frontend.md +5 -12
  79. package/templates/skills/general-worker.md +5 -12
  80. package/templates/skills/researcher.md +7 -12
  81. package/templates/skills/reviewer.md +7 -16
  82. package/templates/vite.yml +4 -0
  83. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -34,6 +34,8 @@ import { paneChip } from "./chip.ts";
34
34
  import { appendEvents, diffFleet, type AgentEventInit } from "./events.ts";
35
35
  import {
36
36
  decideNotifications,
37
+ enabledStates,
38
+ inQuietHours,
37
39
  listAttachedClients,
38
40
  readNotificationPrefs,
39
41
  sendSystemNotification,
@@ -41,6 +43,7 @@ import {
41
43
  type AttachedClient,
42
44
  type NotificationPrefs,
43
45
  type NotifyEvent,
46
+ type SystemNotification,
44
47
  type ToastTarget,
45
48
  } from "./notify.ts";
46
49
  import {
@@ -148,7 +151,13 @@ export interface UpdaterTickDeps {
148
151
  now?: () => number;
149
152
  prefs?: NotificationPrefs;
150
153
  sendToasts?: (toasts: ToastTarget[]) => void;
151
- sendSystem?: (message: string) => void;
154
+ sendSystem?: (n: SystemNotification) => void;
155
+ /**
156
+ * Resolve a pane id to its human `session:window.pane` location for the ping
157
+ * text (optional). Wired to the live tmux {@link paneLocation}; tests inject a
158
+ * pure stub. Only ever called for the pane behind a blocked/done transition.
159
+ */
160
+ locatePane?: (paneId: string) => string;
152
161
  /**
153
162
  * Update-flow surfacing (optional). When wired, the tick calls this cheap,
154
163
  * cache-backed check each tick (throttled internally to 24h; it kicks off a
@@ -211,7 +220,7 @@ export function runUpdaterTick(deps: UpdaterTickDeps): void {
211
220
  for (const [name, status] of state) deps.prevState.set(name, status);
212
221
  if (events.length > 0) {
213
222
  deps.appendEvents(events);
214
- dispatchNotifications(deps, events);
223
+ dispatchNotifications(deps, enrichEvents(events, panes, deps.locatePane));
215
224
  }
216
225
  }
217
226
  }
@@ -241,21 +250,93 @@ function writeChips(
241
250
  }
242
251
  }
243
252
 
253
+ /**
254
+ * PURE — pick the pane to name in a session-level ping. A session rolls up many
255
+ * panes; the ping should point at ONE. We take a pane whose status matches the
256
+ * transition (`to`), preferring one that resolved to a real agent (so the ping
257
+ * reads `claude blocked …`, not a bare shell). Null when no pane matches — the
258
+ * updater then falls back to the session name / a generic label.
259
+ */
260
+ export function pickRepresentativePane(
261
+ session: string,
262
+ to: AgentStatus,
263
+ panes: PaneDetail[],
264
+ ): PaneDetail | null {
265
+ const matching = panes.filter((p) => p.sessionName === session && p.status === to);
266
+ if (matching.length === 0) return null;
267
+ return matching.find((p) => p.agent !== null) ?? matching[0]!;
268
+ }
269
+
270
+ /**
271
+ * PURE — enrich session-level transitions with the pane's `agent` id and human
272
+ * `location` so {@link notifyMessage} can name who needs the user. Only the
273
+ * notifiable states (blocked/done) get resolved — everything else keeps the bare
274
+ * session as its location and is filtered out downstream anyway, so `locate`
275
+ * (a live tmux call) only fires for a real ping.
276
+ */
277
+ export function enrichEvents(
278
+ events: AgentEventInit[],
279
+ panes: PaneDetail[],
280
+ locate?: (paneId: string) => string,
281
+ ): NotifyEvent[] {
282
+ return events.map((ev) => {
283
+ const notifiable = ev.to === "blocked" || ev.to === "done";
284
+ const rep = notifiable ? pickRepresentativePane(ev.session, ev.to, panes) : null;
285
+ return {
286
+ ...ev,
287
+ agent: rep?.agent ?? null,
288
+ location: rep && locate ? locate(rep.paneId) : ev.session,
289
+ };
290
+ });
291
+ }
292
+
244
293
  /**
245
294
  * Ping the user about who needs them from this tick's transitions. Only runs
246
- * when the notification deps are wired AND at least one channel is enabled;
247
- * `lastNotified` is mutated in place so the debounce persists across ticks.
295
+ * when the notification deps are wired, notifications are `enabled`, AND at
296
+ * least one channel is on. `lastNotified` is mutated in place so the debounce
297
+ * (the flap guard) persists across ticks. The macOS banner is additionally
298
+ * gated on QUIET HOURS — inside the window the banner is skipped, but the event
299
+ * has already been recorded to the log by the caller, so history stays honest.
248
300
  */
249
301
  function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): void {
250
302
  const { listClients, lastNotified, now, prefs, sendToasts: toast, sendSystem } = deps;
251
303
  if (!listClients || !lastNotified || !now || !prefs) return;
304
+ if (!prefs.enabled) return;
252
305
  if (!prefs.toast && !prefs.macos) return;
253
- const decision = decideNotifications(events, listClients(), lastNotified, now());
306
+ const nowMs = now();
307
+ const decision = decideNotifications(
308
+ events,
309
+ listClients(),
310
+ lastNotified,
311
+ nowMs,
312
+ enabledStates(prefs),
313
+ );
254
314
  lastNotified.clear();
255
315
  for (const [key, ts] of decision.nextLastNotified) lastNotified.set(key, ts);
256
316
  if (prefs.toast && toast) toast(decision.toasts);
257
- if (prefs.macos && sendSystem) {
258
- for (const { message } of decision.system) sendSystem(message);
317
+ if (prefs.macos && sendSystem && !inQuietHours(new Date(nowMs), prefs.quietHours)) {
318
+ for (const n of decision.system) sendSystem(n);
319
+ }
320
+ }
321
+
322
+ /**
323
+ * io — resolve a pane id to `session:window.pane` (e.g. `myproj:1.2`) for the
324
+ * ping text. Best-effort: a gone pane / failed call degrades to the raw pane id.
325
+ */
326
+ export function paneLocation(paneId: string): string {
327
+ try {
328
+ const raw = runTmux([
329
+ "display-message",
330
+ "-p",
331
+ "-t",
332
+ paneId,
333
+ "#{session_name}:#{window_index}.#{pane_index}",
334
+ ])
335
+ .toString()
336
+ .trim();
337
+ return raw || paneId;
338
+ } catch {
339
+ return paneId;
259
340
  }
260
341
  }
261
342
 
@@ -404,6 +485,7 @@ export function runUpdaterLoop(): void {
404
485
  prefs: readNotificationPrefs(),
405
486
  sendToasts,
406
487
  sendSystem: sendSystemNotification,
488
+ locatePane: paneLocation,
407
489
  maybeCheckForUpdate: () => maybeCheckForUpdate({ enabled: config.updates.check }),
408
490
  markUpdateNotified,
409
491
  });
@@ -9,7 +9,9 @@
9
9
  *
10
10
  * Order is "checkout first, binary second": a dev machine that happens to have
11
11
  * built the binary still uses its live `.tsx` sources. The binary is consulted
12
- * only when the checkout sources or `bun` are missing.
12
+ * only when the checkout sources or `bun` are missing. {@link findCompiledTui}
13
+ * probes the shipped/local compiled binary first, then a per-platform binary
14
+ * downloaded at runtime into `~/.tmux-ide/bin/` (see lib/tui-binary.ts).
13
15
  *
14
16
  * {@link resolveTuiLaunch} is a PURE decision (unit-tested); {@link findCompiledTui}
15
17
  * and {@link isBunAvailable} are the thin io probes that feed it.
@@ -18,6 +20,7 @@ import { existsSync } from "node:fs";
18
20
  import { dirname, resolve } from "node:path";
19
21
  import { fileURLToPath } from "node:url";
20
22
  import { execFileSync } from "node:child_process";
23
+ import { findDownloadedTui } from "../lib/tui-binary.ts";
21
24
 
22
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
26
 
@@ -63,7 +66,7 @@ export function resolveTuiLaunch(input: TuiResolveInput): TuiLaunch {
63
66
  reasons.push("the `bun` runtime is not installed (https://bun.sh)");
64
67
  }
65
68
  reasons.push(
66
- "no compiled `tmux-ide-tui` binary was found (build one with `pnpm build:tui`, or reinstall a release that ships it)",
69
+ "no compiled `tmux-ide-tui` binary was found (build one with `pnpm build:tui`, download it with `tmux-ide update --tui-binary`, or reinstall a release that ships it)",
67
70
  );
68
71
  return { mode: "unavailable", reasons };
69
72
  }
@@ -99,7 +102,12 @@ export function findCompiledTui(): string | null {
99
102
  if (existsSync(candidate)) return candidate;
100
103
  }
101
104
  }
102
- return null;
105
+
106
+ // Last: a per-platform binary fetched at runtime by `tmux-ide update
107
+ // --tui-binary` into `~/.tmux-ide/bin/` — the fallback for an npm install with
108
+ // no bun and no shipped compiled binary. Version-stamped, so it only matches
109
+ // the running version (see lib/tui-binary.ts).
110
+ return findDownloadedTui();
103
111
  }
104
112
 
105
113
  /** io — is the `bun` runtime callable? */
@@ -49,6 +49,21 @@ export function parseAuthority(raw: string | undefined, nowSec: number): AgentSt
49
49
  return state as AgentStatus;
50
50
  }
51
51
 
52
+ /**
53
+ * Extract the epoch STAMP from an `@agent_state` value (`"<state>:<epoch>"`) —
54
+ * the "since" timestamp of the reported state. PURE — returns the epoch, or
55
+ * null when the value is absent or its stamp isn't a finite number. Staleness
56
+ * is NOT considered here (that's {@link parseAuthority}'s job); callers pair
57
+ * this with a fresh `parseAuthority` result to surface a state's `since`.
58
+ */
59
+ export function parseAuthorityEpoch(raw: string | undefined): number | null {
60
+ if (!raw) return null;
61
+ const sep = raw.lastIndexOf(":");
62
+ if (sep === -1) return null;
63
+ const epoch = Number(raw.slice(sep + 1));
64
+ return Number.isFinite(epoch) ? epoch : null;
65
+ }
66
+
52
67
  /**
53
68
  * Classify a single snapshot against a manifest — PURE, never throws.
54
69
  *
@@ -156,7 +156,8 @@ function normalizeStates(m: AgentManifest): AgentManifest {
156
156
  if (m.states.blocked) states.blocked = m.states.blocked;
157
157
  if (m.states.working) states.working = m.states.working;
158
158
  if (m.states.done) states.done = m.states.done;
159
- return { id: m.id, commands: m.commands, states };
159
+ const confidence = m.confidence === "tuned" ? "tuned" : "conservative";
160
+ return { id: m.id, commands: m.commands, states, confidence };
160
161
  }
161
162
 
162
163
  function warnOnce(path: string, reason: string): void {
@@ -40,6 +40,15 @@ export interface StateRules {
40
40
  done?: Rule;
41
41
  }
42
42
 
43
+ /**
44
+ * How much trust the manifest's rules earn.
45
+ * - `tuned`: built from REAL captured screens (or the agent's own source
46
+ * strings) — the markers are verbatim, not guessed.
47
+ * - `conservative`: best-effort from public knowledge; high-precision only, so
48
+ * it can never false-positive on a plain prompt, but it may miss real states.
49
+ */
50
+ export type ManifestConfidence = "tuned" | "conservative";
51
+
43
52
  /** A detection manifest for one or more pane commands. */
44
53
  export interface AgentManifest {
45
54
  /** Stable manifest id. */
@@ -48,6 +57,12 @@ export interface AgentManifest {
48
57
  commands: string[];
49
58
  /** Evidence rules per detectable state. */
50
59
  states: StateRules;
60
+ /**
61
+ * Evidence confidence (default `conservative` when omitted). Surfaced by
62
+ * `agent explain` so a user can see whether a verdict rests on real evidence
63
+ * or a best-effort heuristic. Purely informational — never affects matching.
64
+ */
65
+ confidence?: ManifestConfidence;
51
66
  }
52
67
 
53
68
  /** The states a manifest can positively detect (idle is inferred elsewhere). */
@@ -8,14 +8,22 @@
8
8
  * stay minimal. Treat every matcher below as a heuristic to tune, not a
9
9
  * contract — edit freely as agent UIs change.
10
10
  *
11
- * EVIDENCE PROVENANCE. The `claude` and `codex` manifests are tuned against
12
- * REAL captured screens (see `manifest-corpus.test.ts` for the sanitized
13
- * fixtures). Each matcher cites the invariant it was built from ("seen: …").
14
- * The remaining manifests (`opencode`, `gemini`, `aider`, `copilot`) are
15
- * best-effort from public docs/common knowledge — every one of their matchers
16
- * is marked "untuned — needs real captures" and kept HIGH-PRECISION
17
- * (esc-to-interrupt / spinner / explicit y-n) so they can never false-positive
18
- * on a plain prompt. Users can override any of these with a JSON file in
11
+ * EVIDENCE PROVENANCE (per-manifest `confidence`):
12
+ * - `tuned` — built from REAL captured screens or the agent's own source
13
+ * strings. `claude`, `codex`, and `aider` are tuned: codex from live
14
+ * `tmux capture-pane` frames driven through a real turn (idle / working /
15
+ * command-approval / trust prompt), aider from the verbatim prompt strings
16
+ * in its installed source (`io.py` confirm_ask, `waiting.py` spinner).
17
+ * Each matcher cites the invariant it was built from ("seen: …").
18
+ * - `conservative` — best-effort from public docs/common knowledge
19
+ * (`opencode`, `gemini`, `copilot`, `cursor`, `goose`, `amp`). Every
20
+ * matcher is HIGH-PRECISION (esc-to-interrupt / spinner / explicit y-n /
21
+ * "Do you want") so it can never false-positive on a plain prompt, but it
22
+ * may miss real states until a live capture upgrades it. `opencode` was
23
+ * attempted live but its local auth DB errored and the TUI rendered blank,
24
+ * so it stays conservative.
25
+ *
26
+ * Users can override any of these with a JSON file in
19
27
  * `~/.tmux-ide/agent-detection/` (see `manifest-loader.ts`).
20
28
  */
21
29
  import type { AgentManifest } from "./manifest.ts";
@@ -27,6 +35,7 @@ const BRAILLE_SPINNER = "[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]";
27
35
  const CLAUDE: AgentManifest = {
28
36
  id: "claude",
29
37
  commands: ["claude"],
38
+ confidence: "tuned",
30
39
  states: {
31
40
  // Approval / confirmation prompts — Claude is waiting on the user.
32
41
  // Claude's approval UI is a bordered box asking a "Do you want …?" question
@@ -74,41 +83,57 @@ const CLAUDE: AgentManifest = {
74
83
 
75
84
  const CODEX: AgentManifest = {
76
85
  id: "codex",
77
- commands: ["codex"],
86
+ commands: ["codex", "codex.exe"],
87
+ confidence: "tuned",
78
88
  states: {
89
+ // TUNED against real captures (codex-cli v0.142.5, driven through a turn).
90
+ // The command-approval dialog and the directory-trust prompt are the two
91
+ // "blocked" screens. Codex's approval menu uses a "› 1." numbered arrow —
92
+ // note the arrow is "›" (U+203A), NOT claude's "❯".
79
93
  blocked: {
80
94
  any: [
81
- // untuned — needs real captures. Codex approval prompts (docs/common
82
- // knowledge): a command-approval question before running a shell
83
- // command. Kept high-precision so it can't fire on the idle "›" box.
84
- { region: "bottom", contains: "Allow command", caseInsensitive: true },
85
- { region: "bottom", contains: "Do you want" },
86
- { region: "bottom", contains: "approve", caseInsensitive: true },
87
- { region: "bottom", contains: "(y/n)", caseInsensitive: true },
95
+ // seen (command approval): "Would you like to run the following
96
+ // command?" above a "$ <cmd>" preview and the numbered menu.
97
+ { region: "bottom", contains: "Would you like to run", caseInsensitive: true },
98
+ // seen: the highlighted approval option "› 1. Yes, proceed".
99
+ { region: "bottom", contains: "Yes, proceed" },
100
+ // seen: "3. No, and tell Codex what to do differently (esc)".
101
+ { region: "bottom", contains: "No, and tell Codex" },
102
+ // seen: the confirm footer under the approval menu.
103
+ { region: "bottom", contains: "Press enter to confirm", caseInsensitive: true },
104
+ // seen (directory-trust prompt on first launch in an untrusted dir):
105
+ // "Do you trust the contents of this directory?" + "1. Yes, continue".
106
+ { region: "bottom", contains: "Do you trust the contents", caseInsensitive: true },
88
107
  ],
89
108
  },
90
109
  working: {
91
110
  any: [
92
- // untuned for the exact string — Codex shows an elapsed-time working
93
- // line with an interrupt hint while a turn runs. "esc to interrupt" is
94
- // the shared CLI-TUI invariant; the spinner is the fallback.
111
+ // seen (verbatim): the working status line is
112
+ // "• Working (6s • esc to interrupt)".
113
+ // Both the "Working (" prefix and the shared "esc to interrupt" hint
114
+ // are present for the whole turn.
115
+ { region: "bottom", regex: "Working \\(\\d" },
95
116
  { region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
96
117
  { region: "bottom", regex: BRAILLE_SPINNER },
97
118
  { region: "title", regex: BRAILLE_SPINNER },
98
119
  ],
99
120
  },
100
- // done: omitted. NOTE (seen, NOT used): idle Codex shows a "›" input prompt
101
- // and a "gpt-5.5 high · <cwd> Goal achieved (5m)" status line —
102
- // "Goal achieved" is a finished/idle marker, not "working", so it is left
103
- // out (a done rule would collapse to idle in the instant classifier anyway).
121
+ // done: omitted. NOTE (seen, NOT used): a finished turn leaves the agent's
122
+ // answer above the idle "›" input box (placeholder "Find and fix a bug in
123
+ // @filename") and a "gpt-5.5 xhigh · <cwd>" status line; older builds also
124
+ // showed "Goal achieved (5m)". None are working/blocked evidence, so codex
125
+ // correctly falls through to idle and the classifier infers done.
104
126
  },
105
127
  };
106
128
 
107
129
  const OPENCODE: AgentManifest = {
108
130
  id: "opencode",
109
- commands: ["opencode"],
131
+ commands: ["opencode", "opencode.exe"],
132
+ confidence: "conservative",
110
133
  states: {
111
- // untuned — needs real captures. High-precision only.
134
+ // conservative — a live capture was attempted (opencode v1.17.10) but its
135
+ // local auth DB errored ("no such column: name") and the TUI rendered
136
+ // blank, so these stay best-effort. High-precision only.
112
137
  blocked: {
113
138
  any: [
114
139
  { region: "bottom", contains: "(y/n)", caseInsensitive: true },
@@ -129,8 +154,10 @@ const OPENCODE: AgentManifest = {
129
154
  const GEMINI: AgentManifest = {
130
155
  id: "gemini",
131
156
  commands: ["gemini"],
157
+ confidence: "conservative",
132
158
  states: {
133
- // untuned — needs real captures. gemini-cli. High-precision only.
159
+ // conservative — gemini-cli needs a Google account/API key to reach a
160
+ // working state, so no live capture was taken. High-precision only.
134
161
  blocked: {
135
162
  any: [
136
163
  { region: "bottom", contains: "(y/n)", caseInsensitive: true },
@@ -153,20 +180,33 @@ const GEMINI: AgentManifest = {
153
180
  const AIDER: AgentManifest = {
154
181
  id: "aider",
155
182
  commands: ["aider"],
183
+ confidence: "tuned",
156
184
  states: {
157
- // untuned — needs real captures. aider uses "(Y)es/(N)o" confirmation
158
- // prompts, which are its most reliable blocked signal.
185
+ // TUNED from aider's installed source (v0.86.2). Every confirmation renders
186
+ // through `io.confirm_ask` (io.py), which appends the literal option string
187
+ // " (Y)es/(N)o" (plus "/(A)ll/(S)kip all" or "/(D)on't ask again") and a
188
+ // "[Yes]:"/"[No]:" default — so "(Y)es/(N)o" is aider's exact, universal
189
+ // blocked marker. The specific questions below are verbatim from
190
+ // base_coder.py / commands.py.
159
191
  blocked: {
160
192
  any: [
161
193
  { region: "bottom", contains: "(Y)es/(N)o", caseInsensitive: true },
162
- { region: "bottom", contains: "? [Yes]", caseInsensitive: true },
163
194
  { region: "bottom", contains: "Add file to the chat", caseInsensitive: true },
195
+ { region: "bottom", contains: "Allow edits to file", caseInsensitive: true },
196
+ { region: "bottom", contains: "Add command output to the chat", caseInsensitive: true },
197
+ { region: "bottom", contains: "Run pip install", caseInsensitive: true },
164
198
  ],
165
199
  },
166
- // working: aider streams tokens without a stable status line, so we leave
167
- // it to idle-by-default rather than risk a false positive.
200
+ // TUNED: while a turn runs aider shows a `WaitingSpinner` (waiting.py)
201
+ // rendered as "[░█ ] Waiting for <model>" — the text is literally
202
+ // "Waiting for LLM" or "Waiting for " + the model name (base_coder.py:1440).
203
+ // aider's spinner uses a "░█" scanner, NOT braille, so "Waiting for " is the
204
+ // real invariant; the braille probe is kept only as a harmless fallback.
168
205
  working: {
169
- any: [{ region: "bottom", regex: BRAILLE_SPINNER }],
206
+ any: [
207
+ { region: "bottom", contains: "Waiting for ", caseInsensitive: false },
208
+ { region: "bottom", regex: BRAILLE_SPINNER },
209
+ ],
170
210
  },
171
211
  },
172
212
  };
@@ -174,8 +214,10 @@ const AIDER: AgentManifest = {
174
214
  const COPILOT: AgentManifest = {
175
215
  id: "copilot",
176
216
  commands: ["copilot", "github-copilot", "github-copilot-cli"],
217
+ confidence: "conservative",
177
218
  states: {
178
- // untuned — needs real captures. github-copilot-cli. High-precision only.
219
+ // conservative — github-copilot-cli needs a GitHub account, so no live
220
+ // capture was taken. High-precision only.
179
221
  blocked: {
180
222
  any: [
181
223
  { region: "bottom", contains: "(y/n)", caseInsensitive: true },
@@ -193,9 +235,92 @@ const COPILOT: AgentManifest = {
193
235
  },
194
236
  };
195
237
 
238
+ const CURSOR: AgentManifest = {
239
+ id: "cursor",
240
+ commands: ["cursor-agent", "cursor"],
241
+ confidence: "conservative",
242
+ states: {
243
+ // conservative — cursor-agent (Cursor CLI) was launched live but sits on a
244
+ // "Press any key to log in…" pre-auth screen without an account, so no
245
+ // working/blocked turn could be captured. The pre-auth splash ("Cursor
246
+ // Agent" / "Press any key to log in") is idle chrome and deliberately NOT
247
+ // matched here. Markers below are high-precision guesses from public
248
+ // knowledge of its approval/streaming UI. NOTE: cursor-agent runs under
249
+ // `node`, so it resolves via the process-tree (argv0 basename), not the
250
+ // pane's `current_command`.
251
+ blocked: {
252
+ any: [
253
+ { region: "bottom", contains: "Do you want", caseInsensitive: false },
254
+ { region: "bottom", contains: "Run this command", caseInsensitive: true },
255
+ { region: "bottom", contains: "Apply this edit", caseInsensitive: true },
256
+ { region: "bottom", contains: "(y/n)", caseInsensitive: true },
257
+ ],
258
+ },
259
+ working: {
260
+ any: [
261
+ { region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
262
+ { region: "bottom", regex: BRAILLE_SPINNER },
263
+ { region: "title", regex: BRAILLE_SPINNER },
264
+ ],
265
+ },
266
+ },
267
+ };
268
+
269
+ const GOOSE: AgentManifest = {
270
+ id: "goose",
271
+ commands: ["goose"],
272
+ confidence: "conservative",
273
+ states: {
274
+ // conservative — Block's goose CLI needs a configured provider, so no live
275
+ // capture was taken. High-precision only; markers are best-effort from
276
+ // public knowledge of its confirmation/streaming UI.
277
+ blocked: {
278
+ any: [
279
+ { region: "bottom", contains: "Do you want", caseInsensitive: false },
280
+ { region: "bottom", contains: "Allow this tool", caseInsensitive: true },
281
+ { region: "bottom", contains: "(y/n)", caseInsensitive: true },
282
+ { region: "bottom", contains: "[y/n]", caseInsensitive: true },
283
+ ],
284
+ },
285
+ working: {
286
+ any: [
287
+ { region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
288
+ { region: "bottom", regex: BRAILLE_SPINNER },
289
+ { region: "title", regex: BRAILLE_SPINNER },
290
+ ],
291
+ },
292
+ },
293
+ };
294
+
295
+ const AMP: AgentManifest = {
296
+ id: "amp",
297
+ commands: ["amp"],
298
+ confidence: "conservative",
299
+ states: {
300
+ // conservative — Sourcegraph's amp CLI needs an account, so no live capture
301
+ // was taken. High-precision only; markers are best-effort from public
302
+ // knowledge of its approval/streaming UI.
303
+ blocked: {
304
+ any: [
305
+ { region: "bottom", contains: "Do you want", caseInsensitive: false },
306
+ { region: "bottom", contains: "Allow", caseInsensitive: false },
307
+ { region: "bottom", contains: "(y/n)", caseInsensitive: true },
308
+ ],
309
+ },
310
+ working: {
311
+ any: [
312
+ { region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
313
+ { region: "bottom", regex: BRAILLE_SPINNER },
314
+ { region: "title", regex: BRAILLE_SPINNER },
315
+ ],
316
+ },
317
+ },
318
+ };
319
+
196
320
  const SHELL: AgentManifest = {
197
321
  id: "shell",
198
322
  commands: ["bash", "zsh", "sh", "fish", "nu"],
323
+ confidence: "conservative",
199
324
  states: {
200
325
  // Catch-all: a raw shell is almost always idle. We only flag an explicit
201
326
  // interactive confirmation as blocked; "working" is unreliable to read
@@ -222,5 +347,8 @@ export const BUNDLED_MANIFESTS: AgentManifest[] = [
222
347
  GEMINI,
223
348
  AIDER,
224
349
  COPILOT,
350
+ CURSOR,
351
+ GOOSE,
352
+ AMP,
225
353
  SHELL,
226
354
  ];
@@ -83,6 +83,26 @@ export function subtreeCommands(entries: ProcEntry[], rootPid: number, maxDepth
83
83
  return commands;
84
84
  }
85
85
 
86
+ /**
87
+ * Summarize the executable-ish tokens seen in a pane's process subtree, in the
88
+ * order the tree-walk visits them (deepest first), de-duplicated. Pure.
89
+ *
90
+ * This is a DIAGNOSTIC for `agent explain`: when no manifest resolves, showing
91
+ * "process-tree saw: node, sleep" tells the user WHY nothing matched and what
92
+ * to point an `@agent_hint` at. Tokens are the same basenames
93
+ * `resolveAgentCommand` matches against, so the report reflects reality.
94
+ */
95
+ export function describeSubtree(entries: ProcEntry[], rootPid: number, limit = 8): string[] {
96
+ const seen: string[] = [];
97
+ for (const command of subtreeCommands(entries, rootPid)) {
98
+ for (const token of commandTokens(command)) {
99
+ if (!seen.includes(token)) seen.push(token);
100
+ if (seen.length >= limit) return seen;
101
+ }
102
+ }
103
+ return seen;
104
+ }
105
+
86
106
  /**
87
107
  * Read the live process table. Thin io wrapper — on any failure returns `[]`
88
108
  * so callers degrade to the fast path rather than throwing.
@@ -20,7 +20,16 @@
20
20
  * exactly one surface's top-level `render()` side effect from firing.
21
21
  */
22
22
 
23
- const SURFACES = ["team", "explorer", "changes", "preview", "config", "setup", "sidebar"] as const;
23
+ const SURFACES = [
24
+ "team",
25
+ "app",
26
+ "explorer",
27
+ "changes",
28
+ "preview",
29
+ "config",
30
+ "setup",
31
+ "sidebar",
32
+ ] as const;
24
33
 
25
34
  type Surface = (typeof SURFACES)[number];
26
35
 
@@ -46,6 +55,9 @@ async function main(): Promise<void> {
46
55
  case "team":
47
56
  await import("./team/index.tsx");
48
57
  break;
58
+ case "app":
59
+ await import("./mirror/app.tsx");
60
+ break;
49
61
  case "explorer":
50
62
  await import("../widgets/explorer/index.tsx");
51
63
  break;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * AckWriter — ack-paced, coalescing writes into an async sink (M21.5).
3
+ *
4
+ * xterm-headless's `write()` is asynchronous: bytes queue in its WriteBuffer
5
+ * and parse on its own schedule, with a completion callback per write. Feeding
6
+ * every control-mode `%output` chunk straight in piles up unbounded entries in
7
+ * that internal queue during a flood. This writer paces instead: chunks
8
+ * arriving while a write is in flight buffer HERE, and the completion callback
9
+ * triggers exactly one follow-up write of everything buffered, joined. At most
10
+ * ONE sink write is ever outstanding, order and content are preserved
11
+ * byte-for-byte, and the caller (the control-channel reader loop) never waits.
12
+ *
13
+ * Pure core: the sink is injected (`term.write(data, done)` in production, a
14
+ * recording stub in tests); no timers, no io.
15
+ */
16
+ export class AckWriter {
17
+ private queue: Uint8Array[] = [];
18
+ private inFlight = false;
19
+
20
+ /** `onAck` fires after EACH sink write completes — i.e. when those bytes are
21
+ * actually visible in the sink (parsed, for xterm). Consumers that gate
22
+ * rendering on "content changed" must re-arm on this, not on enqueue: with
23
+ * pacing, an enqueue-time signal can be consumed before the parse lands and
24
+ * the final frame would never render. */
25
+ constructor(
26
+ private readonly sink: (data: Uint8Array, done: () => void) => void,
27
+ private readonly onAck?: () => void,
28
+ ) {}
29
+
30
+ /** Enqueue bytes; writes through immediately when the sink is idle. */
31
+ write(data: Uint8Array): void {
32
+ if (data.length === 0) return;
33
+ this.queue.push(data);
34
+ this.pump();
35
+ }
36
+
37
+ /** Bytes buffered here awaiting the in-flight write's ack (tests only). */
38
+ pendingBytes(): number {
39
+ let n = 0;
40
+ for (const c of this.queue) n += c.length;
41
+ return n;
42
+ }
43
+
44
+ /** A sink write is currently outstanding (tests only). */
45
+ busy(): boolean {
46
+ return this.inFlight;
47
+ }
48
+
49
+ private pump(): void {
50
+ if (this.inFlight || this.queue.length === 0) return;
51
+ const chunks = this.queue;
52
+ this.queue = [];
53
+ const data = chunks.length === 1 ? chunks[0]! : concatBytes(chunks);
54
+ this.inFlight = true;
55
+ let acked = false;
56
+ this.sink(data, () => {
57
+ if (acked) return; // a double-fired callback must not unleash overlap
58
+ acked = true;
59
+ this.inFlight = false;
60
+ this.onAck?.();
61
+ this.pump();
62
+ });
63
+ }
64
+ }
65
+
66
+ /** Join byte chunks into one contiguous array (exported for tests). */
67
+ export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
68
+ let total = 0;
69
+ for (const c of chunks) total += c.length;
70
+ const out = new Uint8Array(total);
71
+ let off = 0;
72
+ for (const c of chunks) {
73
+ out.set(c, off);
74
+ off += c.length;
75
+ }
76
+ return out;
77
+ }