tmux-ide 2.6.1 → 2.8.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 +36 -14
- package/bin/cli.js +4174 -1235
- package/bin/cli.ts +426 -72
- package/package.json +10 -6
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- 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/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- 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/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +194 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +737 -52
- package/packages/daemon/src/tui/chrome/updater.ts +318 -30
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +49 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
- package/packages/daemon/src/tui/detect/manifest.ts +39 -3
- package/packages/daemon/src/tui/detect/manifests.ts +395 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -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 +126 -0
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
- package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
- package/packages/daemon/src/tui/mirror/attention.ts +110 -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 +367 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +322 -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/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
- package/packages/daemon/src/tui/mirror/palette.ts +564 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -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 +376 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +130 -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/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +184 -17
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- package/skill/SKILL.md +110 -2
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-pane agent chips for the unified app (M22.3) — PURE.
|
|
3
|
+
*
|
|
4
|
+
* The fleet payload (`tmux-ide team --json`) carries per-pane agent entries
|
|
5
|
+
* (`agents: PaneAgentEntry[]`, M22.1). This module is the io-free half of the
|
|
6
|
+
* Terminal surface's pane chips: the paneId→entry join, the label builder, and
|
|
7
|
+
* the width-degrading truncation. The render side (app.tsx) styles the label
|
|
8
|
+
* with the app's own STATUS_COLOR/STATUS_GLYPH grammar — the OpenTUI-side
|
|
9
|
+
* mirror of the dock chip grammar in tui/chrome/chip.ts — so a pane chip in
|
|
10
|
+
* the app reads the same as its sidebar/home glyphs. The glyph travels IN as a
|
|
11
|
+
* parameter: this module carries no grammar constants of its own (they live
|
|
12
|
+
* with the app; see the status-grammar coordination note in the M22 board).
|
|
13
|
+
*
|
|
14
|
+
* Staleness is NOT re-implemented here: the report already applies the
|
|
15
|
+
* authority staleness window and falls back to scraping, so `state` is always
|
|
16
|
+
* the final answer — this module only joins and formats.
|
|
17
|
+
*/
|
|
18
|
+
import type { AgentStatus } from "../detect/classify.ts";
|
|
19
|
+
|
|
20
|
+
/** The slice of a fleet `PaneAgentEntry` the chip needs (structural — the app
|
|
21
|
+
* declares its fleet shapes locally instead of importing the data layer). */
|
|
22
|
+
export interface ChipAgent {
|
|
23
|
+
/** tmux pane id (`%N`) — the mirror's pane ids are the same ids, so the join
|
|
24
|
+
* is a straight map lookup. */
|
|
25
|
+
paneId: string;
|
|
26
|
+
/** Resolved agent kind (manifest id: `claude`, `codex`, …). */
|
|
27
|
+
kind: string;
|
|
28
|
+
/** Final per-pane status (authority when fresh, else scraped/tracked). */
|
|
29
|
+
state: AgentStatus;
|
|
30
|
+
/** Authority epoch (SECONDS) when the authority layer supplied the state;
|
|
31
|
+
* null for scraped panes. Feeds the "blocked 4m" age suffix. */
|
|
32
|
+
since: number | null;
|
|
33
|
+
/** Self-reported one-liner (`@agent_status_text`, already sanitized ≤32 chars
|
|
34
|
+
* by the report; absent when the pane never stamped one or its authority
|
|
35
|
+
* stamp went stale). Renders as the "· refactoring auth" suffix. */
|
|
36
|
+
statusText?: string;
|
|
37
|
+
/** Self-reported display name (`@agent_display_name`) — replaces `kind` in
|
|
38
|
+
* the label when present. Same staleness gate as {@link statusText}. */
|
|
39
|
+
displayName?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Flatten a fleet payload into a paneId→entry map. Tolerates sessions without
|
|
44
|
+
* an `agents` field (older payloads degrade to no chips, never a crash).
|
|
45
|
+
* First entry wins on a duplicate paneId (a session listed under two projects
|
|
46
|
+
* repeats its panes; pane ids are server-global so the entries are identical).
|
|
47
|
+
*/
|
|
48
|
+
export function agentsByPane(
|
|
49
|
+
projects: ReadonlyArray<{
|
|
50
|
+
sessions: ReadonlyArray<{ agents?: ReadonlyArray<ChipAgent> }>;
|
|
51
|
+
}>,
|
|
52
|
+
): Map<string, ChipAgent> {
|
|
53
|
+
const map = new Map<string, ChipAgent>();
|
|
54
|
+
for (const p of projects)
|
|
55
|
+
for (const s of p.sessions)
|
|
56
|
+
for (const a of s.agents ?? []) if (!map.has(a.paneId)) map.set(a.paneId, a);
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compact age of a state stamp — "32s" / "4m" / "2h" / "3d" — or null when
|
|
62
|
+
* there is no authoritative timestamp (scraped pane). Negative skew clamps to
|
|
63
|
+
* "0s" rather than inventing time travel.
|
|
64
|
+
*/
|
|
65
|
+
export function stateAge(since: number | null, nowMs: number): string | null {
|
|
66
|
+
if (since === null) return null;
|
|
67
|
+
const s = Math.max(0, Math.floor(nowMs / 1000 - since));
|
|
68
|
+
if (s < 60) return `${s}s`;
|
|
69
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
70
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
71
|
+
return `${Math.floor(s / 86400)}d`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The least status-text worth showing: 3 chars + the ellipsis. Below this the
|
|
75
|
+
* suffix is dropped instead of rendering an unreadable "· r…". */
|
|
76
|
+
const MIN_STATUS_TEXT = 4;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a chip label that fits `budget` cells (the printable text, WITHOUT the
|
|
80
|
+
* one-cell padding the renderer adds each side). Degrades in steps rather than
|
|
81
|
+
* mid-word ellipsis so a narrow pane still reads:
|
|
82
|
+
*
|
|
83
|
+
* `● claude · refactoring auth` self-reported status text (M25.4)
|
|
84
|
+
* `● claude · blocked 4m` blocked, authority age known
|
|
85
|
+
* `● claude · blocked` blocked, scraped (no stamp)
|
|
86
|
+
* `● claude` any other state (color carries the state)
|
|
87
|
+
* `●` narrow pane
|
|
88
|
+
* null no room at all (or a hidden-worthy budget < 1)
|
|
89
|
+
*
|
|
90
|
+
* `displayName` replaces the kind when the agent stamped one. A status-text
|
|
91
|
+
* candidate that overflows is first re-fit by truncating the TEXT with an
|
|
92
|
+
* ellipsis (never the glyph/name prefix), then dropped entirely — so the
|
|
93
|
+
* degradation order stays step-wise and readable. Blocked still spells its
|
|
94
|
+
* state out (it is the attention state) and outranks the status text; the text
|
|
95
|
+
* rides after it when the budget allows.
|
|
96
|
+
*/
|
|
97
|
+
export function chipLabel(
|
|
98
|
+
entry: ChipAgent,
|
|
99
|
+
glyph: string,
|
|
100
|
+
nowMs: number,
|
|
101
|
+
budget: number,
|
|
102
|
+
): string | null {
|
|
103
|
+
const base = `${glyph} ${entry.displayName ?? entry.kind}`;
|
|
104
|
+
const text = entry.statusText;
|
|
105
|
+
const candidates: string[] = [];
|
|
106
|
+
if (entry.state === "blocked") {
|
|
107
|
+
const age = stateAge(entry.since, nowMs);
|
|
108
|
+
const blocked = age ? `${base} · blocked ${age}` : `${base} · blocked`;
|
|
109
|
+
if (text) candidates.push(`${blocked} · ${text}`);
|
|
110
|
+
candidates.push(blocked);
|
|
111
|
+
if (age) candidates.push(`${base} · blocked`);
|
|
112
|
+
} else if (text) {
|
|
113
|
+
candidates.push(`${base} · ${text}`);
|
|
114
|
+
}
|
|
115
|
+
candidates.push(base, glyph);
|
|
116
|
+
for (const c of candidates) {
|
|
117
|
+
if (c.length <= budget) return c;
|
|
118
|
+
// A status-text candidate re-fits by truncating its text before giving up.
|
|
119
|
+
if (text && c.endsWith(` · ${text}`)) {
|
|
120
|
+
const head = c.slice(0, c.length - text.length);
|
|
121
|
+
const room = budget - head.length;
|
|
122
|
+
if (room >= MIN_STATUS_TEXT) return head + text.slice(0, room - 1) + "…";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent lifecycle verbs — the PURE model (M23.1). The app can finally MANAGE
|
|
3
|
+
* the fleet it watches: spawn a new agent (home chips / palette / sidebar
|
|
4
|
+
* session menu / Terminal split), restart one, stop one, close its pane. The
|
|
5
|
+
* kind list, the kind→launch-command map, the placement rows, and the exact
|
|
6
|
+
* tmux argv every verb runs all live here so they unit-test without OpenTUI;
|
|
7
|
+
* app.tsx supplies the dialog flows and the async execFile calls.
|
|
8
|
+
*
|
|
9
|
+
* KIND SOURCING: the detection manifests (manifest-loader) are the single
|
|
10
|
+
* source of WHICH kinds exist — a user override manifest automatically shows
|
|
11
|
+
* up in the "New agent" picker. Manifests carry `commands` (process names to
|
|
12
|
+
* MATCH, e.g. `codex.exe`), not a command to LAUNCH, so the launch command is
|
|
13
|
+
* a small map here keyed by manifest id, falling back to the manifest's first
|
|
14
|
+
* command token (right for every current bundle and for user overrides).
|
|
15
|
+
*
|
|
16
|
+
* CONTRACT HYGIENE: the claude integration's SessionEnd hook stamps
|
|
17
|
+
* `@agent_state idle:<epoch>` when the agent exits on its own. When WE kill it
|
|
18
|
+
* out-of-band (stop/restart) no hook fires, and a `working`/`blocked` stamp
|
|
19
|
+
* would keep lying for AUTHORITY_STALE_SECONDS (10 min) before the staleness
|
|
20
|
+
* guard falls back to scraping. So stop/restart UNSET the pane's authority
|
|
21
|
+
* options (`@agent_state`, `@agent_session_id`) — the same end state as a
|
|
22
|
+
* clean exit, without inventing a fake idle epoch.
|
|
23
|
+
*/
|
|
24
|
+
import type { AgentManifest } from "../detect/manifest.ts";
|
|
25
|
+
import type { DialogRowAction, DialogSelectItem } from "./dialog-model.ts";
|
|
26
|
+
import { agentAgeLabel, agentDisplayKind, type AgentRowInput } from "./agent-rows.ts";
|
|
27
|
+
|
|
28
|
+
/** The "Custom command…" picker row — resolves to a DialogPrompt, not a kind. */
|
|
29
|
+
export const CUSTOM_KIND_ID = "custom-command";
|
|
30
|
+
|
|
31
|
+
/** The kind picker's front-loaded repeat row (M24.1) — pre-selected, so a
|
|
32
|
+
* repeat spawn is Enter·Enter from the palette. */
|
|
33
|
+
export const AGAIN_ID = "again";
|
|
34
|
+
|
|
35
|
+
/** Prefix for the custom-command RECENTS rows beneath "Custom command…". */
|
|
36
|
+
const CUSTOM_RECENT_PREFIX = "custom-recent:";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Launch command per manifest id. Manifest `commands` are process-match
|
|
40
|
+
* tokens; this is what actually gets typed/executed to START the agent. Only
|
|
41
|
+
* ids whose launch spelling differs from (or shouldn't trust) the first match
|
|
42
|
+
* token need an entry — {@link launchCommandFor} falls back for the rest.
|
|
43
|
+
*/
|
|
44
|
+
export const AGENT_LAUNCH_COMMANDS: Record<string, string> = {
|
|
45
|
+
claude: "claude",
|
|
46
|
+
codex: "codex",
|
|
47
|
+
opencode: "opencode",
|
|
48
|
+
gemini: "gemini",
|
|
49
|
+
aider: "aider",
|
|
50
|
+
copilot: "copilot",
|
|
51
|
+
cursor: "cursor-agent",
|
|
52
|
+
goose: "goose",
|
|
53
|
+
amp: "amp",
|
|
54
|
+
// M25.4 breadth — real installable CLIs only (spawn-picker honesty), each
|
|
55
|
+
// verified against an installer/package: devin (curl cli.devin.ai), kimi
|
|
56
|
+
// (curl code.kimi.com), pi (npm @mariozechner/pi-coding-agent), grok (npm
|
|
57
|
+
// @vibe-kit/grok-cli), kiro (curl cli.kiro.dev → kiro-cli), cline (npm
|
|
58
|
+
// cline), droid (verified live — Factory CLI, own process), kilo (npm
|
|
59
|
+
// @kilocode/cli; the manifest matches its ".kilo" platform binary too).
|
|
60
|
+
devin: "devin",
|
|
61
|
+
kimi: "kimi",
|
|
62
|
+
pi: "pi",
|
|
63
|
+
grok: "grok",
|
|
64
|
+
kiro: "kiro-cli",
|
|
65
|
+
cline: "cline",
|
|
66
|
+
droid: "droid",
|
|
67
|
+
kilo: "kilo",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* PURE — the command that launches an agent of `kind`: the map entry when we
|
|
72
|
+
* ship one, else the kind's manifest's first `commands` token (user-override
|
|
73
|
+
* manifests land here), else the kind itself.
|
|
74
|
+
*/
|
|
75
|
+
export function launchCommandFor(kind: string, manifests: readonly AgentManifest[]): string {
|
|
76
|
+
const mapped = AGENT_LAUNCH_COMMANDS[kind];
|
|
77
|
+
if (mapped) return mapped;
|
|
78
|
+
const m = manifests.find((x) => x.id === kind);
|
|
79
|
+
return m?.commands[0] ?? kind;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* PURE — the "New agent" picker rows: one per manifest id (the `shell`
|
|
84
|
+
* catch-all is not an agent and is excluded), detail showing the command that
|
|
85
|
+
* will run, then the "Custom command…" escape hatch last.
|
|
86
|
+
*/
|
|
87
|
+
export function agentKindItems(manifests: readonly AgentManifest[]): DialogSelectItem[] {
|
|
88
|
+
const items: DialogSelectItem[] = manifests
|
|
89
|
+
.filter((m) => m.id !== "shell")
|
|
90
|
+
.map((m) => ({ id: m.id, label: m.id, detail: launchCommandFor(m.id, manifests) }));
|
|
91
|
+
items.push({ id: CUSTOM_KIND_ID, label: "Custom command…", detail: "type your own" });
|
|
92
|
+
return items;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Where a spawned agent lands, relative to the target session/pane. */
|
|
96
|
+
export type SpawnPlacement = "window" | "split-h" | "split-v";
|
|
97
|
+
|
|
98
|
+
/** Every place a spawn can land — the pane placements plus a fresh detached
|
|
99
|
+
* session (home project rows). The "again" memory remembers one of these. */
|
|
100
|
+
export type SpawnWhere = SpawnPlacement | "session";
|
|
101
|
+
|
|
102
|
+
const SPAWN_WHERES: readonly SpawnWhere[] = ["window", "split-h", "split-v", "session"];
|
|
103
|
+
|
|
104
|
+
/** PURE — is `x` a persistable spawn placement? (app-state sanitizing). */
|
|
105
|
+
export function isSpawnWhere(x: unknown): x is SpawnWhere {
|
|
106
|
+
return typeof x === "string" && (SPAWN_WHERES as readonly string[]).includes(x);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One remembered spawn — enough to repeat it exactly (M24.1). Persisted per
|
|
110
|
+
* project/session-dir in app-state; `command` carries custom argv verbatim. */
|
|
111
|
+
export interface LastSpawn {
|
|
112
|
+
/** Manifest id, or {@link CUSTOM_KIND_ID} for a typed command. */
|
|
113
|
+
kind: string;
|
|
114
|
+
/** The exact command the spawn ran (the resolved launch command / custom argv). */
|
|
115
|
+
command: string;
|
|
116
|
+
/** Where it landed. */
|
|
117
|
+
placement: SpawnWhere;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What the spawn flow knows about its entry point: is there a concrete pane
|
|
121
|
+
* to split (Terminal surface), and is there a live session at all? */
|
|
122
|
+
export interface SpawnContextShape {
|
|
123
|
+
pane: boolean;
|
|
124
|
+
session: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* PURE — where a spawn lands when the user just presses Enter (M24.1: the flow
|
|
129
|
+
* never ASKS where). Terminal surface (a focused pane exists) → split right of
|
|
130
|
+
* it; a live session without a concrete pane (home/sidebar session rows) → a
|
|
131
|
+
* new window in it; no session (home project rows) → a fresh detached session.
|
|
132
|
+
*/
|
|
133
|
+
export function defaultSpawnPlacement(ctx: SpawnContextShape): SpawnWhere {
|
|
134
|
+
if (ctx.pane) return "split-h";
|
|
135
|
+
if (ctx.session) return "window";
|
|
136
|
+
return "session";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** PURE — can a remembered placement replay in this context? Splits need a
|
|
140
|
+
* concrete pane; window needs a live session; a fresh session always can. */
|
|
141
|
+
export function compatiblePlacement(placement: SpawnWhere, ctx: SpawnContextShape): boolean {
|
|
142
|
+
if (placement === "session") return true;
|
|
143
|
+
if (placement === "window") return ctx.session;
|
|
144
|
+
return ctx.pane;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** PURE — plain language for a placement (footer hints, the again row). */
|
|
148
|
+
export function placementLabel(placement: SpawnWhere): string {
|
|
149
|
+
if (placement === "split-h") return "split right";
|
|
150
|
+
if (placement === "split-v") return "split below";
|
|
151
|
+
if (placement === "window") return "new window";
|
|
152
|
+
return "new session";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* PURE — the kind picker's footer ACTIONS: placement ALTERNATIVES as ctrl-key
|
|
157
|
+
* chords, never a second dialog (M24.1). Offered only where they differ from
|
|
158
|
+
* the default and are honest in this context: with a focused pane the default
|
|
159
|
+
* is split-right, so ^w (new window) and ^d (split below) are the escapes; a
|
|
160
|
+
* session-only context defaults to a new window with nothing else honest to
|
|
161
|
+
* offer; no session means a fresh one — no alternatives.
|
|
162
|
+
*/
|
|
163
|
+
export function placementActions(ctx: SpawnContextShape): DialogRowAction[] {
|
|
164
|
+
if (ctx.pane) {
|
|
165
|
+
return [
|
|
166
|
+
{ key: "w", label: "in a new window" },
|
|
167
|
+
{ key: "d", label: "split below" },
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** PURE — the placement a dialog result maps to: an action key overrides the
|
|
174
|
+
* default (`^w` → window, `^d` → split below); Enter keeps the default. */
|
|
175
|
+
export function resolvePlacement(fallback: SpawnWhere, actionKey?: string): SpawnWhere {
|
|
176
|
+
if (actionKey === "w") return "window";
|
|
177
|
+
if (actionKey === "d") return "split-v";
|
|
178
|
+
return fallback;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** PURE — a custom-recent row id for `index` into the recents list. */
|
|
182
|
+
export function customRecentId(index: number): string {
|
|
183
|
+
return `${CUSTOM_RECENT_PREFIX}${index}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** PURE — the recents index a row id encodes, or null for any other row. */
|
|
187
|
+
export function customRecentIndex(id: string): number | null {
|
|
188
|
+
if (!id.startsWith(CUSTOM_RECENT_PREFIX)) return null;
|
|
189
|
+
const n = Number(id.slice(CUSTOM_RECENT_PREFIX.length));
|
|
190
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** PURE — what a remembered spawn is called: the kind, or the custom argv
|
|
194
|
+
* verbatim (labels for the again row + the palette's again action). */
|
|
195
|
+
export function lastSpawnName(last: LastSpawn): string {
|
|
196
|
+
return last.kind === CUSTOM_KIND_ID ? last.command : last.kind;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* PURE — the ONE dialog's rows (M24.1): the "again" repeat row FIRST (when a
|
|
201
|
+
* spawn is remembered for this context — pre-selected, so repeat = Enter), then
|
|
202
|
+
* the manifest kinds, then "Custom command…", then the recent custom commands
|
|
203
|
+
* as directly selectable rows beneath it. The again row names what it repeats
|
|
204
|
+
* and where it will land.
|
|
205
|
+
*/
|
|
206
|
+
export function newAgentItems(opts: {
|
|
207
|
+
manifests: readonly AgentManifest[];
|
|
208
|
+
last: LastSpawn | null;
|
|
209
|
+
/** The placement the again row will actually use (memory, context-checked). */
|
|
210
|
+
againPlacement?: SpawnWhere;
|
|
211
|
+
customRecents: readonly string[];
|
|
212
|
+
}): DialogSelectItem[] {
|
|
213
|
+
const items: DialogSelectItem[] = [];
|
|
214
|
+
if (opts.last) {
|
|
215
|
+
const where = opts.againPlacement ?? opts.last.placement;
|
|
216
|
+
items.push({
|
|
217
|
+
id: AGAIN_ID,
|
|
218
|
+
label: `${lastSpawnName(opts.last)} — again`,
|
|
219
|
+
detail: placementLabel(where),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
items.push(...agentKindItems(opts.manifests));
|
|
223
|
+
opts.customRecents.forEach((cmd, i) => {
|
|
224
|
+
items.push({ id: customRecentId(i), label: cmd, detail: "recent" });
|
|
225
|
+
});
|
|
226
|
+
return items;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** PURE — the label a spawned pane/window gets: the kind, or a custom
|
|
230
|
+
* command's first token stripped to its basename (`/us/bin/my-agent -x` →
|
|
231
|
+
* `my-agent`). Empty input falls back to "agent". */
|
|
232
|
+
export function spawnLabelFor(kind: string, command: string): string {
|
|
233
|
+
if (kind !== CUSTOM_KIND_ID) return kind;
|
|
234
|
+
const first = command.trim().split(/\s+/)[0] ?? "";
|
|
235
|
+
const base = first.split("/").pop() ?? "";
|
|
236
|
+
return base.length > 0 ? base : "agent";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The spawn target: the owning session, plus the concrete pane for splits. */
|
|
240
|
+
export interface SpawnTarget {
|
|
241
|
+
session: string;
|
|
242
|
+
/** Required for `split-h`/`split-v`; ignored for `window`. */
|
|
243
|
+
paneId?: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** `-P -F` — every spawn PRINTS its new pane id, so the flow can label the
|
|
247
|
+
* pane/window and stamp `@agent_launch` without a lookup race. */
|
|
248
|
+
const PRINT_PANE_ID = ["-P", "-F", "#{pane_id}"];
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* PURE — the tmux argv that spawns `command` at `placement`. New windows
|
|
252
|
+
* target `<session>:` (tmux appends); splits target the pane. `dir` becomes
|
|
253
|
+
* `-c` when known. The command is passed as tmux's shell-command argument —
|
|
254
|
+
* a bare binary is exec'd directly, so `pane_current_command` is the agent
|
|
255
|
+
* itself and detection picks it up with no extra wiring. `-P -F "#{pane_id}"`
|
|
256
|
+
* prints the spawned pane's id (M24.1 — the label/stamp follow-ups target it).
|
|
257
|
+
*/
|
|
258
|
+
export function spawnAgentArgs(
|
|
259
|
+
placement: SpawnPlacement,
|
|
260
|
+
target: SpawnTarget,
|
|
261
|
+
dir: string | null,
|
|
262
|
+
command: string,
|
|
263
|
+
): string[] {
|
|
264
|
+
const cd = dir ? ["-c", dir] : [];
|
|
265
|
+
if (placement === "window") {
|
|
266
|
+
return ["new-window", "-t", `${target.session}:`, ...PRINT_PANE_ID, ...cd, command];
|
|
267
|
+
}
|
|
268
|
+
const flag = placement === "split-h" ? "-h" : "-v";
|
|
269
|
+
return [
|
|
270
|
+
"split-window",
|
|
271
|
+
flag,
|
|
272
|
+
"-t",
|
|
273
|
+
target.paneId ?? `${target.session}:`,
|
|
274
|
+
...PRINT_PANE_ID,
|
|
275
|
+
...cd,
|
|
276
|
+
command,
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* PURE — the tmux argv that creates a fresh detached session running
|
|
282
|
+
* `command` (the home PROJECT-row spawn: no live session exists yet, so the
|
|
283
|
+
* agent gets one, named for the project). Prints the new pane id like
|
|
284
|
+
* {@link spawnAgentArgs}.
|
|
285
|
+
*/
|
|
286
|
+
export function spawnSessionArgs(name: string, dir: string | null, command: string): string[] {
|
|
287
|
+
return ["new-session", "-d", "-s", name, ...PRINT_PANE_ID, ...(dir ? ["-c", dir] : []), command];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** PURE — title the spawned pane after its agent (`select-pane -T`), so the
|
|
291
|
+
* pane is named without the user being asked (M24.1 auto-label). */
|
|
292
|
+
export function labelPaneArgs(paneId: string, label: string): string[] {
|
|
293
|
+
return ["select-pane", "-t", paneId, "-T", label];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** PURE — name a spawned WINDOW after its agent. Targets the spawned pane's
|
|
297
|
+
* id, which tmux resolves to the window that holds it. */
|
|
298
|
+
export function labelWindowArgs(paneId: string, label: string): string[] {
|
|
299
|
+
return ["rename-window", "-t", paneId, label];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** PURE — stamp the spawned pane with the exact command that launched it
|
|
303
|
+
* (`@agent_launch`, pane-local) — restart's preferred relaunch source (better
|
|
304
|
+
* than `pane_start_command`, which tmux rewrites for respawned panes). */
|
|
305
|
+
export function stampLaunchArgs(paneId: string, command: string): string[] {
|
|
306
|
+
return ["set-option", "-p", "-t", paneId, "@agent_launch", command];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── The team dialog (M24.1 — "manage your team" in one surface) ─────────────
|
|
310
|
+
|
|
311
|
+
/** The team dialog's pinned "+ new agent" row id. */
|
|
312
|
+
export const TEAM_NEW_ID = "team-new";
|
|
313
|
+
|
|
314
|
+
/** Prefix for the team dialog's per-agent rows: `agent:<index>` into the
|
|
315
|
+
* caller's (already sorted) fleet agent list. */
|
|
316
|
+
const TEAM_AGENT_PREFIX = "agent:";
|
|
317
|
+
|
|
318
|
+
/** PURE — a team row id for `index` into the fleet agent list. */
|
|
319
|
+
export function teamAgentId(index: number): string {
|
|
320
|
+
return `${TEAM_AGENT_PREFIX}${index}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** PURE — the fleet-agent index a team row id encodes, or null. */
|
|
324
|
+
export function teamAgentIndex(id: string): number | null {
|
|
325
|
+
if (!id.startsWith(TEAM_AGENT_PREFIX)) return null;
|
|
326
|
+
const n = Number(id.slice(TEAM_AGENT_PREFIX.length));
|
|
327
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* PURE — the Team dialog rows: a pinned "+ new agent" first (the same one-
|
|
332
|
+
* dialog kind picker), then one row per fleet agent — "<kind> · <session>",
|
|
333
|
+
* detail its state (+ dwell when stamped). Enter/click jumps; the footer's
|
|
334
|
+
* ctrl-actions (restart/stop) ride the DialogRowAction channel.
|
|
335
|
+
*/
|
|
336
|
+
export function teamItems(agents: readonly AgentRowInput[], nowSec: number): DialogSelectItem[] {
|
|
337
|
+
const items: DialogSelectItem[] = [
|
|
338
|
+
{ id: TEAM_NEW_ID, label: "+ new agent", detail: "kind picker" },
|
|
339
|
+
];
|
|
340
|
+
agents.forEach((a, i) => {
|
|
341
|
+
items.push({
|
|
342
|
+
id: teamAgentId(i),
|
|
343
|
+
// Display-name precedence (M25.4): the Team dialog names an agent the
|
|
344
|
+
// same way the sidebar rows do.
|
|
345
|
+
label: `${agentDisplayKind(a)} · ${a.session}`,
|
|
346
|
+
detail: agentAgeLabel(a.state, a.since, nowSec) ?? a.state,
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
return items;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The Team dialog's footer ctrl-actions (restart/stop the selected agent). */
|
|
353
|
+
export const TEAM_ACTIONS: DialogRowAction[] = [
|
|
354
|
+
{ key: "r", label: "restart" },
|
|
355
|
+
{ key: "s", label: "stop" },
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
/** Interactive shells beyond the shell manifest's `commands` — a login shell
|
|
359
|
+
* hosting an agent can surface as any of these in `pane_current_command`. */
|
|
360
|
+
const EXTRA_SHELLS = ["dash", "ksh", "tcsh", "csh"];
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* PURE — is `command` an interactive shell? Login-shell dashes and paths are
|
|
364
|
+
* stripped (`-zsh`, `/bin/zsh` → `zsh`); sourced from the `shell` manifest's
|
|
365
|
+
* command list plus a few shells it doesn't track.
|
|
366
|
+
*/
|
|
367
|
+
export function isShellCommand(command: string, manifests: readonly AgentManifest[]): boolean {
|
|
368
|
+
const name = command.replace(/^-/, "").split("/").pop() ?? command;
|
|
369
|
+
const shell = manifests.find((m) => m.id === "shell");
|
|
370
|
+
return [...(shell?.commands ?? []), ...EXTRA_SHELLS].includes(name);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* PURE — does the pane HOST A SHELL underneath whatever runs in it? Decides
|
|
375
|
+
* the RESTART strategy: shell-hosted agents are ctrl-c'd and relaunched via
|
|
376
|
+
* send-keys (the shell survives to type into); an agent that IS the pane's
|
|
377
|
+
* own process (our spawn verb's panes) has no shell underneath — ctrl-c would
|
|
378
|
+
* end the pane — so it is respawned in place instead.
|
|
379
|
+
*
|
|
380
|
+
* The input is `#{pane_start_command}`, NOT `pane_current_command`: the
|
|
381
|
+
* current command reflects the FOREGROUND process, so a user-typed `claude`
|
|
382
|
+
* under zsh reads as `claude` too — indistinguishable from a pane-command
|
|
383
|
+
* agent (measured). The start command is the pane's ROOT: empty means tmux's
|
|
384
|
+
* default shell (every plain user pane), a shell word means an explicit shell
|
|
385
|
+
* pane, anything else means the pane dies with that process.
|
|
386
|
+
*/
|
|
387
|
+
export function paneHostsShell(startCommand: string, manifests: readonly AgentManifest[]): boolean {
|
|
388
|
+
const first = startCommand.trim().split(/\s+/)[0] ?? "";
|
|
389
|
+
if (first.length === 0) return true; // default-shell pane
|
|
390
|
+
return isShellCommand(first, manifests);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* PURE — the tmux argv that relaunches `command` as the pane's own process:
|
|
395
|
+
* `respawn-pane -k` kills what runs there and restarts IN PLACE — same pane
|
|
396
|
+
* id, same geometry. `dir` rides as an explicit `-c` (the flow reads the
|
|
397
|
+
* pane's current path first) so the cwd is preserved on every tmux version.
|
|
398
|
+
*/
|
|
399
|
+
export function respawnArgs(paneId: string, command: string, dir: string | null): string[] {
|
|
400
|
+
return ["respawn-pane", "-k", "-t", paneId, ...(dir ? ["-c", dir] : []), command];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** PURE — one interrupt (ctrl-c) to the pane. Sent TWICE by the flows: TUI
|
|
404
|
+
* agents (claude, codex) treat a single ctrl-c as "clear input / cancel turn"
|
|
405
|
+
* and only a quick second one as "exit"; a plain process ignores the repeat. */
|
|
406
|
+
export function interruptArgs(paneId: string): string[] {
|
|
407
|
+
return ["send-keys", "-t", paneId, "C-c"];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** PURE — relaunch `command` in the pane's (still-running) shell: the literal
|
|
411
|
+
* command text, then Enter as a key. Two calls — `-l` must not eat "Enter". */
|
|
412
|
+
export function relaunchArgs(paneId: string, command: string): string[][] {
|
|
413
|
+
return [
|
|
414
|
+
["send-keys", "-t", paneId, "-l", command],
|
|
415
|
+
["send-keys", "-t", paneId, "Enter"],
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* PURE — unset the pane's authority stamps after an out-of-band stop/restart
|
|
421
|
+
* (see the header: no hook fires, so a stale `working:<epoch>` would lie for
|
|
422
|
+
* 10 minutes). `-u` on a pane-local (`-p`) option removes it, which is exactly
|
|
423
|
+
* the "no authority present" state the classifier falls back to scraping from.
|
|
424
|
+
*/
|
|
425
|
+
export function clearAuthorityArgs(paneId: string): string[][] {
|
|
426
|
+
return [
|
|
427
|
+
["set-option", "-p", "-t", paneId, "-u", "@agent_state"],
|
|
428
|
+
["set-option", "-p", "-t", paneId, "-u", "@agent_session_id"],
|
|
429
|
+
];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Gap between the two interrupts — inside claude/codex's "press again to
|
|
433
|
+
* exit" window, long enough for the first ^c to be processed. */
|
|
434
|
+
export const INTERRUPT_TAP_GAP_MS = 250;
|
|
435
|
+
/** Grace after the interrupts before relaunching in the same pane — lets the
|
|
436
|
+
* agent process die and its shell repaint the prompt. */
|
|
437
|
+
export const RESTART_GRACE_MS = 1000;
|