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.
- package/README.md +22 -5
- package/bin/cli.js +3532 -1090
- package/bin/cli.ts +368 -71
- package/package.json +2 -1
- 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/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/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/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 +103 -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/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 +582 -84
- package/packages/daemon/src/tui/chrome/updater.ts +268 -62
- package/packages/daemon/src/tui/detect/classify.ts +34 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
- package/packages/daemon/src/tui/detect/manifest.ts +24 -3
- package/packages/daemon/src/tui/detect/manifests.ts +240 -6
- package/packages/daemon/src/tui/detect/process-tree.ts +13 -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/mirror/agent-chip.ts +40 -11
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
- package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
- package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
- package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
- package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
- 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/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
- package/packages/daemon/src/tui/mirror/palette.ts +299 -9
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
- package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
- package/packages/daemon/src/tui/mirror/selection.ts +122 -8
- package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
- package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/sessions.ts +85 -7
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -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 +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 ·
|
|
73
|
-
* `● claude · blocked`
|
|
74
|
-
* `● claude`
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
-
|
|
92
|
-
candidates.push(`${
|
|
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)
|
|
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.
|
|
107
|
-
*
|
|
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
|
|
121
|
-
* `
|
|
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
|
|
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
|
}
|