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.
Files changed (154) hide show
  1. package/README.md +36 -14
  2. package/bin/cli.js +4174 -1235
  3. package/bin/cli.ts +426 -72
  4. package/package.json +10 -6
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  9. package/packages/daemon/dist/agent-explain.js +19 -3
  10. package/packages/daemon/dist/control/client.d.ts +23 -0
  11. package/packages/daemon/dist/control/client.js +105 -0
  12. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  13. package/packages/daemon/dist/control/dispatch.js +83 -0
  14. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  15. package/packages/daemon/dist/control/fanout.js +37 -0
  16. package/packages/daemon/dist/control/frames.d.ts +23 -0
  17. package/packages/daemon/dist/control/frames.js +37 -0
  18. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  19. package/packages/daemon/dist/control/lifecycle.js +114 -0
  20. package/packages/daemon/dist/control/server.d.ts +16 -0
  21. package/packages/daemon/dist/control/server.js +214 -0
  22. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  23. package/packages/daemon/dist/control/verbs.js +91 -0
  24. package/packages/daemon/dist/doctor.d.ts +18 -0
  25. package/packages/daemon/dist/doctor.js +105 -15
  26. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  27. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  28. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  29. package/packages/daemon/dist/lib/app-config.js +104 -5
  30. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  31. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  32. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  33. package/packages/daemon/dist/lib/state-home.js +12 -0
  34. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  35. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  36. package/packages/daemon/dist/lib/update-check.js +5 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  43. package/packages/daemon/dist/restore.d.ts +35 -8
  44. package/packages/daemon/dist/restore.js +52 -15
  45. package/packages/daemon/dist/send.d.ts +33 -1
  46. package/packages/daemon/dist/send.js +32 -19
  47. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  48. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  49. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  50. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  51. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  52. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  53. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  54. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  55. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  56. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  57. package/packages/daemon/src/agent-explain.ts +34 -6
  58. package/packages/daemon/src/control/client.ts +128 -0
  59. package/packages/daemon/src/control/dispatch.ts +107 -0
  60. package/packages/daemon/src/control/fanout.ts +44 -0
  61. package/packages/daemon/src/control/frames.ts +40 -0
  62. package/packages/daemon/src/control/lifecycle.ts +151 -0
  63. package/packages/daemon/src/control/server.ts +237 -0
  64. package/packages/daemon/src/control/verbs.ts +118 -0
  65. package/packages/daemon/src/doctor.ts +113 -28
  66. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  67. package/packages/daemon/src/lib/app-config.ts +194 -5
  68. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  69. package/packages/daemon/src/lib/state-home.ts +13 -0
  70. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  71. package/packages/daemon/src/lib/update-check.ts +5 -0
  72. package/packages/daemon/src/restore.ts +53 -15
  73. package/packages/daemon/src/send.ts +55 -21
  74. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  75. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  76. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  77. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  78. package/packages/daemon/src/tui/chrome/notify.ts +737 -52
  79. package/packages/daemon/src/tui/chrome/updater.ts +318 -30
  80. package/packages/daemon/src/tui/compiled.ts +11 -3
  81. package/packages/daemon/src/tui/detect/classify.ts +49 -0
  82. package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
  83. package/packages/daemon/src/tui/detect/manifest.ts +39 -3
  84. package/packages/daemon/src/tui/detect/manifests.ts +395 -33
  85. package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
  86. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  87. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  88. package/packages/daemon/src/tui/main.ts +13 -1
  89. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  90. package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
  91. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  92. package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
  93. package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
  94. package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
  95. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  96. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  97. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  98. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  99. package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
  100. package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
  101. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  102. package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
  103. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  104. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  105. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  106. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  107. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  108. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  109. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  110. package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
  111. package/packages/daemon/src/tui/mirror/palette.ts +564 -0
  112. package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
  113. package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
  114. package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
  115. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  116. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  117. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  118. package/packages/daemon/src/tui/mirror/selection.ts +376 -0
  119. package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
  120. package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
  121. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  122. package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
  123. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  124. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  125. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  126. package/packages/daemon/src/tui/team/entry.ts +34 -7
  127. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  128. package/packages/daemon/src/tui/team/report.ts +11 -1
  129. package/packages/daemon/src/tui/team/sessions.ts +184 -17
  130. package/packages/daemon/src/tui/team/wait.ts +144 -0
  131. package/scripts/build-macos-notifier.mjs +160 -0
  132. package/scripts/build-tui.mjs +11 -4
  133. package/scripts/perf-mirror.mjs +313 -0
  134. package/scripts/postinstall.js +8 -1
  135. package/scripts/prepublish-check.mjs +37 -1
  136. package/scripts/publish-tap.sh +55 -0
  137. package/skill/SKILL.md +110 -2
  138. package/templates/AGENTS.md +14 -7
  139. package/templates/agent-team-monorepo.yml +8 -0
  140. package/templates/agent-team-nextjs.yml +8 -0
  141. package/templates/agent-team.yml +10 -0
  142. package/templates/convex.yml +2 -0
  143. package/templates/default.yml +11 -5
  144. package/templates/go.yml +4 -0
  145. package/templates/missions.yml +6 -0
  146. package/templates/nextjs.yml +4 -0
  147. package/templates/python.yml +4 -0
  148. package/templates/skills/backend.md +5 -12
  149. package/templates/skills/frontend.md +5 -12
  150. package/templates/skills/general-worker.md +5 -12
  151. package/templates/skills/researcher.md +7 -12
  152. package/templates/skills/reviewer.md +7 -16
  153. package/templates/vite.yml +4 -0
  154. 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;