tmux-ide 2.6.0 → 2.6.1
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/bin/cli.js +13 -5
- package/bin/cli.ts +1 -1
- package/bunfig.toml +4 -0
- package/package.json +11 -3
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/src/agent-explain.ts +270 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +336 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +195 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +428 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +113 -0
- package/packages/daemon/src/tui/detect/classify.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
- package/packages/daemon/src/tui/detect/manifest.ts +184 -0
- package/packages/daemon/src/tui/detect/manifests.ts +226 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +70 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +11 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +73 -0
- package/packages/daemon/src/tui/team/sessions.ts +344 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/postinstall.js +26 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled detection manifests for known agent commands.
|
|
3
|
+
*
|
|
4
|
+
* These encode CONSERVATIVE, hand-tuned heuristics — the textual evidence
|
|
5
|
+
* seen at the bottom of a pane when an agent is blocked (waiting on input) or
|
|
6
|
+
* working (streaming/spinning). `done` is genuinely hard to read from a single
|
|
7
|
+
* snapshot, so it is left to the classifier's seen-tracking; the rules here
|
|
8
|
+
* stay minimal. Treat every matcher below as a heuristic to tune, not a
|
|
9
|
+
* contract — edit freely as agent UIs change.
|
|
10
|
+
*
|
|
11
|
+
* EVIDENCE PROVENANCE. The `claude` and `codex` manifests are tuned against
|
|
12
|
+
* REAL captured screens (see `manifest-corpus.test.ts` for the sanitized
|
|
13
|
+
* fixtures). Each matcher cites the invariant it was built from ("seen: …").
|
|
14
|
+
* The remaining manifests (`opencode`, `gemini`, `aider`, `copilot`) are
|
|
15
|
+
* best-effort from public docs/common knowledge — every one of their matchers
|
|
16
|
+
* is marked "untuned — needs real captures" and kept HIGH-PRECISION
|
|
17
|
+
* (esc-to-interrupt / spinner / explicit y-n) so they can never false-positive
|
|
18
|
+
* on a plain prompt. Users can override any of these with a JSON file in
|
|
19
|
+
* `~/.tmux-ide/agent-detection/` (see `manifest-loader.ts`).
|
|
20
|
+
*/
|
|
21
|
+
import type { AgentManifest } from "./manifest.ts";
|
|
22
|
+
|
|
23
|
+
// Braille spinner glyphs common to CLI TUIs (claude, codex, ora, …). Scoped to
|
|
24
|
+
// a single manifest's region, so it can only fire on that agent's pane.
|
|
25
|
+
const BRAILLE_SPINNER = "[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]";
|
|
26
|
+
|
|
27
|
+
const CLAUDE: AgentManifest = {
|
|
28
|
+
id: "claude",
|
|
29
|
+
commands: ["claude"],
|
|
30
|
+
states: {
|
|
31
|
+
// Approval / confirmation prompts — Claude is waiting on the user.
|
|
32
|
+
// Claude's approval UI is a bordered box asking a "Do you want …?" question
|
|
33
|
+
// with a numbered arrow menu ("❯ 1. Yes" / "3. No, and tell Claude …").
|
|
34
|
+
// These phrases are approval-specific and never appear in the idle chrome
|
|
35
|
+
// (a bare "❯ " input box) or the "How is Claude doing this session?" survey
|
|
36
|
+
// (which uses "1: Bad" colon-style options, not "❯ 1.").
|
|
37
|
+
blocked: {
|
|
38
|
+
any: [
|
|
39
|
+
// seen (approval dialogs): "Do you want to proceed?" / "Do you want to
|
|
40
|
+
// make this edit to …"
|
|
41
|
+
{ region: "bottom", contains: "Do you want" },
|
|
42
|
+
// seen: the highlighted first option of the numbered approval menu.
|
|
43
|
+
{ region: "bottom", contains: "❯ 1." },
|
|
44
|
+
// seen: "2. Yes, and don't ask again this session"
|
|
45
|
+
{ region: "bottom", contains: "Yes, and" },
|
|
46
|
+
// seen: "3. No, and tell Claude what to do differently"
|
|
47
|
+
{ region: "bottom", contains: "No, and tell Claude" },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
// Streaming / thinking indicators. While Claude works the bottom line shows
|
|
51
|
+
// a spinner + gerund + the interrupt hint, e.g.
|
|
52
|
+
// "✳ Cerebrating… (esc to interrupt · ctrl+t to hide todos)".
|
|
53
|
+
working: {
|
|
54
|
+
any: [
|
|
55
|
+
// seen: the interrupt hint is present for the entire duration of a turn
|
|
56
|
+
// — the single most reliable "working" invariant.
|
|
57
|
+
{ region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
|
|
58
|
+
// seen: the animated status verb ("Thinking…", "Cerebrating…").
|
|
59
|
+
{ region: "bottom", contains: "Thinking" },
|
|
60
|
+
{ region: "bottom", contains: "Cerebrating" },
|
|
61
|
+
// The leading braille spinner glyph, in the body or (rarely) the title.
|
|
62
|
+
{ region: "bottom", regex: BRAILLE_SPINNER },
|
|
63
|
+
{ region: "title", regex: BRAILLE_SPINNER },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
// done: intentionally omitted — inferred by the classifier's seen-tracking.
|
|
67
|
+
// NOTE (seen, NOT used): idle Claude shows a bordered "❯ " input box with a
|
|
68
|
+
// "⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents" or
|
|
69
|
+
// "? for shortcuts · ← for agents" footer, and finished turns leave a
|
|
70
|
+
// "✻ Brewed for 9s" summary — none of these are working/blocked evidence,
|
|
71
|
+
// so they are deliberately absent and fall through to idle.
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const CODEX: AgentManifest = {
|
|
76
|
+
id: "codex",
|
|
77
|
+
commands: ["codex"],
|
|
78
|
+
states: {
|
|
79
|
+
blocked: {
|
|
80
|
+
any: [
|
|
81
|
+
// untuned — needs real captures. Codex approval prompts (docs/common
|
|
82
|
+
// knowledge): a command-approval question before running a shell
|
|
83
|
+
// command. Kept high-precision so it can't fire on the idle "›" box.
|
|
84
|
+
{ region: "bottom", contains: "Allow command", caseInsensitive: true },
|
|
85
|
+
{ region: "bottom", contains: "Do you want" },
|
|
86
|
+
{ region: "bottom", contains: "approve", caseInsensitive: true },
|
|
87
|
+
{ region: "bottom", contains: "(y/n)", caseInsensitive: true },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
working: {
|
|
91
|
+
any: [
|
|
92
|
+
// untuned for the exact string — Codex shows an elapsed-time working
|
|
93
|
+
// line with an interrupt hint while a turn runs. "esc to interrupt" is
|
|
94
|
+
// the shared CLI-TUI invariant; the spinner is the fallback.
|
|
95
|
+
{ region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
|
|
96
|
+
{ region: "bottom", regex: BRAILLE_SPINNER },
|
|
97
|
+
{ region: "title", regex: BRAILLE_SPINNER },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
// done: omitted. NOTE (seen, NOT used): idle Codex shows a "›" input prompt
|
|
101
|
+
// and a "gpt-5.5 high · <cwd> Goal achieved (5m)" status line —
|
|
102
|
+
// "Goal achieved" is a finished/idle marker, not "working", so it is left
|
|
103
|
+
// out (a done rule would collapse to idle in the instant classifier anyway).
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const OPENCODE: AgentManifest = {
|
|
108
|
+
id: "opencode",
|
|
109
|
+
commands: ["opencode"],
|
|
110
|
+
states: {
|
|
111
|
+
// untuned — needs real captures. High-precision only.
|
|
112
|
+
blocked: {
|
|
113
|
+
any: [
|
|
114
|
+
{ region: "bottom", contains: "(y/n)", caseInsensitive: true },
|
|
115
|
+
{ region: "bottom", contains: "[y/n]", caseInsensitive: true },
|
|
116
|
+
{ region: "bottom", contains: "Do you want" },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
working: {
|
|
120
|
+
any: [
|
|
121
|
+
{ region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
|
|
122
|
+
{ region: "bottom", regex: BRAILLE_SPINNER },
|
|
123
|
+
{ region: "title", regex: BRAILLE_SPINNER },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const GEMINI: AgentManifest = {
|
|
130
|
+
id: "gemini",
|
|
131
|
+
commands: ["gemini"],
|
|
132
|
+
states: {
|
|
133
|
+
// untuned — needs real captures. gemini-cli. High-precision only.
|
|
134
|
+
blocked: {
|
|
135
|
+
any: [
|
|
136
|
+
{ region: "bottom", contains: "(y/n)", caseInsensitive: true },
|
|
137
|
+
{ region: "bottom", contains: "Apply this change", caseInsensitive: true },
|
|
138
|
+
{ region: "bottom", contains: "Allow execution", caseInsensitive: true },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
working: {
|
|
142
|
+
any: [
|
|
143
|
+
// gemini-cli shows an "(esc to cancel)" hint during a turn.
|
|
144
|
+
{ region: "bottom", contains: "esc to cancel", caseInsensitive: true },
|
|
145
|
+
{ region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
|
|
146
|
+
{ region: "bottom", regex: BRAILLE_SPINNER },
|
|
147
|
+
{ region: "title", regex: BRAILLE_SPINNER },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const AIDER: AgentManifest = {
|
|
154
|
+
id: "aider",
|
|
155
|
+
commands: ["aider"],
|
|
156
|
+
states: {
|
|
157
|
+
// untuned — needs real captures. aider uses "(Y)es/(N)o" confirmation
|
|
158
|
+
// prompts, which are its most reliable blocked signal.
|
|
159
|
+
blocked: {
|
|
160
|
+
any: [
|
|
161
|
+
{ region: "bottom", contains: "(Y)es/(N)o", caseInsensitive: true },
|
|
162
|
+
{ region: "bottom", contains: "? [Yes]", caseInsensitive: true },
|
|
163
|
+
{ region: "bottom", contains: "Add file to the chat", caseInsensitive: true },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
// working: aider streams tokens without a stable status line, so we leave
|
|
167
|
+
// it to idle-by-default rather than risk a false positive.
|
|
168
|
+
working: {
|
|
169
|
+
any: [{ region: "bottom", regex: BRAILLE_SPINNER }],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const COPILOT: AgentManifest = {
|
|
175
|
+
id: "copilot",
|
|
176
|
+
commands: ["copilot", "github-copilot", "github-copilot-cli"],
|
|
177
|
+
states: {
|
|
178
|
+
// untuned — needs real captures. github-copilot-cli. High-precision only.
|
|
179
|
+
blocked: {
|
|
180
|
+
any: [
|
|
181
|
+
{ region: "bottom", contains: "(y/n)", caseInsensitive: true },
|
|
182
|
+
{ region: "bottom", contains: "Select an option", caseInsensitive: true },
|
|
183
|
+
{ region: "bottom", contains: "Allow", caseInsensitive: false },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
working: {
|
|
187
|
+
any: [
|
|
188
|
+
{ region: "bottom", contains: "esc to interrupt", caseInsensitive: true },
|
|
189
|
+
{ region: "bottom", regex: BRAILLE_SPINNER },
|
|
190
|
+
{ region: "title", regex: BRAILLE_SPINNER },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const SHELL: AgentManifest = {
|
|
197
|
+
id: "shell",
|
|
198
|
+
commands: ["bash", "zsh", "sh", "fish", "nu"],
|
|
199
|
+
states: {
|
|
200
|
+
// Catch-all: a raw shell is almost always idle. We only flag an explicit
|
|
201
|
+
// interactive confirmation as blocked; "working" is unreliable to read
|
|
202
|
+
// from a shell snapshot, so it stays absent (idle by default).
|
|
203
|
+
blocked: {
|
|
204
|
+
any: [
|
|
205
|
+
{ region: "bottom", contains: "[y/n]", caseInsensitive: true },
|
|
206
|
+
{ region: "bottom", contains: "(yes/no)", caseInsensitive: true },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* All bundled manifests, in preference order for `pickManifest` and for the
|
|
214
|
+
* tree-walk priority in `resolveAgentCommand` (earlier = higher priority). The
|
|
215
|
+
* `shell` catch-all is intentionally LAST so a real agent that has spawned a
|
|
216
|
+
* transient shell child still resolves to the agent.
|
|
217
|
+
*/
|
|
218
|
+
export const BUNDLED_MANIFESTS: AgentManifest[] = [
|
|
219
|
+
CLAUDE,
|
|
220
|
+
CODEX,
|
|
221
|
+
OPENCODE,
|
|
222
|
+
GEMINI,
|
|
223
|
+
AIDER,
|
|
224
|
+
COPILOT,
|
|
225
|
+
SHELL,
|
|
226
|
+
];
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-tree resolution for the snapshot-scraping fallback.
|
|
3
|
+
*
|
|
4
|
+
* A tmux pane's `pane_current_command` is only its IMMEDIATE child process —
|
|
5
|
+
* for a real agent that is usually `node`/`bun`/`sh`, never `claude`/`codex`.
|
|
6
|
+
* So the detection manifests picked purely by `pane_current_command` almost
|
|
7
|
+
* never match in practice. To fix that we walk the pane's process TREE and
|
|
8
|
+
* resolve what is actually running underneath it.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is pure except the thin `readProcessTable` io wrapper, and
|
|
11
|
+
* nothing ever throws — a missing/garbled `ps` simply yields no matches, and
|
|
12
|
+
* resolution falls back to the fast `pane_current_command` path.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { pickManifest, type AgentManifest } from "./manifest.ts";
|
|
16
|
+
import { getManifests } from "./manifest-loader.ts";
|
|
17
|
+
|
|
18
|
+
/** One row of the process table. */
|
|
19
|
+
export interface ProcEntry {
|
|
20
|
+
pid: number;
|
|
21
|
+
ppid: number;
|
|
22
|
+
/** Full command line (argv joined), as `ps` printed it. */
|
|
23
|
+
command: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse `ps -axo pid=,ppid=,command=` output. Each line is
|
|
28
|
+
* `<pid> <ppid> <command line…>` with leading padding on the numeric columns.
|
|
29
|
+
* Malformed lines are skipped; never throws.
|
|
30
|
+
*/
|
|
31
|
+
export function parsePsOutput(raw: string): ProcEntry[] {
|
|
32
|
+
const entries: ProcEntry[] = [];
|
|
33
|
+
for (const line of raw.split("\n")) {
|
|
34
|
+
const trimmed = line.replace(/^\s+/, "");
|
|
35
|
+
if (trimmed.length === 0) continue;
|
|
36
|
+
// pid, ppid, then the rest is the command line (which may contain spaces).
|
|
37
|
+
const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed);
|
|
38
|
+
if (!match) continue;
|
|
39
|
+
const pid = Number(match[1]);
|
|
40
|
+
const ppid = Number(match[2]);
|
|
41
|
+
const command = match[3] ?? "";
|
|
42
|
+
if (!Number.isFinite(pid) || !Number.isFinite(ppid) || command.length === 0) continue;
|
|
43
|
+
entries.push({ pid, ppid, command });
|
|
44
|
+
}
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Collect the command lines in the subtree rooted at `rootPid`, INCLUDING the
|
|
50
|
+
* root process itself. Deeper (more specific) processes come FIRST — a DFS that
|
|
51
|
+
* emits children before their parent — so the most-specific agent command is
|
|
52
|
+
* seen before the generic `node`/`bun`/`sh` shims above it. Cycles and runaway
|
|
53
|
+
* depth are guarded.
|
|
54
|
+
*/
|
|
55
|
+
export function subtreeCommands(entries: ProcEntry[], rootPid: number, maxDepth = 6): string[] {
|
|
56
|
+
const childrenByParent = new Map<number, ProcEntry[]>();
|
|
57
|
+
const byPid = new Map<number, ProcEntry>();
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
byPid.set(entry.pid, entry);
|
|
60
|
+
const siblings = childrenByParent.get(entry.ppid) ?? [];
|
|
61
|
+
siblings.push(entry);
|
|
62
|
+
childrenByParent.set(entry.ppid, siblings);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const root = byPid.get(rootPid);
|
|
66
|
+
if (!root) return [];
|
|
67
|
+
|
|
68
|
+
const commands: string[] = [];
|
|
69
|
+
const visited = new Set<number>();
|
|
70
|
+
|
|
71
|
+
const walk = (pid: number, depth: number): void => {
|
|
72
|
+
if (depth > maxDepth || visited.has(pid)) return;
|
|
73
|
+
visited.add(pid);
|
|
74
|
+
// Emit children first (deepest → shallowest) so the root lands last.
|
|
75
|
+
for (const child of childrenByParent.get(pid) ?? []) {
|
|
76
|
+
walk(child.pid, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
const self = byPid.get(pid);
|
|
79
|
+
if (self) commands.push(self.command);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
walk(rootPid, 0);
|
|
83
|
+
return commands;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the live process table. Thin io wrapper — on any failure returns `[]`
|
|
88
|
+
* so callers degrade to the fast path rather than throwing.
|
|
89
|
+
*/
|
|
90
|
+
export function readProcessTable(): ProcEntry[] {
|
|
91
|
+
try {
|
|
92
|
+
const raw = execFileSync("ps", ["-axo", "pid=,ppid=,command="], {
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
95
|
+
timeout: 2000,
|
|
96
|
+
});
|
|
97
|
+
return parsePsOutput(raw);
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract the candidate tokens a command line should be matched against.
|
|
105
|
+
*
|
|
106
|
+
* Full argv strings can incidentally contain an agent name (a path like
|
|
107
|
+
* `/Users/x/.claude/notes.md`, or `vim ~/.codex/todo`), which would produce a
|
|
108
|
+
* false positive if we ran `pickManifest` over the raw string. Instead we test
|
|
109
|
+
* only the meaningful executable-ish tokens: the basename of argv[0] and the
|
|
110
|
+
* basename of the second token (a script/subcommand path). Query strings and
|
|
111
|
+
* flags are ignored.
|
|
112
|
+
*
|
|
113
|
+
* e.g. `node /Users/x/.nvm/versions/node/v20/bin/claude --foo` → ["node", "claude"]
|
|
114
|
+
* `vim /Users/x/.claude/notes.md` → ["vim", "notes.md"]
|
|
115
|
+
*/
|
|
116
|
+
export function commandTokens(command: string): string[] {
|
|
117
|
+
const parts = command.trim().split(/\s+/).filter(Boolean);
|
|
118
|
+
const tokens: string[] = [];
|
|
119
|
+
const argv0 = parts[0];
|
|
120
|
+
if (argv0) tokens.push(basename(argv0));
|
|
121
|
+
const argv1 = parts[1];
|
|
122
|
+
// Only treat the second token as a script path when it is not a flag.
|
|
123
|
+
if (argv1 && !argv1.startsWith("-")) tokens.push(basename(argv1));
|
|
124
|
+
return tokens;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Path basename without the node:path import — splits on `/` and takes the tail. */
|
|
128
|
+
function basename(pathLike: string): string {
|
|
129
|
+
const segments = pathLike.split("/");
|
|
130
|
+
return segments[segments.length - 1] ?? pathLike;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve which agent manifest is actually running in a pane.
|
|
135
|
+
*
|
|
136
|
+
* 1. Fast path: `pane_current_command` may already BE the agent (e.g. `claude`
|
|
137
|
+
* via a shim). Try `pickManifest(paneCmd)` directly.
|
|
138
|
+
* 2. Tree path: otherwise walk the pane's process subtree and match extracted
|
|
139
|
+
* tokens against the manifests. Token extraction avoids the incidental-path
|
|
140
|
+
* false positives described above.
|
|
141
|
+
*
|
|
142
|
+
* The tree walk prefers the highest-PRIORITY manifest (its index in
|
|
143
|
+
* `manifests`, which is preference-ordered: claude, codex, …, then the `shell`
|
|
144
|
+
* catch-all) over merely the deepest match — an agent that has spawned a
|
|
145
|
+
* transient shell child (a Bash tool call) must still resolve to the agent, not
|
|
146
|
+
* to `shell`. Depth (deepest first) only breaks ties within the same manifest.
|
|
147
|
+
*
|
|
148
|
+
* Resolution order:
|
|
149
|
+
* 0. HINT — `opts.hint` (the pane's `@agent_hint` option): if it names a
|
|
150
|
+
* known manifest it WINS outright. This is the sandbox/wrapper escape
|
|
151
|
+
* hatch, set with `tmux set-option -p @agent_hint claude`.
|
|
152
|
+
* 1. FAST — `pane_current_command` may already be the agent (a shim).
|
|
153
|
+
* 2. TREE — walk the process subtree and match extracted tokens.
|
|
154
|
+
*
|
|
155
|
+
* `manifests` defaults to the loaded set (bundled + user overrides).
|
|
156
|
+
* `matchedCommand` is the token/command that produced the hit and `source`
|
|
157
|
+
* records which path won — both surfaced by `agent explain`. Returns an
|
|
158
|
+
* undefined manifest when nothing matches.
|
|
159
|
+
*/
|
|
160
|
+
export type ResolveSource = "hint" | "fast" | "tree" | "none";
|
|
161
|
+
|
|
162
|
+
export function resolveAgentCommand(
|
|
163
|
+
paneCmd: string,
|
|
164
|
+
panePid: number,
|
|
165
|
+
table: ProcEntry[],
|
|
166
|
+
opts: { manifests?: AgentManifest[]; hint?: string } = {},
|
|
167
|
+
): { manifest: AgentManifest | undefined; matchedCommand: string; source: ResolveSource } {
|
|
168
|
+
const manifests = opts.manifests ?? getManifests();
|
|
169
|
+
|
|
170
|
+
// 0. Hint: a per-pane @agent_hint forces a manifest, bypassing resolution.
|
|
171
|
+
const hint = opts.hint?.trim();
|
|
172
|
+
if (hint) {
|
|
173
|
+
const hinted = pickManifest(hint, manifests);
|
|
174
|
+
if (hinted) return { manifest: hinted, matchedCommand: hint, source: "hint" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const fast = pickManifest(paneCmd, manifests);
|
|
178
|
+
if (fast) return { manifest: fast, matchedCommand: paneCmd, source: "fast" };
|
|
179
|
+
|
|
180
|
+
let best: { manifest: AgentManifest; matchedCommand: string; rank: number } | undefined;
|
|
181
|
+
for (const command of subtreeCommands(table, panePid)) {
|
|
182
|
+
for (const token of commandTokens(command)) {
|
|
183
|
+
const hit = pickManifest(token, manifests);
|
|
184
|
+
if (!hit) continue;
|
|
185
|
+
const rank = manifests.indexOf(hit);
|
|
186
|
+
// Keep the first (deepest) match at a given rank; only replace when a
|
|
187
|
+
// strictly higher-priority manifest turns up.
|
|
188
|
+
if (!best || rank < best.rank) best = { manifest: hit, matchedCommand: token, rank };
|
|
189
|
+
if (best.rank === 0)
|
|
190
|
+
return { manifest: best.manifest, matchedCommand: best.matchedCommand, source: "tree" };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return best
|
|
195
|
+
? { manifest: best.manifest, matchedCommand: best.matchedCommand, source: "tree" }
|
|
196
|
+
: { manifest: undefined, matchedCommand: "", source: "none" };
|
|
197
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pane snapshot reader for agent-state detection.
|
|
3
|
+
*
|
|
4
|
+
* Reads the bottom-buffer text of a tmux pane and normalizes it into a
|
|
5
|
+
* `PaneSnapshot` — the raw evidence later detectors classify into
|
|
6
|
+
* blocked/working/done/idle. The parsing (`parseSnapshot`) is pure and
|
|
7
|
+
* unit-tested; `readPaneSnapshot` is the thin tmux I/O wrapper.
|
|
8
|
+
*/
|
|
9
|
+
import { captureRecent } from "@tmux-ide/tmux-bridge";
|
|
10
|
+
|
|
11
|
+
export interface PaneSnapshot {
|
|
12
|
+
/** Last N non-empty lines, ANSI-stripped, trailing whitespace trimmed. */
|
|
13
|
+
bottomNonEmpty: string[];
|
|
14
|
+
/** ANSI-stripped joined snapshot text. */
|
|
15
|
+
text: string;
|
|
16
|
+
/** Original raw capture, kept for ansi-format debugging. */
|
|
17
|
+
raw: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Well-known ANSI escape matcher (ansi-regex): SGR/cursor CSI sequences plus
|
|
21
|
+
// OSC strings terminated by BEL.
|
|
22
|
+
/* eslint-disable no-control-regex */
|
|
23
|
+
const ANSI =
|
|
24
|
+
/[][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
|
25
|
+
|
|
26
|
+
/* eslint-enable no-control-regex */
|
|
27
|
+
|
|
28
|
+
/** Strip ANSI escape sequences from a string. */
|
|
29
|
+
function stripAnsi(input: string): string {
|
|
30
|
+
return input.replace(ANSI, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_LINES = 20;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize raw pane text into a `PaneSnapshot`. Pure — never throws.
|
|
37
|
+
*
|
|
38
|
+
* @param raw Raw pane capture (may contain ANSI escapes).
|
|
39
|
+
* @param opts.lines Number of trailing non-empty lines to keep (default 20).
|
|
40
|
+
*/
|
|
41
|
+
export function parseSnapshot(raw: string, opts: { lines?: number } = {}): PaneSnapshot {
|
|
42
|
+
const lines = opts.lines ?? DEFAULT_LINES;
|
|
43
|
+
const text = stripAnsi(raw ?? "");
|
|
44
|
+
|
|
45
|
+
const nonEmpty = text
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((line) => line.replace(/\s+$/, ""))
|
|
48
|
+
.filter((line) => line.length > 0);
|
|
49
|
+
|
|
50
|
+
const bottomNonEmpty = lines > 0 ? nonEmpty.slice(-lines) : [];
|
|
51
|
+
|
|
52
|
+
return { bottomNonEmpty, text, raw: raw ?? "" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Capture a pane's recent buffer and parse it into a `PaneSnapshot`.
|
|
57
|
+
* On any tmux error, returns an empty snapshot rather than throwing.
|
|
58
|
+
*
|
|
59
|
+
* @param target tmux pane target (session, window, or pane id).
|
|
60
|
+
* @param opts.lines Number of trailing non-empty lines to keep (default 20).
|
|
61
|
+
*/
|
|
62
|
+
export function readPaneSnapshot(target: string, opts: { lines?: number } = {}): PaneSnapshot {
|
|
63
|
+
const lines = opts.lines ?? DEFAULT_LINES;
|
|
64
|
+
try {
|
|
65
|
+
const raw = captureRecent(target, lines);
|
|
66
|
+
return parseSnapshot(raw, { lines });
|
|
67
|
+
} catch {
|
|
68
|
+
return { bottomNonEmpty: [], text: "", raw: "" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code integration — the authoritative detection layer.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code has a first-class hooks system. `tmux-ide integration install
|
|
5
|
+
* claude` writes a tiny POSIX hook script and registers it in the user's
|
|
6
|
+
* `~/.claude/settings.json` for the lifecycle events we care about. Each hook
|
|
7
|
+
* invocation stamps the CURRENT tmux pane with a pane-local user option:
|
|
8
|
+
*
|
|
9
|
+
* @agent_state "<working|blocked|done|idle>:<unix epoch>"
|
|
10
|
+
* @agent_session_id the Claude session id (future: --resume on restore)
|
|
11
|
+
*
|
|
12
|
+
* The detector treats a fresh `@agent_state` as GROUND TRUTH and only falls
|
|
13
|
+
* back to screen-manifest scraping when no authority is present — the same
|
|
14
|
+
* two-layer model the best agent terminals use. Any other agent can join the
|
|
15
|
+
* authority layer by writing the same option (`tmux set-option -p
|
|
16
|
+
* @agent_state working:$(date +%s)`) — no integration required.
|
|
17
|
+
*
|
|
18
|
+
* The settings merge is surgical and reversible: entries are tagged by the
|
|
19
|
+
* hook-script path, a one-time backup is written next to settings.json, and
|
|
20
|
+
* uninstall removes exactly our entries.
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
chmodSync,
|
|
24
|
+
copyFileSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Marker every installed hook command contains — the removal key. */
|
|
34
|
+
export const HOOK_SCRIPT_RELPATH = ".tmux-ide/hooks/claude-state.sh";
|
|
35
|
+
|
|
36
|
+
export function hookScriptPath(): string {
|
|
37
|
+
return join(homedir(), HOOK_SCRIPT_RELPATH);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Absolute path to Claude Code's settings file: `TMUX_IDE_CLAUDE_SETTINGS` when
|
|
42
|
+
* set (tests / per-run overrides), else `~/.claude/settings.json`. The override
|
|
43
|
+
* lets install/offer flows be exercised against a scratch file so a test never
|
|
44
|
+
* reads or rewrites the user's real settings.
|
|
45
|
+
*/
|
|
46
|
+
export function claudeSettingsPath(): string {
|
|
47
|
+
return process.env.TMUX_IDE_CLAUDE_SETTINGS ?? join(homedir(), ".claude", "settings.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The hook script. POSIX sh, no dependencies. Claude Code passes the event
|
|
52
|
+
* payload as JSON on stdin; the state to report comes in as $1 (each settings
|
|
53
|
+
* entry passes its own state, so the script never parses the event name).
|
|
54
|
+
* Outside tmux ($TMUX_PANE unset) it exits quietly. The session id is
|
|
55
|
+
* extracted with sed — good enough for a flat JSON string field.
|
|
56
|
+
*/
|
|
57
|
+
export const HOOK_SCRIPT = `#!/bin/sh
|
|
58
|
+
# tmux-ide agent-state hook (installed by: tmux-ide integration install claude)
|
|
59
|
+
# $1 = state to report: working | blocked | done | idle
|
|
60
|
+
state="\${1:-idle}"
|
|
61
|
+
payload="$(cat 2>/dev/null || true)"
|
|
62
|
+
[ -n "$TMUX_PANE" ] || exit 0
|
|
63
|
+
tmux set-option -p -t "$TMUX_PANE" @agent_state "\${state}:$(date +%s)" 2>/dev/null || exit 0
|
|
64
|
+
sid="$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' | head -1)"
|
|
65
|
+
[ -n "$sid" ] && tmux set-option -p -t "$TMUX_PANE" @agent_session_id "$sid" 2>/dev/null
|
|
66
|
+
exit 0
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
/** Claude Code lifecycle events → the state each reports. */
|
|
70
|
+
export const EVENT_STATES: Array<{ event: string; state: string; matcher?: string }> = [
|
|
71
|
+
{ event: "UserPromptSubmit", state: "working" },
|
|
72
|
+
{ event: "PreToolUse", state: "working", matcher: "*" },
|
|
73
|
+
{ event: "Notification", state: "blocked" },
|
|
74
|
+
{ event: "Stop", state: "done" },
|
|
75
|
+
{ event: "SessionEnd", state: "idle" },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
interface HookCommand {
|
|
79
|
+
type: "command";
|
|
80
|
+
command: string;
|
|
81
|
+
}
|
|
82
|
+
interface HookGroup {
|
|
83
|
+
matcher?: string;
|
|
84
|
+
hooks: HookCommand[];
|
|
85
|
+
}
|
|
86
|
+
type HooksConfig = Record<string, HookGroup[]>;
|
|
87
|
+
export type ClaudeSettings = Record<string, unknown> & { hooks?: HooksConfig };
|
|
88
|
+
|
|
89
|
+
/** Does a hook group belong to us? (its command references our script) */
|
|
90
|
+
function isOurs(group: HookGroup): boolean {
|
|
91
|
+
return group.hooks?.some((h) => h.command?.includes(HOOK_SCRIPT_RELPATH)) ?? false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* PURE — return a copy of `settings` with our hook entries merged in.
|
|
96
|
+
* Idempotent: existing tmux-ide entries are replaced, everything else is
|
|
97
|
+
* preserved untouched.
|
|
98
|
+
*/
|
|
99
|
+
export function mergeHooks(settings: ClaudeSettings, scriptPath: string): ClaudeSettings {
|
|
100
|
+
const next: ClaudeSettings = { ...settings, hooks: { ...(settings.hooks ?? {}) } };
|
|
101
|
+
const hooks = next.hooks as HooksConfig;
|
|
102
|
+
for (const { event, state, matcher } of EVENT_STATES) {
|
|
103
|
+
const existing = (hooks[event] ?? []).filter((g) => !isOurs(g));
|
|
104
|
+
const group: HookGroup = {
|
|
105
|
+
...(matcher !== undefined ? { matcher } : {}),
|
|
106
|
+
hooks: [{ type: "command", command: `${scriptPath} ${state}` }],
|
|
107
|
+
};
|
|
108
|
+
hooks[event] = [...existing, group];
|
|
109
|
+
}
|
|
110
|
+
return next;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** PURE — return a copy of `settings` with exactly our entries removed. */
|
|
114
|
+
export function removeHooks(settings: ClaudeSettings): ClaudeSettings {
|
|
115
|
+
if (!settings.hooks) return { ...settings };
|
|
116
|
+
const hooks: HooksConfig = {};
|
|
117
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
118
|
+
const kept = groups.filter((g) => !isOurs(g));
|
|
119
|
+
if (kept.length > 0) hooks[event] = kept;
|
|
120
|
+
}
|
|
121
|
+
const next: ClaudeSettings = { ...settings, hooks };
|
|
122
|
+
if (Object.keys(hooks).length === 0) delete next.hooks;
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** PURE — is our integration present in these settings? */
|
|
127
|
+
export function isInstalled(settings: ClaudeSettings): boolean {
|
|
128
|
+
return Object.values(settings.hooks ?? {}).some((groups) => groups.some(isOurs));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readSettings(path: string): ClaudeSettings {
|
|
132
|
+
if (!existsSync(path)) return {};
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(readFileSync(path, "utf8")) as ClaudeSettings;
|
|
135
|
+
} catch {
|
|
136
|
+
throw new Error(`${path} is not valid JSON — fix or move it, then retry`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Install: write the hook script, back up settings.json once, merge our
|
|
142
|
+
* entries. Takes effect for NEW Claude Code sessions (hooks are read at
|
|
143
|
+
* session start).
|
|
144
|
+
*/
|
|
145
|
+
export function installClaudeIntegration(): { scriptPath: string; settingsPath: string } {
|
|
146
|
+
const script = hookScriptPath();
|
|
147
|
+
mkdirSync(dirname(script), { recursive: true });
|
|
148
|
+
writeFileSync(script, HOOK_SCRIPT, "utf8");
|
|
149
|
+
chmodSync(script, 0o755);
|
|
150
|
+
|
|
151
|
+
const settingsPath = claudeSettingsPath();
|
|
152
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
153
|
+
const settings = readSettings(settingsPath);
|
|
154
|
+
const backup = `${settingsPath}.tmux-ide.bak`;
|
|
155
|
+
if (existsSync(settingsPath) && !existsSync(backup)) copyFileSync(settingsPath, backup);
|
|
156
|
+
writeFileSync(settingsPath, `${JSON.stringify(mergeHooks(settings, script), null, 2)}\n`, "utf8");
|
|
157
|
+
return { scriptPath: script, settingsPath };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Uninstall: remove exactly our entries (script file is left, it's inert). */
|
|
161
|
+
export function uninstallClaudeIntegration(): { settingsPath: string; wasInstalled: boolean } {
|
|
162
|
+
const settingsPath = claudeSettingsPath();
|
|
163
|
+
const settings = readSettings(settingsPath);
|
|
164
|
+
const wasInstalled = isInstalled(settings);
|
|
165
|
+
if (wasInstalled) {
|
|
166
|
+
writeFileSync(settingsPath, `${JSON.stringify(removeHooks(settings), null, 2)}\n`, "utf8");
|
|
167
|
+
}
|
|
168
|
+
return { settingsPath, wasInstalled };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function claudeIntegrationStatus(): { installed: boolean; scriptExists: boolean } {
|
|
172
|
+
return {
|
|
173
|
+
installed: isInstalled(readSettings(claudeSettingsPath())),
|
|
174
|
+
scriptExists: existsSync(hookScriptPath()),
|
|
175
|
+
};
|
|
176
|
+
}
|