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.
Files changed (209) hide show
  1. package/bin/cli.js +13 -5
  2. package/bin/cli.ts +1 -1
  3. package/bunfig.toml +4 -0
  4. package/package.json +11 -3
  5. package/packages/contracts/package.json +22 -0
  6. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  7. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  8. package/packages/contracts/src/actions-contract.ts +310 -0
  9. package/packages/contracts/src/actions-errors.ts +41 -0
  10. package/packages/contracts/src/domain.ts +36 -0
  11. package/packages/contracts/src/ide-config.ts +170 -0
  12. package/packages/contracts/src/index.ts +24 -0
  13. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  14. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  15. package/packages/contracts/src/terminals.ts +116 -0
  16. package/packages/contracts/src/tmux.ts +60 -0
  17. package/packages/contracts/src/workspace.ts +67 -0
  18. package/packages/daemon/src/agent-explain.ts +270 -0
  19. package/packages/daemon/src/attach.ts +20 -0
  20. package/packages/daemon/src/bin.ts +4 -0
  21. package/packages/daemon/src/canonical.ts +7 -0
  22. package/packages/daemon/src/cli.ts +499 -0
  23. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  24. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  25. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  26. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  27. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  28. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  29. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  30. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  31. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  32. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  33. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  34. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  35. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  36. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  37. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  38. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  39. package/packages/daemon/src/command-center/discovery.ts +96 -0
  40. package/packages/daemon/src/command-center/index.ts +31 -0
  41. package/packages/daemon/src/command-center/schemas.ts +85 -0
  42. package/packages/daemon/src/command-center/server.ts +1260 -0
  43. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  44. package/packages/daemon/src/config.ts +549 -0
  45. package/packages/daemon/src/detect.ts +248 -0
  46. package/packages/daemon/src/doctor.ts +242 -0
  47. package/packages/daemon/src/embed.ts +5 -0
  48. package/packages/daemon/src/index.ts +12 -0
  49. package/packages/daemon/src/init.ts +211 -0
  50. package/packages/daemon/src/inspect.ts +178 -0
  51. package/packages/daemon/src/js-yaml.d.ts +10 -0
  52. package/packages/daemon/src/launch.ts +349 -0
  53. package/packages/daemon/src/lib/active-projects.ts +49 -0
  54. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  55. package/packages/daemon/src/lib/app-config.ts +336 -0
  56. package/packages/daemon/src/lib/app-settings.ts +53 -0
  57. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  58. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  59. package/packages/daemon/src/lib/auth/types.ts +2 -0
  60. package/packages/daemon/src/lib/auth-token.ts +5 -0
  61. package/packages/daemon/src/lib/authorship.ts +280 -0
  62. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  63. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  64. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  65. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  66. package/packages/daemon/src/lib/daemon.ts +79 -0
  67. package/packages/daemon/src/lib/dot-path.ts +17 -0
  68. package/packages/daemon/src/lib/errors.ts +67 -0
  69. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  70. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  71. package/packages/daemon/src/lib/log.ts +134 -0
  72. package/packages/daemon/src/lib/output.ts +76 -0
  73. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  74. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  75. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  76. package/packages/daemon/src/lib/project-probe.ts +92 -0
  77. package/packages/daemon/src/lib/project-registry.ts +296 -0
  78. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  79. package/packages/daemon/src/lib/session-options.ts +100 -0
  80. package/packages/daemon/src/lib/shell.ts +8 -0
  81. package/packages/daemon/src/lib/sizes.ts +36 -0
  82. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  83. package/packages/daemon/src/lib/slugify.ts +10 -0
  84. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  85. package/packages/daemon/src/lib/update-check.ts +298 -0
  86. package/packages/daemon/src/lib/update.ts +158 -0
  87. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  88. package/packages/daemon/src/lib/worktree.ts +289 -0
  89. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  90. package/packages/daemon/src/ls.ts +40 -0
  91. package/packages/daemon/src/restart.ts +24 -0
  92. package/packages/daemon/src/restore.ts +514 -0
  93. package/packages/daemon/src/schemas/domain.ts +2 -0
  94. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  95. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  96. package/packages/daemon/src/schemas/index.ts +59 -0
  97. package/packages/daemon/src/schemas/inspect.ts +67 -0
  98. package/packages/daemon/src/schemas/registry.ts +55 -0
  99. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  100. package/packages/daemon/src/send.ts +171 -0
  101. package/packages/daemon/src/server/README.md +15 -0
  102. package/packages/daemon/src/server/index.ts +74 -0
  103. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  104. package/packages/daemon/src/server/standalone.ts +18 -0
  105. package/packages/daemon/src/server/ws-route.ts +483 -0
  106. package/packages/daemon/src/status.ts +59 -0
  107. package/packages/daemon/src/stop.ts +28 -0
  108. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  109. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  110. package/packages/daemon/src/terminal/README.md +92 -0
  111. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  112. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  113. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  114. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  115. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  116. package/packages/daemon/src/tui/chrome/notify.ts +195 -0
  117. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  118. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  119. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  120. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  121. package/packages/daemon/src/tui/chrome/updater.ts +428 -0
  122. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  123. package/packages/daemon/src/tui/compiled.ts +113 -0
  124. package/packages/daemon/src/tui/detect/classify.ts +193 -0
  125. package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
  126. package/packages/daemon/src/tui/detect/manifest.ts +184 -0
  127. package/packages/daemon/src/tui/detect/manifests.ts +226 -0
  128. package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
  129. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  130. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  131. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  132. package/packages/daemon/src/tui/main.ts +70 -0
  133. package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
  134. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  135. package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
  136. package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
  137. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  138. package/packages/daemon/src/tui/team/entry.ts +11 -0
  139. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  140. package/packages/daemon/src/tui/team/home.ts +170 -0
  141. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  142. package/packages/daemon/src/tui/team/input.ts +34 -0
  143. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  144. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  145. package/packages/daemon/src/tui/team/nav.ts +31 -0
  146. package/packages/daemon/src/tui/team/preview.ts +34 -0
  147. package/packages/daemon/src/tui/team/projects.ts +191 -0
  148. package/packages/daemon/src/tui/team/report.ts +73 -0
  149. package/packages/daemon/src/tui/team/sessions.ts +344 -0
  150. package/packages/daemon/src/tui/team/tree.ts +62 -0
  151. package/packages/daemon/src/types.ts +13 -0
  152. package/packages/daemon/src/ui/index.ts +32 -0
  153. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  154. package/packages/daemon/src/ui/types.ts +91 -0
  155. package/packages/daemon/src/ui/web/base.css +80 -0
  156. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  157. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  158. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  159. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  160. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  161. package/packages/daemon/src/ui/web/index.ts +27 -0
  162. package/packages/daemon/src/ui/web/render.ts +77 -0
  163. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  164. package/packages/daemon/src/validate.ts +217 -0
  165. package/packages/daemon/src/widgets/changes/README.md +3 -0
  166. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  167. package/packages/daemon/src/widgets/config/README.md +3 -0
  168. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  169. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  170. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  171. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  172. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  173. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  174. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  175. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  176. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  177. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  178. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  179. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  180. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  181. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  182. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  183. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  184. package/packages/daemon/src/widgets/preview/README.md +3 -0
  185. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  186. package/packages/daemon/src/widgets/resolve.ts +121 -0
  187. package/packages/daemon/src/widgets/setup/README.md +3 -0
  188. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  189. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  190. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  191. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  192. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  193. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  194. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  195. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  196. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  197. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  198. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  199. package/packages/tmux-bridge/package.json +22 -0
  200. package/packages/tmux-bridge/src/errors.ts +28 -0
  201. package/packages/tmux-bridge/src/index.ts +31 -0
  202. package/packages/tmux-bridge/src/monitor.ts +77 -0
  203. package/packages/tmux-bridge/src/panes.ts +136 -0
  204. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  205. package/packages/tmux-bridge/src/runner.ts +91 -0
  206. package/packages/tmux-bridge/src/sessions.ts +126 -0
  207. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  208. package/packages/tmux-bridge/src/targeting.ts +90 -0
  209. 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
+ }