tmux-ide 2.7.0 → 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 (101) hide show
  1. package/README.md +22 -5
  2. package/bin/cli.js +3532 -1090
  3. package/bin/cli.ts +368 -71
  4. package/package.json +2 -1
  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/control/client.d.ts +23 -0
  9. package/packages/daemon/dist/control/client.js +105 -0
  10. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  11. package/packages/daemon/dist/control/dispatch.js +83 -0
  12. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  13. package/packages/daemon/dist/control/fanout.js +37 -0
  14. package/packages/daemon/dist/control/frames.d.ts +23 -0
  15. package/packages/daemon/dist/control/frames.js +37 -0
  16. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  17. package/packages/daemon/dist/control/lifecycle.js +114 -0
  18. package/packages/daemon/dist/control/server.d.ts +16 -0
  19. package/packages/daemon/dist/control/server.js +214 -0
  20. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  21. package/packages/daemon/dist/control/verbs.js +91 -0
  22. package/packages/daemon/dist/doctor.d.ts +18 -0
  23. package/packages/daemon/dist/doctor.js +105 -15
  24. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  25. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  26. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  27. package/packages/daemon/dist/lib/app-config.js +104 -5
  28. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  29. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  30. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  31. package/packages/daemon/dist/lib/state-home.js +12 -0
  32. package/packages/daemon/dist/lib/update-check.js +5 -0
  33. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  34. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  35. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  36. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  39. package/packages/daemon/dist/restore.d.ts +35 -8
  40. package/packages/daemon/dist/restore.js +52 -15
  41. package/packages/daemon/dist/send.d.ts +33 -1
  42. package/packages/daemon/dist/send.js +32 -19
  43. package/packages/daemon/src/control/client.ts +128 -0
  44. package/packages/daemon/src/control/dispatch.ts +107 -0
  45. package/packages/daemon/src/control/fanout.ts +44 -0
  46. package/packages/daemon/src/control/frames.ts +40 -0
  47. package/packages/daemon/src/control/lifecycle.ts +151 -0
  48. package/packages/daemon/src/control/server.ts +237 -0
  49. package/packages/daemon/src/control/verbs.ts +118 -0
  50. package/packages/daemon/src/doctor.ts +113 -28
  51. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  52. package/packages/daemon/src/lib/app-config.ts +103 -5
  53. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  54. package/packages/daemon/src/lib/state-home.ts +13 -0
  55. package/packages/daemon/src/lib/update-check.ts +5 -0
  56. package/packages/daemon/src/restore.ts +53 -15
  57. package/packages/daemon/src/send.ts +55 -21
  58. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  59. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  60. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  61. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  62. package/packages/daemon/src/tui/chrome/notify.ts +582 -84
  63. package/packages/daemon/src/tui/chrome/updater.ts +268 -62
  64. package/packages/daemon/src/tui/detect/classify.ts +34 -0
  65. package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
  66. package/packages/daemon/src/tui/detect/manifest.ts +24 -3
  67. package/packages/daemon/src/tui/detect/manifests.ts +240 -6
  68. package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
  69. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  70. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  71. package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
  72. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  73. package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
  74. package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
  75. package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
  76. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  77. package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
  78. package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
  79. package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
  80. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  81. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  82. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  83. package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
  84. package/packages/daemon/src/tui/mirror/palette.ts +299 -9
  85. package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
  86. package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
  87. package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
  88. package/packages/daemon/src/tui/mirror/selection.ts +122 -8
  89. package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
  90. package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
  91. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  92. package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
  93. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  94. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  95. package/packages/daemon/src/tui/team/sessions.ts +85 -7
  96. package/packages/daemon/src/tui/team/wait.ts +144 -0
  97. package/scripts/build-macos-notifier.mjs +160 -0
  98. package/scripts/postinstall.js +8 -1
  99. package/scripts/prepublish-check.mjs +37 -1
  100. package/scripts/publish-tap.sh +55 -0
  101. package/skill/SKILL.md +88 -2
@@ -30,6 +30,13 @@ export interface ChipAgent {
30
30
  /** Authority epoch (SECONDS) when the authority layer supplied the state;
31
31
  * null for scraped panes. Feeds the "blocked 4m" age suffix. */
32
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;
33
40
  }
34
41
 
35
42
  /**
@@ -64,19 +71,28 @@ export function stateAge(since: number | null, nowMs: number): string | null {
64
71
  return `${Math.floor(s / 86400)}d`;
65
72
  }
66
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
+
67
78
  /**
68
79
  * Build a chip label that fits `budget` cells (the printable text, WITHOUT the
69
80
  * one-cell padding the renderer adds each side). Degrades in steps rather than
70
81
  * mid-word ellipsis so a narrow pane still reads:
71
82
  *
72
- * `● claude · blocked 4m` blocked, authority age known
73
- * `● claude · blocked` blocked, scraped (no stamp)
74
- * `● claude` any other state (color carries the state)
75
- * `●` narrow pane
76
- * null no room at all (or a hidden-worthy budget < 1)
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)
77
89
  *
78
- * Only `blocked` spells its state out — it is the attention state; everything
79
- * else leans on the glyph color, matching the dock chips' quiet default.
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.
80
96
  */
81
97
  export function chipLabel(
82
98
  entry: ChipAgent,
@@ -84,14 +100,27 @@ export function chipLabel(
84
100
  nowMs: number,
85
101
  budget: number,
86
102
  ): string | null {
87
- const base = `${glyph} ${entry.kind}`;
103
+ const base = `${glyph} ${entry.displayName ?? entry.kind}`;
104
+ const text = entry.statusText;
88
105
  const candidates: string[] = [];
89
106
  if (entry.state === "blocked") {
90
107
  const age = stateAge(entry.since, nowMs);
91
- if (age) candidates.push(`${base} · blocked ${age}`);
92
- candidates.push(`${base} · blocked`);
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}`);
93
114
  }
94
115
  candidates.push(base, glyph);
95
- for (const c of candidates) if (c.length <= budget) return c;
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
+ }
96
125
  return null;
97
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;
@@ -30,6 +30,19 @@ export interface AgentRowInput {
30
30
  state: AgentStatus;
31
31
  /** Authority state's epoch-seconds stamp, or null for a scraped pane. */
32
32
  since: number | null;
33
+ /** Self-reported one-liner (`@agent_status_text`, sanitized by the report;
34
+ * dropped with a stale authority stamp). Rides to the pane chips (M25.4). */
35
+ statusText?: string;
36
+ /** Self-reported display name (`@agent_display_name`) — row-label precedence
37
+ * over the detected `kind` ({@link agentDisplayKind}). */
38
+ displayName?: string;
39
+ }
40
+
41
+ /** PURE — what an agent row is CALLED: the self-reported display name when one
42
+ * is stamped (and fresh — the report already applied staleness), else the
43
+ * detected kind. The one precedence rule every label surface shares. */
44
+ export function agentDisplayKind(a: Pick<AgentRowInput, "kind" | "displayName">): string {
45
+ return a.displayName ?? a.kind;
33
46
  }
34
47
 
35
48
  /** Attention-first rank: blocked, working, done, idle (from `ROLLUP_ORDER`), then
@@ -78,6 +91,11 @@ export function agentsHeaderLabel(count: number, maxChars: number): string {
78
91
  /** The quiet empty-state line shown under the header when no agents are running. */
79
92
  export const AGENTS_EMPTY_LINE = "no agents running — panes running claude or codex show up here";
80
93
 
94
+ /** The always-present spawn chip on the AGENTS header row (and the empty-state
95
+ * row) — THE discoverable "start a new agent" entry (M24.1). Right-aligned;
96
+ * the render and the router share its span via `spansFromRight`. */
97
+ export const AGENTS_ADD_CHIP = "[+ agent]";
98
+
81
99
  /**
82
100
  * PURE — a compact state-age like `"blocked 4m"` from a `since` epoch-seconds
83
101
  * stamp, for the hovered row. Returns null when there is no timestamp (a scraped
@@ -103,12 +121,15 @@ export function agentAgeLabel(
103
121
  * agree on the row the header sits on — a mismatch lands clicks one row off. */
104
122
  export const AGENTS_GAP_ROWS = 1;
105
123
 
106
- /** What a sidebar content row (a tab-bar-adjusted `gy`) targets. `agents-header`,
107
- * the gap row(s), and the empty-state row are inert (the router does nothing). */
124
+ /** What a sidebar content row (a tab-bar-adjusted `gy`) targets. The gap
125
+ * row(s) are inert; `agents-header` opens the Team dialog (its right-aligned
126
+ * `[+ agent]` chip spawns — the router x-tests the chip span first), and
127
+ * `agents-empty` is the empty-state row (inert except its chip). */
108
128
  export type SidebarHit =
109
129
  | { kind: "session"; index: number }
110
130
  | { kind: "agent"; index: number }
111
131
  | { kind: "agents-header" }
132
+ | { kind: "agents-empty" }
112
133
  | null;
113
134
 
114
135
  /**
@@ -117,8 +138,8 @@ export type SidebarHit =
117
138
  * never land where a row isn't drawn. Layout, top to bottom: `gy 0` title,
118
139
  * `gy 1` rule, then `sessionCount` session rows from `gy 2`, then
119
140
  * `AGENTS_GAP_ROWS` blank row(s), then the agents section — one header row, then
120
- * `agentCount` agent rows (or a single inert empty-state row when
121
- * `agentCount === 0`).
141
+ * `agentCount` agent rows (or a single empty-state row when `agentCount === 0`,
142
+ * hit-tested as `agents-empty` so its `[+ agent]` chip stays clickable).
122
143
  */
123
144
  export function sidebarHit(gy: number, sessionCount: number, agentCount: number): SidebarHit {
124
145
  if (gy < 2) return null;
@@ -128,6 +149,7 @@ export function sidebarHit(gy: number, sessionCount: number, agentCount: number)
128
149
  if (gy < headerGy) return null; // the gap row(s) between sessions and agents
129
150
  if (gy === headerGy) return { kind: "agents-header" };
130
151
  const ai = gy - headerGy - 1;
131
- if (agentCount === 0 || ai < 0 || ai >= agentCount) return null;
152
+ if (agentCount === 0) return ai === 0 ? { kind: "agents-empty" } : null;
153
+ if (ai < 0 || ai >= agentCount) return null;
132
154
  return { kind: "agent", index: ai };
133
155
  }