tmux-ide 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- 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/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -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 +427 -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/tui-binary.ts +165 -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 +382 -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 +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -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 +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -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 +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -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/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
* Summarize the executable-ish tokens seen in a pane's process subtree, in the
|
|
88
|
+
* order the tree-walk visits them (deepest first), de-duplicated. Pure.
|
|
89
|
+
*
|
|
90
|
+
* This is a DIAGNOSTIC for `agent explain`: when no manifest resolves, showing
|
|
91
|
+
* "process-tree saw: node, sleep" tells the user WHY nothing matched and what
|
|
92
|
+
* to point an `@agent_hint` at. Tokens are the same basenames
|
|
93
|
+
* `resolveAgentCommand` matches against, so the report reflects reality.
|
|
94
|
+
*/
|
|
95
|
+
export function describeSubtree(entries: ProcEntry[], rootPid: number, limit = 8): string[] {
|
|
96
|
+
const seen: string[] = [];
|
|
97
|
+
for (const command of subtreeCommands(entries, rootPid)) {
|
|
98
|
+
for (const token of commandTokens(command)) {
|
|
99
|
+
if (!seen.includes(token)) seen.push(token);
|
|
100
|
+
if (seen.length >= limit) return seen;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return seen;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read the live process table. Thin io wrapper — on any failure returns `[]`
|
|
108
|
+
* so callers degrade to the fast path rather than throwing.
|
|
109
|
+
*/
|
|
110
|
+
export function readProcessTable(): ProcEntry[] {
|
|
111
|
+
try {
|
|
112
|
+
const raw = execFileSync("ps", ["-axo", "pid=,ppid=,command="], {
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
115
|
+
timeout: 2000,
|
|
116
|
+
});
|
|
117
|
+
return parsePsOutput(raw);
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract the candidate tokens a command line should be matched against.
|
|
125
|
+
*
|
|
126
|
+
* Full argv strings can incidentally contain an agent name (a path like
|
|
127
|
+
* `/Users/x/.claude/notes.md`, or `vim ~/.codex/todo`), which would produce a
|
|
128
|
+
* false positive if we ran `pickManifest` over the raw string. Instead we test
|
|
129
|
+
* only the meaningful executable-ish tokens: the basename of argv[0] and the
|
|
130
|
+
* basename of the second token (a script/subcommand path). Query strings and
|
|
131
|
+
* flags are ignored.
|
|
132
|
+
*
|
|
133
|
+
* e.g. `node /Users/x/.nvm/versions/node/v20/bin/claude --foo` → ["node", "claude"]
|
|
134
|
+
* `vim /Users/x/.claude/notes.md` → ["vim", "notes.md"]
|
|
135
|
+
*/
|
|
136
|
+
export function commandTokens(command: string): string[] {
|
|
137
|
+
const parts = command.trim().split(/\s+/).filter(Boolean);
|
|
138
|
+
const tokens: string[] = [];
|
|
139
|
+
const argv0 = parts[0];
|
|
140
|
+
if (argv0) tokens.push(basename(argv0));
|
|
141
|
+
const argv1 = parts[1];
|
|
142
|
+
// Only treat the second token as a script path when it is not a flag.
|
|
143
|
+
if (argv1 && !argv1.startsWith("-")) tokens.push(basename(argv1));
|
|
144
|
+
return tokens;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Path basename without the node:path import — splits on `/` and takes the tail. */
|
|
148
|
+
function basename(pathLike: string): string {
|
|
149
|
+
const segments = pathLike.split("/");
|
|
150
|
+
return segments[segments.length - 1] ?? pathLike;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve which agent manifest is actually running in a pane.
|
|
155
|
+
*
|
|
156
|
+
* 1. Fast path: `pane_current_command` may already BE the agent (e.g. `claude`
|
|
157
|
+
* via a shim). Try `pickManifest(paneCmd)` directly.
|
|
158
|
+
* 2. Tree path: otherwise walk the pane's process subtree and match extracted
|
|
159
|
+
* tokens against the manifests. Token extraction avoids the incidental-path
|
|
160
|
+
* false positives described above.
|
|
161
|
+
*
|
|
162
|
+
* The tree walk prefers the highest-PRIORITY manifest (its index in
|
|
163
|
+
* `manifests`, which is preference-ordered: claude, codex, …, then the `shell`
|
|
164
|
+
* catch-all) over merely the deepest match — an agent that has spawned a
|
|
165
|
+
* transient shell child (a Bash tool call) must still resolve to the agent, not
|
|
166
|
+
* to `shell`. Depth (deepest first) only breaks ties within the same manifest.
|
|
167
|
+
*
|
|
168
|
+
* Resolution order:
|
|
169
|
+
* 0. HINT — `opts.hint` (the pane's `@agent_hint` option): if it names a
|
|
170
|
+
* known manifest it WINS outright. This is the sandbox/wrapper escape
|
|
171
|
+
* hatch, set with `tmux set-option -p @agent_hint claude`.
|
|
172
|
+
* 1. FAST — `pane_current_command` may already be the agent (a shim).
|
|
173
|
+
* 2. TREE — walk the process subtree and match extracted tokens.
|
|
174
|
+
*
|
|
175
|
+
* `manifests` defaults to the loaded set (bundled + user overrides).
|
|
176
|
+
* `matchedCommand` is the token/command that produced the hit and `source`
|
|
177
|
+
* records which path won — both surfaced by `agent explain`. Returns an
|
|
178
|
+
* undefined manifest when nothing matches.
|
|
179
|
+
*/
|
|
180
|
+
export type ResolveSource = "hint" | "fast" | "tree" | "none";
|
|
181
|
+
|
|
182
|
+
export function resolveAgentCommand(
|
|
183
|
+
paneCmd: string,
|
|
184
|
+
panePid: number,
|
|
185
|
+
table: ProcEntry[],
|
|
186
|
+
opts: { manifests?: AgentManifest[]; hint?: string } = {},
|
|
187
|
+
): { manifest: AgentManifest | undefined; matchedCommand: string; source: ResolveSource } {
|
|
188
|
+
const manifests = opts.manifests ?? getManifests();
|
|
189
|
+
|
|
190
|
+
// 0. Hint: a per-pane @agent_hint forces a manifest, bypassing resolution.
|
|
191
|
+
const hint = opts.hint?.trim();
|
|
192
|
+
if (hint) {
|
|
193
|
+
const hinted = pickManifest(hint, manifests);
|
|
194
|
+
if (hinted) return { manifest: hinted, matchedCommand: hint, source: "hint" };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const fast = pickManifest(paneCmd, manifests);
|
|
198
|
+
if (fast) return { manifest: fast, matchedCommand: paneCmd, source: "fast" };
|
|
199
|
+
|
|
200
|
+
let best: { manifest: AgentManifest; matchedCommand: string; rank: number } | undefined;
|
|
201
|
+
for (const command of subtreeCommands(table, panePid)) {
|
|
202
|
+
for (const token of commandTokens(command)) {
|
|
203
|
+
const hit = pickManifest(token, manifests);
|
|
204
|
+
if (!hit) continue;
|
|
205
|
+
const rank = manifests.indexOf(hit);
|
|
206
|
+
// Keep the first (deepest) match at a given rank; only replace when a
|
|
207
|
+
// strictly higher-priority manifest turns up.
|
|
208
|
+
if (!best || rank < best.rank) best = { manifest: hit, matchedCommand: token, rank };
|
|
209
|
+
if (best.rank === 0)
|
|
210
|
+
return { manifest: best.manifest, matchedCommand: best.matchedCommand, source: "tree" };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return best
|
|
215
|
+
? { manifest: best.manifest, matchedCommand: best.matchedCommand, source: "tree" }
|
|
216
|
+
: { manifest: undefined, matchedCommand: "", source: "none" };
|
|
217
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-time INTEGRATION OFFER — shown the first time tmux-ide adopts a
|
|
3
|
+
* session on a machine that has Claude Code but hasn't installed the lifecycle
|
|
4
|
+
* hook.
|
|
5
|
+
*
|
|
6
|
+
* The onboarding gap: the Claude Code integration turns pane state from
|
|
7
|
+
* screen-scraping guesswork into ground truth, but nothing prompts the user to
|
|
8
|
+
* install it — they'd have to read the docs. This offer notices `claude` on
|
|
9
|
+
* PATH at adopt time and asks once: install now? [y/N]. `y` installs; anything
|
|
10
|
+
* else skips. Either way a marker is written so it never asks again.
|
|
11
|
+
*
|
|
12
|
+
* "Once" is enforced by a marker file (`<home>/integration-offered`, `<home>` =
|
|
13
|
+
* `TMUX_IDE_HOME` when set — so tests and the dev box never see it), exactly
|
|
14
|
+
* like the welcome card ({@link ../chrome/welcome.ts}). The offer is ALSO gated
|
|
15
|
+
* by config (`integrations.offer`), so it can be suppressed without touching the
|
|
16
|
+
* marker. {@link shouldOfferIntegration} is the PURE decision; the marker
|
|
17
|
+
* helpers and {@link maybeOfferIntegrationPopup} wire the io. It NEVER fails an
|
|
18
|
+
* adopt — every io path is best-effort.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
21
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { getAppConfig } from "../../lib/app-config.ts";
|
|
25
|
+
import { claudeIntegrationStatus } from "./claude.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Absolute path to the "already offered" marker: `<home>/integration-offered`,
|
|
29
|
+
* where `<home>` is `TMUX_IDE_HOME` when set (tests / per-run overrides), else
|
|
30
|
+
* `~/.tmux-ide`. The env override lets a live test point the marker at a scratch
|
|
31
|
+
* dir so it never touches — or is confused by — the real user's marker.
|
|
32
|
+
*/
|
|
33
|
+
export function integrationOfferMarkerPath(): string {
|
|
34
|
+
const home = process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
|
|
35
|
+
return join(home, "integration-offered");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* PURE — the first-adopt offer decision. Show the offer only when ALL hold:
|
|
40
|
+
* - `claudeOnPath` Claude Code is installed on this machine,
|
|
41
|
+
* - `integrationInstalled` is false (nothing to offer if it's already hooked),
|
|
42
|
+
* - `markerPresent` is false (we've never offered before), and
|
|
43
|
+
* - `offerEnabled` config hasn't disabled the offer.
|
|
44
|
+
* Kept pure so every gate is trivially unit-tested.
|
|
45
|
+
*/
|
|
46
|
+
export function shouldOfferIntegration(input: {
|
|
47
|
+
claudeOnPath: boolean;
|
|
48
|
+
integrationInstalled: boolean;
|
|
49
|
+
markerPresent: boolean;
|
|
50
|
+
offerEnabled: boolean;
|
|
51
|
+
}): boolean {
|
|
52
|
+
return (
|
|
53
|
+
input.claudeOnPath && !input.integrationInstalled && !input.markerPresent && input.offerEnabled
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create the marker so the offer shows only once. Best-effort — a marker we
|
|
59
|
+
* can't write means the offer may show again, but it must never crash adopt.
|
|
60
|
+
*/
|
|
61
|
+
export function markIntegrationOffered(): void {
|
|
62
|
+
const path = integrationOfferMarkerPath();
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
65
|
+
writeFileSync(path, new Date().toISOString());
|
|
66
|
+
} catch {
|
|
67
|
+
// can't write the marker — degrade to "may offer again", never throw
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* PURE — the offer prompt text shown inside the popup. A single question with a
|
|
73
|
+
* one-line rationale; the `[y/N]` makes the safe default (skip) obvious.
|
|
74
|
+
*/
|
|
75
|
+
export function buildOfferText(): string {
|
|
76
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
77
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
|
78
|
+
const head = (s: string) => `\x1b[1;36m${s}\x1b[0m`;
|
|
79
|
+
return [
|
|
80
|
+
head(" Claude Code detected"),
|
|
81
|
+
"",
|
|
82
|
+
" Install the tmux-ide integration for ground-truth agent status?",
|
|
83
|
+
dim(" It hooks Claude Code's lifecycle so pane state is exact, not guessed."),
|
|
84
|
+
"",
|
|
85
|
+
` ${bold("[y]")} install ${bold("[N]")} skip (any other key)`,
|
|
86
|
+
dim(" Asked once — press a key."),
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* io — float the one-time offer popup on the CURRENT tmux client, best-effort.
|
|
92
|
+
*
|
|
93
|
+
* Called at the end of {@link ../chrome/statusline.ts adoptSession} (right after
|
|
94
|
+
* the welcome card). Gated by {@link shouldOfferIntegration} (PATH + integration
|
|
95
|
+
* status + marker + config) AND by being inside a tmux client — outside tmux
|
|
96
|
+
* there's nowhere to float the popup, so we neither show it nor burn the marker
|
|
97
|
+
* (the next in-tmux adopt still offers).
|
|
98
|
+
*
|
|
99
|
+
* The popup runs `tmux-ide integration offer`, which reads one key and installs
|
|
100
|
+
* on `y` (see bin/cli.ts). It's spawned DETACHED and unref'd so it never blocks
|
|
101
|
+
* the adopt. The marker is written immediately after the spawn attempt so a
|
|
102
|
+
* rapid `adopt --all` offers exactly once, not once per session — matching the
|
|
103
|
+
* welcome card's one-shot discipline.
|
|
104
|
+
*/
|
|
105
|
+
export function maybeOfferIntegrationPopup(): void {
|
|
106
|
+
let offer: boolean;
|
|
107
|
+
try {
|
|
108
|
+
const status = claudeIntegrationStatus();
|
|
109
|
+
offer = shouldOfferIntegration({
|
|
110
|
+
claudeOnPath: claudeOnPath(),
|
|
111
|
+
integrationInstalled: status.installed,
|
|
112
|
+
markerPresent: existsSync(integrationOfferMarkerPath()),
|
|
113
|
+
offerEnabled: getAppConfig().integrations.offer,
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
return; // any probe failure → never risk the adopt, just don't offer
|
|
117
|
+
}
|
|
118
|
+
if (!offer) return;
|
|
119
|
+
if (!process.env.TMUX) return;
|
|
120
|
+
try {
|
|
121
|
+
const child = spawn(
|
|
122
|
+
"tmux",
|
|
123
|
+
["display-popup", "-E", "-w", "64", "-h", "12", "tmux-ide integration offer"],
|
|
124
|
+
{ stdio: "ignore", detached: true },
|
|
125
|
+
);
|
|
126
|
+
child.unref();
|
|
127
|
+
} catch {
|
|
128
|
+
// tmux missing / no client — best-effort, still mark so we don't retry forever
|
|
129
|
+
}
|
|
130
|
+
markIntegrationOffered();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* io — is `claude` on PATH? A which-probe capped at 2s, swallowing every failure
|
|
135
|
+
* to false. Local (not {@link ../../lib/agent-discovery.ts}) to keep the adopt
|
|
136
|
+
* path's offer check a single cheap probe rather than the full agent sweep.
|
|
137
|
+
*/
|
|
138
|
+
function claudeOnPath(): boolean {
|
|
139
|
+
try {
|
|
140
|
+
execFileSync("which", ["claude"], { stdio: "ignore", timeout: 2000 });
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatcher entry for the compiled `tmux-ide-tui` binary.
|
|
3
|
+
*
|
|
4
|
+
* `bun build --compile` bundles every TUI surface (the cockpit + widgets) into
|
|
5
|
+
* ONE standalone executable so the OpenTUI/Solid `.tsx` surfaces run on a clean
|
|
6
|
+
* `npm i -g tmux-ide` — no dev checkout, no `bun` runtime, no bunfig preload.
|
|
7
|
+
* The native OpenTUI dylib rides along via Bun's embedded-asset mechanism (the
|
|
8
|
+
* `import ... with { type: "file" }` in @opentui/core-*), and the Solid JSX
|
|
9
|
+
* transform happens at build time via the @opentui/solid bun plugin, so the
|
|
10
|
+
* binary carries only plain JS.
|
|
11
|
+
*
|
|
12
|
+
* Contract: `tmux-ide-tui <surface> [--flags…]`. The first positional selects
|
|
13
|
+
* the surface; the rest are the surface's own args (theme, session, dir, …),
|
|
14
|
+
* exactly as the `bun <entry> …` invocation passed them. We strip the surface
|
|
15
|
+
* token from `process.argv` before importing so each entry's top-level
|
|
16
|
+
* `parseArgs()` sees the same argv it always has.
|
|
17
|
+
*
|
|
18
|
+
* The `import()` calls use LITERAL specifiers on purpose: Bun's bundler only
|
|
19
|
+
* embeds dynamic imports it can resolve statically, and a literal switch keeps
|
|
20
|
+
* exactly one surface's top-level `render()` side effect from firing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SURFACES = [
|
|
24
|
+
"team",
|
|
25
|
+
"app",
|
|
26
|
+
"explorer",
|
|
27
|
+
"changes",
|
|
28
|
+
"preview",
|
|
29
|
+
"config",
|
|
30
|
+
"setup",
|
|
31
|
+
"sidebar",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
type Surface = (typeof SURFACES)[number];
|
|
35
|
+
|
|
36
|
+
function isSurface(value: string | undefined): value is Surface {
|
|
37
|
+
return value !== undefined && (SURFACES as readonly string[]).includes(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main(): Promise<void> {
|
|
41
|
+
const surface = process.argv[2];
|
|
42
|
+
|
|
43
|
+
if (!isSurface(surface)) {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
`tmux-ide-tui: unknown surface ${surface ? `"${surface}"` : "(none given)"}.\n` +
|
|
46
|
+
`Usage: tmux-ide-tui <${SURFACES.join("|")}> [flags]\n`,
|
|
47
|
+
);
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Drop the surface token so each entry's `parseArgs()` sees only its flags.
|
|
52
|
+
process.argv.splice(2, 1);
|
|
53
|
+
|
|
54
|
+
switch (surface) {
|
|
55
|
+
case "team":
|
|
56
|
+
await import("./team/index.tsx");
|
|
57
|
+
break;
|
|
58
|
+
case "app":
|
|
59
|
+
await import("./mirror/app.tsx");
|
|
60
|
+
break;
|
|
61
|
+
case "explorer":
|
|
62
|
+
await import("../widgets/explorer/index.tsx");
|
|
63
|
+
break;
|
|
64
|
+
case "changes":
|
|
65
|
+
await import("../widgets/changes/index.tsx");
|
|
66
|
+
break;
|
|
67
|
+
case "preview":
|
|
68
|
+
await import("../widgets/preview/index.tsx");
|
|
69
|
+
break;
|
|
70
|
+
case "config":
|
|
71
|
+
await import("../widgets/config/index.tsx");
|
|
72
|
+
break;
|
|
73
|
+
case "setup":
|
|
74
|
+
await import("../widgets/setup/index.tsx");
|
|
75
|
+
break;
|
|
76
|
+
case "sidebar":
|
|
77
|
+
await import("../widgets/sidebar/index.tsx");
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
void main();
|