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.
- package/README.md +14 -9
- package/bin/cli.js +1013 -516
- package/bin/cli.ts +62 -5
- package/package.json +9 -6
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +34 -6
- package/packages/daemon/src/lib/app-config.ts +93 -2
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/tui/chrome/notify.ts +221 -34
- package/packages/daemon/src/tui/chrome/updater.ts +89 -7
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +15 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
- package/packages/daemon/src/tui/detect/manifest.ts +15 -0
- package/packages/daemon/src/tui/detect/manifests.ts +161 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
- package/packages/daemon/src/tui/main.ts +13 -1
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +499 -19
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +102 -13
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
- 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?: (
|
|
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
|
|
247
|
-
* `lastNotified` is mutated in place so the debounce
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
* REAL captured screens
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{ region: "bottom", contains: "
|
|
86
|
-
|
|
87
|
-
{ region: "bottom", contains: "
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// the
|
|
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):
|
|
101
|
-
//
|
|
102
|
-
// "
|
|
103
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
158
|
-
//
|
|
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
|
-
//
|
|
167
|
-
//
|
|
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: [
|
|
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
|
-
//
|
|
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 = [
|
|
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
|
+
}
|