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,193 @@
1
+ /**
2
+ * The 4-state agent classifier.
3
+ *
4
+ * Turns snapshot + manifest evidence into a final per-pane status. The
5
+ * instantaneous half (`classifyInstant`) is pure: it maps a single snapshot
6
+ * through a manifest to blocked/working/idle/unknown. The `done` state,
7
+ * however, is inherently cross-tick — an agent is "done" only when it WAS
8
+ * working and has since gone quiet without being viewed. That temporal logic
9
+ * lives in the small stateful `StatusTracker`, which layers `done` + seen
10
+ * acknowledgement on top of the instantaneous state.
11
+ */
12
+ import type { AgentManifest } from "./manifest.ts";
13
+ import { evaluateManifest, pickManifest } from "./manifest.ts";
14
+ import { BUNDLED_MANIFESTS } from "./manifests.ts";
15
+ import type { PaneSnapshot } from "./snapshot.ts";
16
+
17
+ /** The final per-pane status surfaced to the TUI. */
18
+ export type AgentStatus = "blocked" | "working" | "done" | "idle" | "unknown";
19
+
20
+ /** The instantaneous status derivable from a single snapshot (no history). */
21
+ export type InstantState = "blocked" | "working" | "idle" | "unknown";
22
+
23
+ /**
24
+ * Working/blocked reports older than this are considered stale — the agent
25
+ * (or its hook) probably died mid-turn, so we fall back to screen scraping.
26
+ * Done/idle are terminal until the next event and never go stale.
27
+ */
28
+ const AUTHORITY_STALE_SECONDS = 600;
29
+
30
+ const AUTHORITY_STATES = new Set(["working", "blocked", "done", "idle"]);
31
+
32
+ /**
33
+ * Parse an authoritative `@agent_state` pane option (`"<state>:<epoch>"`,
34
+ * written by lifecycle-hook integrations or any self-reporting agent).
35
+ * PURE — returns the reported status, or null when the value is absent,
36
+ * malformed, or stale (working/blocked past {@link AUTHORITY_STALE_SECONDS}).
37
+ * A fresh authority report OUTRANKS screen-manifest scraping.
38
+ */
39
+ export function parseAuthority(raw: string | undefined, nowSec: number): AgentStatus | null {
40
+ if (!raw) return null;
41
+ const sep = raw.lastIndexOf(":");
42
+ if (sep === -1) return null;
43
+ const state = raw.slice(0, sep);
44
+ const epoch = Number(raw.slice(sep + 1));
45
+ if (!AUTHORITY_STATES.has(state) || !Number.isFinite(epoch)) return null;
46
+ if ((state === "working" || state === "blocked") && nowSec - epoch > AUTHORITY_STALE_SECONDS) {
47
+ return null;
48
+ }
49
+ return state as AgentStatus;
50
+ }
51
+
52
+ /**
53
+ * Classify a single snapshot against a manifest — PURE, never throws.
54
+ *
55
+ * - no manifest → `"unknown"` (we can't reason about an unrecognized command)
56
+ * - manifest reports `blocked` → `"blocked"`
57
+ * - manifest reports `working` → `"working"`
58
+ * - manifest reports `done` or nothing → `"idle"`
59
+ *
60
+ * A manifest-detected `done` is deliberately collapsed to `idle` here; the
61
+ * real, cross-tick `done` is produced by {@link StatusTracker}.
62
+ */
63
+ export function classifyInstant(
64
+ snapshot: PaneSnapshot & { title?: string },
65
+ manifest: AgentManifest | undefined,
66
+ ): InstantState {
67
+ if (!manifest) return "unknown";
68
+
69
+ const { state } = evaluateManifest(snapshot, manifest);
70
+ switch (state) {
71
+ case "blocked":
72
+ return "blocked";
73
+ case "working":
74
+ return "working";
75
+ // "done" (instantaneous) and null both fall through to idle.
76
+ default:
77
+ return "idle";
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Convenience wrapper: pick the manifest for a pane command, then classify.
83
+ * Returns `"unknown"` when no manifest applies to the command.
84
+ */
85
+ export function classifyPaneCommand(
86
+ snapshot: PaneSnapshot & { title?: string },
87
+ command: string,
88
+ manifests: AgentManifest[] = BUNDLED_MANIFESTS,
89
+ ): InstantState {
90
+ return classifyInstant(snapshot, pickManifest(command, manifests));
91
+ }
92
+
93
+ /** Per-pane bookkeeping the tracker keeps between ticks. */
94
+ interface PaneState {
95
+ /** Whether the previous tick observed `working`. */
96
+ wasWorking: boolean;
97
+ /** A working→idle transition happened and hasn't been acknowledged. */
98
+ doneUnseen: boolean;
99
+ }
100
+
101
+ /**
102
+ * Stateful layer that adds `done` + seen-acknowledgement on top of the
103
+ * instantaneous state. Deterministic given its call sequence.
104
+ */
105
+ export interface StatusTracker {
106
+ /**
107
+ * Fold a fresh instantaneous state into the pane's history and return the
108
+ * final status. `opts.seen` marks the pane as currently viewed/attached,
109
+ * which acknowledges (and suppresses) a pending `done`.
110
+ */
111
+ update(paneId: string, instant: InstantState, opts?: { seen?: boolean }): AgentStatus;
112
+ /** Acknowledge a pane — clears any pending `done`. */
113
+ markSeen(paneId: string): void;
114
+ /** Drop a pane's state entirely (e.g. its session closed). */
115
+ forget(paneId: string): void;
116
+ }
117
+
118
+ /**
119
+ * Create a {@link StatusTracker}.
120
+ *
121
+ * State transitions (given the previous tick's bookkeeping):
122
+ * - `working` → clear `doneUnseen`, remember working → `"working"`.
123
+ * - `blocked` → clear `doneUnseen` (blocked outranks a pending done) →
124
+ * `"blocked"`.
125
+ * - `idle`:
126
+ * - a working→idle transition sets `doneUnseen`.
127
+ * - `doneUnseen` && not seen → `"done"`.
128
+ * - otherwise → `"idle"`.
129
+ * - `unknown` → `"unknown"` (leaves `doneUnseen` untouched).
130
+ *
131
+ * `opts.seen === true` always clears `doneUnseen`, and downgrades a would-be
132
+ * `done` to `"idle"` — seeing a finished pane acknowledges it.
133
+ */
134
+ export function createStatusTracker(): StatusTracker {
135
+ const states = new Map<string, PaneState>();
136
+
137
+ function get(paneId: string): PaneState {
138
+ let s = states.get(paneId);
139
+ if (!s) {
140
+ s = { wasWorking: false, doneUnseen: false };
141
+ states.set(paneId, s);
142
+ }
143
+ return s;
144
+ }
145
+
146
+ return {
147
+ update(paneId, instant, opts) {
148
+ const seen = opts?.seen === true;
149
+ const s = get(paneId);
150
+
151
+ switch (instant) {
152
+ case "working":
153
+ s.doneUnseen = false;
154
+ s.wasWorking = true;
155
+ return "working";
156
+
157
+ case "blocked":
158
+ s.doneUnseen = false;
159
+ s.wasWorking = false;
160
+ return "blocked";
161
+
162
+ case "idle": {
163
+ // A working→idle transition means the agent just finished.
164
+ if (s.wasWorking) s.doneUnseen = true;
165
+ s.wasWorking = false;
166
+
167
+ // Viewing the pane acknowledges any pending done.
168
+ if (seen) {
169
+ s.doneUnseen = false;
170
+ return "idle";
171
+ }
172
+ return s.doneUnseen ? "done" : "idle";
173
+ }
174
+
175
+ case "unknown":
176
+ default:
177
+ // Leave doneUnseen untouched; unknown is not a transition signal.
178
+ s.wasWorking = false;
179
+ if (seen) s.doneUnseen = false;
180
+ return "unknown";
181
+ }
182
+ },
183
+
184
+ markSeen(paneId) {
185
+ const s = states.get(paneId);
186
+ if (s) s.doneUnseen = false;
187
+ },
188
+
189
+ forget(paneId) {
190
+ states.delete(paneId);
191
+ },
192
+ };
193
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Manifest loading with user overrides.
3
+ *
4
+ * The bundled manifests (`manifests.ts`) cover the agents we ship tuning for.
5
+ * Users can add or replace tuning WITHOUT a code change by dropping JSON files
6
+ * in `~/.tmux-ide/agent-detection/` — one {@link AgentManifest} per file:
7
+ *
8
+ * ~/.tmux-ide/agent-detection/my-agent.json
9
+ * {
10
+ * "id": "my-agent",
11
+ * "commands": ["my-agent", "my-agent-cli"],
12
+ * "states": {
13
+ * "working": { "any": [{ "contains": "esc to interrupt", "caseInsensitive": true }] },
14
+ * "blocked": { "any": [{ "contains": "(y/n)", "caseInsensitive": true }] }
15
+ * }
16
+ * }
17
+ *
18
+ * An override whose `id` matches a bundled manifest REPLACES it (so you can
19
+ * re-tune claude/codex); a new `id` is APPENDED. Invalid files are skipped with
20
+ * a one-time stderr warning — a typo in one override never breaks detection.
21
+ *
22
+ * Related: a per-pane `@agent_hint` tmux option (e.g.
23
+ * `tmux set-option -p @agent_hint claude`) forces a specific manifest for a
24
+ * pane, bypassing process-tree resolution — see `resolveAgentCommand`. That is
25
+ * the escape hatch for sandboxes/wrappers where the process tree is opaque.
26
+ */
27
+ import { readdirSync, readFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+ import type { AgentManifest, Rule, StateRules } from "./manifest.ts";
31
+ import { BUNDLED_MANIFESTS } from "./manifests.ts";
32
+
33
+ /** Directory scanned for user override manifests. */
34
+ export function overrideDir(): string {
35
+ return join(homedir(), ".tmux-ide", "agent-detection");
36
+ }
37
+
38
+ /**
39
+ * Light structural validator — enough to reject a malformed override without a
40
+ * full schema. Requires a non-empty `id`, a non-empty `commands: string[]`, and
41
+ * a `states` object whose present state rules are shaped `{ all?: [], any?: [] }`
42
+ * with matcher-like entries. PURE — never throws.
43
+ */
44
+ export function validateManifestShape(value: unknown): value is AgentManifest {
45
+ if (typeof value !== "object" || value === null) return false;
46
+ const m = value as Record<string, unknown>;
47
+
48
+ if (typeof m.id !== "string" || m.id.trim().length === 0) return false;
49
+
50
+ if (!Array.isArray(m.commands) || m.commands.length === 0) return false;
51
+ if (!m.commands.every((c) => typeof c === "string" && c.length > 0)) return false;
52
+
53
+ if (typeof m.states !== "object" || m.states === null) return false;
54
+ const states = m.states as Record<string, unknown>;
55
+ for (const key of ["blocked", "working", "done"] as const) {
56
+ if (!(key in states)) continue;
57
+ if (!isRuleShape(states[key])) return false;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ /** A rule is `{ all?: Matcher[]; any?: Matcher[] }` with matcher-shaped entries. */
63
+ function isRuleShape(value: unknown): value is Rule {
64
+ if (typeof value !== "object" || value === null) return false;
65
+ const r = value as Record<string, unknown>;
66
+ for (const key of ["all", "any"] as const) {
67
+ if (!(key in r)) continue;
68
+ const arr = r[key];
69
+ if (!Array.isArray(arr) || !arr.every(isMatcherShape)) return false;
70
+ }
71
+ return true;
72
+ }
73
+
74
+ /** A matcher must carry a string `contains` or `regex` (the two probe forms). */
75
+ function isMatcherShape(value: unknown): boolean {
76
+ if (typeof value !== "object" || value === null) return false;
77
+ const m = value as Record<string, unknown>;
78
+ return typeof m.contains === "string" || typeof m.regex === "string";
79
+ }
80
+
81
+ /**
82
+ * Merge bundled manifests with user overrides. PURE.
83
+ *
84
+ * - An override whose `id` matches a bundled manifest REPLACES that entry
85
+ * in-place (position preserved, so pickManifest/tree priority is stable).
86
+ * - A later override with a not-yet-seen `id` is APPENDED (in encounter order).
87
+ * - Among overrides sharing an `id`, the LAST one wins.
88
+ */
89
+ export function mergeManifests(
90
+ bundled: AgentManifest[],
91
+ overrides: AgentManifest[],
92
+ ): AgentManifest[] {
93
+ const byId = new Map<string, AgentManifest>();
94
+ for (const o of overrides) byId.set(o.id, o);
95
+
96
+ const result: AgentManifest[] = [];
97
+ const consumed = new Set<string>();
98
+
99
+ for (const b of bundled) {
100
+ const override = byId.get(b.id);
101
+ if (override) {
102
+ result.push(override);
103
+ consumed.add(b.id);
104
+ } else {
105
+ result.push(b);
106
+ }
107
+ }
108
+ // Append new ids in the order first encountered — but use the last-wins entry
109
+ // from `byId`, so a duplicate id still resolves to the final override.
110
+ for (const o of overrides) {
111
+ if (!consumed.has(o.id)) {
112
+ result.push(byId.get(o.id)!);
113
+ consumed.add(o.id);
114
+ }
115
+ }
116
+ return result;
117
+ }
118
+
119
+ /** Paths we have already warned about, so a bad override warns only once. */
120
+ const warned = new Set<string>();
121
+
122
+ /**
123
+ * Read and validate override manifests from `dir`. Thin io wrapper — a missing
124
+ * directory yields `[]`, and each invalid/malformed file is skipped with a
125
+ * one-time stderr warning. Never throws.
126
+ */
127
+ export function readOverrideManifests(dir = overrideDir()): AgentManifest[] {
128
+ let files: string[];
129
+ try {
130
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
131
+ } catch {
132
+ // Missing directory (the common case) → no overrides.
133
+ return [];
134
+ }
135
+
136
+ const overrides: AgentManifest[] = [];
137
+ for (const file of files.sort()) {
138
+ const path = join(dir, file);
139
+ try {
140
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
141
+ if (validateManifestShape(parsed)) {
142
+ overrides.push(normalizeStates(parsed));
143
+ } else {
144
+ warnOnce(path, "not a valid AgentManifest (need id, commands[], states)");
145
+ }
146
+ } catch (err) {
147
+ warnOnce(path, err instanceof Error ? err.message : String(err));
148
+ }
149
+ }
150
+ return overrides;
151
+ }
152
+
153
+ /** Drop unknown state keys — keep only blocked/working/done. */
154
+ function normalizeStates(m: AgentManifest): AgentManifest {
155
+ const states: StateRules = {};
156
+ if (m.states.blocked) states.blocked = m.states.blocked;
157
+ if (m.states.working) states.working = m.states.working;
158
+ if (m.states.done) states.done = m.states.done;
159
+ return { id: m.id, commands: m.commands, states };
160
+ }
161
+
162
+ function warnOnce(path: string, reason: string): void {
163
+ if (warned.has(path)) return;
164
+ warned.add(path);
165
+ process.stderr.write(`tmux-ide: skipping agent-detection override ${path}: ${reason}\n`);
166
+ }
167
+
168
+ /**
169
+ * Load the full manifest set: bundled + user overrides, merged. Does io on
170
+ * every call — prefer {@link getManifests} for hot paths.
171
+ */
172
+ export function loadManifests(): AgentManifest[] {
173
+ return mergeManifests(BUNDLED_MANIFESTS, readOverrideManifests());
174
+ }
175
+
176
+ /** Process-lifetime cache for the merged manifest set. */
177
+ let cache: AgentManifest[] | undefined;
178
+
179
+ /**
180
+ * Cached {@link loadManifests}. The override directory is read once per
181
+ * process; call {@link _resetForTests} to force a re-read.
182
+ */
183
+ export function getManifests(): AgentManifest[] {
184
+ if (!cache) cache = loadManifests();
185
+ return cache;
186
+ }
187
+
188
+ /** Test hook: clear the cache and the one-time-warning ledger. */
189
+ export function _resetForTests(): void {
190
+ cache = undefined;
191
+ warned.clear();
192
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Declarative detection manifests — a pure, data-driven rule engine.
3
+ *
4
+ * A manifest describes, per agent command (claude, codex, shell, …), the
5
+ * textual evidence that indicates a `blocked`/`working`/`done` state. The
6
+ * evaluator resolves matcher regions from a `PaneSnapshot`, tests them, and
7
+ * returns the first state (by precedence) whose rule matches. Everything here
8
+ * is pure and never throws — invalid regexes simply fail to match. The 4-state
9
+ * classifier layers `idle` + seen-tracking on top of this.
10
+ */
11
+ import type { PaneSnapshot } from "./snapshot.ts";
12
+
13
+ /** Which slice of a snapshot a matcher tests against. */
14
+ export type Region = "bottom" | "text" | "title";
15
+
16
+ /** A single evidence probe. Exactly one of `contains`/`regex` is required. */
17
+ export interface Matcher {
18
+ /** Region to resolve (default `bottom`). */
19
+ region?: Region;
20
+ /** Substring test. */
21
+ contains?: string;
22
+ /** Regex source string (compiled safely; invalid → no match). */
23
+ regex?: string;
24
+ /** Case-insensitive contains/regex (default false). */
25
+ caseInsensitive?: boolean;
26
+ }
27
+
28
+ /** A boolean clause: `all` matchers AND-ed, `any` matchers OR-ed. */
29
+ export interface Rule {
30
+ /** Every matcher must match. */
31
+ all?: Matcher[];
32
+ /** At least one matcher must match. */
33
+ any?: Matcher[];
34
+ }
35
+
36
+ /** Per-state rules. `idle` is the fallback and has no rule. */
37
+ export interface StateRules {
38
+ blocked?: Rule;
39
+ working?: Rule;
40
+ done?: Rule;
41
+ }
42
+
43
+ /** A detection manifest for one or more pane commands. */
44
+ export interface AgentManifest {
45
+ /** Stable manifest id. */
46
+ id: string;
47
+ /** Pane `current_command` names this manifest applies to. */
48
+ commands: string[];
49
+ /** Evidence rules per detectable state. */
50
+ states: StateRules;
51
+ }
52
+
53
+ /** The states a manifest can positively detect (idle is inferred elsewhere). */
54
+ export type DetectedState = "blocked" | "working" | "done";
55
+
56
+ /** Snapshot plus the optional pane title used by the `title` region. */
57
+ type SnapshotWithTitle = PaneSnapshot & { title?: string };
58
+
59
+ /** Precedence order — the first matching state wins. */
60
+ const PRECEDENCE: DetectedState[] = ["blocked", "working", "done"];
61
+
62
+ /** Resolve the text a matcher tests against, tolerating an absent title. */
63
+ function resolveRegion(snapshot: SnapshotWithTitle, region: Region): string {
64
+ switch (region) {
65
+ case "text":
66
+ return snapshot.text;
67
+ case "title":
68
+ return snapshot.title ?? "";
69
+ case "bottom":
70
+ default:
71
+ return snapshot.bottomNonEmpty.join("\n");
72
+ }
73
+ }
74
+
75
+ /** Compile a regex safely — returns undefined on invalid source. */
76
+ function safeRegex(source: string, caseInsensitive?: boolean): RegExp | undefined {
77
+ try {
78
+ return new RegExp(source, caseInsensitive ? "i" : "");
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Test a single matcher against a snapshot. Pure — never throws.
86
+ * A matcher with neither `contains` nor `regex` never matches.
87
+ */
88
+ export function matchMatcher(snapshot: SnapshotWithTitle, matcher: Matcher): boolean {
89
+ const haystack = resolveRegion(snapshot, matcher.region ?? "bottom");
90
+
91
+ if (matcher.contains !== undefined) {
92
+ if (matcher.caseInsensitive) {
93
+ return haystack.toLowerCase().includes(matcher.contains.toLowerCase());
94
+ }
95
+ return haystack.includes(matcher.contains);
96
+ }
97
+
98
+ if (matcher.regex !== undefined) {
99
+ const re = safeRegex(matcher.regex, matcher.caseInsensitive);
100
+ return re ? re.test(haystack) : false;
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Test a rule: all present `all` matchers must match AND at least one present
108
+ * `any` matcher must match. An empty or absent rule never matches.
109
+ */
110
+ export function matchRule(snapshot: SnapshotWithTitle, rule: Rule): boolean {
111
+ const hasAll = rule.all !== undefined && rule.all.length > 0;
112
+ const hasAny = rule.any !== undefined && rule.any.length > 0;
113
+ if (!hasAll && !hasAny) return false;
114
+
115
+ if (hasAll && !rule.all!.every((m) => matchMatcher(snapshot, m))) return false;
116
+ if (hasAny && !rule.any!.some((m) => matchMatcher(snapshot, m))) return false;
117
+
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * Evaluate a manifest against a snapshot in precedence order
123
+ * (blocked → working → done). Returns the first matching state, or
124
+ * `{ state: null }` when no rule matches. Detection is strict: blocked
125
+ * requires explicit evidence.
126
+ */
127
+ export function evaluateManifest(
128
+ snapshot: SnapshotWithTitle,
129
+ manifest: AgentManifest,
130
+ ): { state: DetectedState | null; matched?: { state: DetectedState; matcher: Matcher } } {
131
+ for (const state of PRECEDENCE) {
132
+ const rule = manifest.states[state];
133
+ if (rule && matchRule(snapshot, rule)) {
134
+ const matcher = firstMatchingMatcher(snapshot, rule);
135
+ return matcher ? { state, matched: { state, matcher } } : { state };
136
+ }
137
+ }
138
+ return { state: null };
139
+ }
140
+
141
+ /** Find the first matcher in a rule that matched, for reporting. */
142
+ function firstMatchingMatcher(snapshot: SnapshotWithTitle, rule: Rule): Matcher | undefined {
143
+ const matchers = [...(rule.all ?? []), ...(rule.any ?? [])];
144
+ return matchers.find((m) => matchMatcher(snapshot, m));
145
+ }
146
+
147
+ /**
148
+ * Debug helper — evaluate every state and report which matched, alongside the
149
+ * winning state (by precedence).
150
+ */
151
+ export function explain(
152
+ snapshot: SnapshotWithTitle,
153
+ manifest: AgentManifest,
154
+ ): { state: DetectedState | null; checked: Array<{ state: DetectedState; matched: boolean }> } {
155
+ const checked = PRECEDENCE.map((state) => {
156
+ const rule = manifest.states[state];
157
+ return { state, matched: rule ? matchRule(snapshot, rule) : false };
158
+ });
159
+ const winner = checked.find((c) => c.matched);
160
+ return { state: winner ? winner.state : null, checked };
161
+ }
162
+
163
+ /**
164
+ * Pick the manifest whose `commands` best match a pane's current command.
165
+ * Prefers an exact (case-insensitive) match, then a substring match in either
166
+ * direction. Returns undefined when nothing applies.
167
+ */
168
+ export function pickManifest(
169
+ command: string,
170
+ manifests: AgentManifest[],
171
+ ): AgentManifest | undefined {
172
+ const cmd = command.trim().toLowerCase();
173
+ if (cmd.length === 0) return undefined;
174
+
175
+ const exact = manifests.find((m) => m.commands.some((c) => c.toLowerCase() === cmd));
176
+ if (exact) return exact;
177
+
178
+ return manifests.find((m) =>
179
+ m.commands.some((c) => {
180
+ const name = c.toLowerCase();
181
+ return cmd.includes(name) || name.includes(cmd);
182
+ }),
183
+ );
184
+ }