tmux-ide 2.6.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -14
- package/bin/cli.js +4174 -1235
- package/bin/cli.ts +426 -72
- package/package.json +10 -6
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- package/packages/daemon/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 +34 -6
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +194 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +737 -52
- package/packages/daemon/src/tui/chrome/updater.ts +318 -30
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +49 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
- package/packages/daemon/src/tui/detect/manifest.ts +39 -3
- package/packages/daemon/src/tui/detect/manifests.ts +395 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/main.ts +13 -1
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
- package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +322 -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/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
- package/packages/daemon/src/tui/mirror/palette.ts +564 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -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 +376 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +130 -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/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +184 -17
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- package/skill/SKILL.md +110 -2
- 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
- package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent session-id capture — stamping `@agent_session_id` for agents whose
|
|
3
|
+
* CLIs don't announce their session id the way Claude Code's hooks do.
|
|
4
|
+
*
|
|
5
|
+
* WHY: `tmux-ide restore --resume-agents` revives a pane's conversation via its
|
|
6
|
+
* kind's verified resume invocation ({@link ../../restore.ts AGENT_RESUME_COMMANDS}),
|
|
7
|
+
* keyed on the pane-local `@agent_session_id` option. Claude's integration
|
|
8
|
+
* records it from lifecycle hooks; opencode's integration records it from a
|
|
9
|
+
* plugin. codex and cursor-agent expose NO hook surface, but both keep their
|
|
10
|
+
* session's identity ON DISK in a deterministic place — the exact layout their
|
|
11
|
+
* own `resume` pickers read. This module probes that, per pane, and stamps the
|
|
12
|
+
* option so restore needs no changes.
|
|
13
|
+
*
|
|
14
|
+
* Per-kind mechanism (verdicts investigated 2026-07, versions noted):
|
|
15
|
+
*
|
|
16
|
+
* - `codex` (codex-cli 0.144.1, VERIFIED LIVE): every session appends to
|
|
17
|
+
* `~/.codex/sessions/YYYY/MM/DD/rollout-<local-ts>-<uuid>.jsonl`; the uuid in
|
|
18
|
+
* the filename IS the id `codex resume <id>` accepts (verified end-to-end:
|
|
19
|
+
* quit + `codex resume <uuid>` revived the conversation). Two probes:
|
|
20
|
+
* 1. OPEN FILES (exact): the codex process holds its rollout file open for
|
|
21
|
+
* write for the whole session — including across a resume, which
|
|
22
|
+
* re-opens the SAME file (verified via lsof, fd `34w`). Pane pid →
|
|
23
|
+
* process subtree → codex pid(s) → open files → rollout filename.
|
|
24
|
+
* 2. STATE DIR (fallback, when lsof/procfs is unavailable): newest rollout
|
|
25
|
+
* whose filename timestamp is at/after the agent process start and whose
|
|
26
|
+
* first-line `session_meta` records `cwd` == the pane's cwd. Subagent
|
|
27
|
+
* threads (`payload.source.subagent` / `thread_source: "subagent"`) are
|
|
28
|
+
* skipped — they live in the same dir with the same cwd.
|
|
29
|
+
* The rollout file only exists after the FIRST turn (verified: a fresh TUI
|
|
30
|
+
* writes nothing until a message is sent), so capture keeps retrying until
|
|
31
|
+
* the session materializes.
|
|
32
|
+
*
|
|
33
|
+
* - `cursor` (cursor-agent 2026.04.30): chats live at
|
|
34
|
+
* `~/.cursor/chats/<md5-hex-of-cwd>/<chatId>/store.db` — the md5(cwd) dir
|
|
35
|
+
* naming is VERIFIED against a real state dir, and this layout is what
|
|
36
|
+
* `cursor-agent --resume [chatId]` / `cursor-agent ls` themselves read. Same
|
|
37
|
+
* two probes: open `store.db` handles first (sqlite stays open), else the
|
|
38
|
+
* newest chat dir under md5(pane cwd) modified at/after the agent process
|
|
39
|
+
* start. A live working session could not be driven on this machine (the CLI
|
|
40
|
+
* sat on its pre-auth login screen — same limitation the detection manifest
|
|
41
|
+
* notes), so the cursor probes are exercised against fixtures shaped from
|
|
42
|
+
* the real on-disk layout.
|
|
43
|
+
*
|
|
44
|
+
* - `claude` / `opencode`: captured by their integrations (hooks / plugin) —
|
|
45
|
+
* deliberately NOT probed here; an integration-stamped id always wins
|
|
46
|
+
* because capture only ever fills EMPTY stamps.
|
|
47
|
+
*
|
|
48
|
+
* - `copilot`: SKIPPED (documented, revisit with a real install). Two known
|
|
49
|
+
* surfaces, neither shippable blind: (1) Copilot CLI has Claude-style
|
|
50
|
+
* lifecycle hooks (docs.github.com/en/copilot/reference/hooks-configuration)
|
|
51
|
+
* whose payloads carry the session id + cwd — the RIGHT future path, an
|
|
52
|
+
* `integration install copilot` — but the payload key casing is known to
|
|
53
|
+
* flip (sessionId vs session_id) and the hooks-config schema couldn't be
|
|
54
|
+
* verified against a real install (the CLI isn't installed here); a wrong
|
|
55
|
+
* hook config risks breaking the user's copilot startup. (2) Disk probing
|
|
56
|
+
* `~/.copilot/session-state/<uuid>/workspace.yaml` (id/cwd/created_at) is
|
|
57
|
+
* viable in principle but the field shape is confirmed only by secondary
|
|
58
|
+
* sources, `--resume` currently spawns phantom EMPTY session dirs
|
|
59
|
+
* (github/copilot-cli#3908) that break newest-dir heuristics, and the
|
|
60
|
+
* legacy `history-session-state/` rename shows the layout still moves.
|
|
61
|
+
*
|
|
62
|
+
* - `gemini` / `aider` / `goose` / `amp`: SKIPPED — no verified native resume
|
|
63
|
+
* invocation in {@link ../../restore.ts AGENT_RESUME_COMMANDS}, so a
|
|
64
|
+
* captured id would have nothing to feed.
|
|
65
|
+
*
|
|
66
|
+
* Shape: pure probe logic + a stateful, throttled capturer the chrome updater
|
|
67
|
+
* pulses each tick; all io (ps/lsof/procfs/fs) is injectable so every decision
|
|
68
|
+
* path is unit-tested against fixtures.
|
|
69
|
+
*/
|
|
70
|
+
import { execFileSync } from "node:child_process";
|
|
71
|
+
import { createHash } from "node:crypto";
|
|
72
|
+
import { readdirSync, readFileSync, readlinkSync, statSync } from "node:fs";
|
|
73
|
+
import { homedir } from "node:os";
|
|
74
|
+
import { join } from "node:path";
|
|
75
|
+
import { readProcessTable, subtreeEntries, commandTokens, type ProcEntry } from "./process-tree.ts";
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Pure — id extraction from open-file paths
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** Same trust gate restore applies: uuid-ish only, nothing shell-active. */
|
|
82
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
83
|
+
|
|
84
|
+
/** `…/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl` → the uuid (codex's resume key). */
|
|
85
|
+
const CODEX_ROLLOUT_RE =
|
|
86
|
+
/\/rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.jsonl$/;
|
|
87
|
+
|
|
88
|
+
/** `…/.cursor/chats/<md5(cwd)>/<chatId>/store.db` → the chatId (cursor's resume key). */
|
|
89
|
+
const CURSOR_STORE_RE = /\/\.cursor\/chats\/[0-9a-f]{32}\/([A-Za-z0-9-]+)\/store\.db$/;
|
|
90
|
+
|
|
91
|
+
/** PURE — codex session id from a process's open-file paths, or null. */
|
|
92
|
+
export function codexIdFromOpenFiles(paths: string[]): string | null {
|
|
93
|
+
for (const path of paths) {
|
|
94
|
+
const match = CODEX_ROLLOUT_RE.exec(path);
|
|
95
|
+
if (match?.[1]) return match[1];
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** PURE — cursor chat id from a process's open-file paths, or null. */
|
|
101
|
+
export function cursorIdFromOpenFiles(paths: string[]): string | null {
|
|
102
|
+
for (const path of paths) {
|
|
103
|
+
const match = CURSOR_STORE_RE.exec(path);
|
|
104
|
+
if (match?.[1] && SAFE_SESSION_ID.test(match[1])) return match[1];
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Pure — process helpers
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* PURE — parse `ps -o etime=` output (`[[dd-]hh:]mm:ss`) into seconds, or null
|
|
115
|
+
* for anything malformed. The etime format is the portable way to recover a
|
|
116
|
+
* process's start time (start ≈ now − etime) without locale-dependent `lstart`
|
|
117
|
+
* parsing.
|
|
118
|
+
*/
|
|
119
|
+
export function parseEtimeSeconds(raw: string): number | null {
|
|
120
|
+
const trimmed = raw.trim();
|
|
121
|
+
const match = /^(?:(\d+)-)?(?:(\d+):)?(\d{1,2}):(\d{2})$/.exec(trimmed);
|
|
122
|
+
if (!match) return null;
|
|
123
|
+
const days = Number(match[1] ?? 0);
|
|
124
|
+
const hours = Number(match[2] ?? 0);
|
|
125
|
+
const minutes = Number(match[3]);
|
|
126
|
+
const seconds = Number(match[4]);
|
|
127
|
+
if (minutes >= 60 || seconds >= 60 || (match[2] !== undefined && hours >= 24)) return null;
|
|
128
|
+
return ((days * 24 + hours) * 60 + minutes) * 60 + seconds;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* PURE — the pids under `panePid` whose command tokens include one of the
|
|
133
|
+
* kind's binary names (e.g. codex's `node` shim AND its vendor binary both
|
|
134
|
+
* carry the `codex` token). These are the candidates worth an open-files look.
|
|
135
|
+
*/
|
|
136
|
+
export function agentPidsInSubtree(table: ProcEntry[], panePid: number, bins: string[]): number[] {
|
|
137
|
+
const wanted = new Set(bins);
|
|
138
|
+
const pids: number[] = [];
|
|
139
|
+
for (const entry of subtreeEntries(table, panePid)) {
|
|
140
|
+
if (commandTokens(entry.command).some((token) => wanted.has(token))) pids.push(entry.pid);
|
|
141
|
+
}
|
|
142
|
+
return pids;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Pure — state-dir fallback probes (injectable fs)
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/** The minimal fs surface the state-dir probes need — injectable for fixtures. */
|
|
150
|
+
export interface StateDirIo {
|
|
151
|
+
/** Directory entry NAMES (not paths); [] when missing/unreadable. */
|
|
152
|
+
listDir: (path: string) => string[];
|
|
153
|
+
/** mtime in ms, or null when missing. */
|
|
154
|
+
mtimeMs: (path: string) => number | null;
|
|
155
|
+
/** First line of a file (utf8), or null. */
|
|
156
|
+
readFirstLine: (path: string) => string | null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Slack applied before the process start when comparing session timestamps —
|
|
160
|
+
* covers clock/etime rounding, not ambiguity (a session can't predate its CLI). */
|
|
161
|
+
const START_SLACK_MS = 120_000;
|
|
162
|
+
|
|
163
|
+
/** How many day-directories back the codex fallback scans (start-date → today,
|
|
164
|
+
* capped) — a resumable pane's session started while its process was alive. */
|
|
165
|
+
const MAX_SCAN_DAYS = 7;
|
|
166
|
+
|
|
167
|
+
/** `rollout-2026-07-12T12-16-13-<uuid>.jsonl` → { tsMs (LOCAL time), id } | null. */
|
|
168
|
+
export function parseCodexRolloutName(name: string): { tsMs: number; id: string } | null {
|
|
169
|
+
const match =
|
|
170
|
+
/^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.jsonl$/.exec(
|
|
171
|
+
name,
|
|
172
|
+
);
|
|
173
|
+
if (!match) return null;
|
|
174
|
+
const [, y, mo, d, h, mi, s, id] = match;
|
|
175
|
+
// The filename timestamp is LOCAL time (verified: a 14:50Z session wrote
|
|
176
|
+
// rollout-…T16-50-38 under CEST) — construct via local Date components.
|
|
177
|
+
const tsMs = new Date(
|
|
178
|
+
Number(y),
|
|
179
|
+
Number(mo) - 1,
|
|
180
|
+
Number(d),
|
|
181
|
+
Number(h),
|
|
182
|
+
Number(mi),
|
|
183
|
+
Number(s),
|
|
184
|
+
).getTime();
|
|
185
|
+
return Number.isFinite(tsMs) && id ? { tsMs, id } : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A parsed codex `session_meta` first line — only the fields the probe reads. */
|
|
189
|
+
interface CodexSessionMeta {
|
|
190
|
+
cwd?: string;
|
|
191
|
+
source?: unknown;
|
|
192
|
+
thread_source?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** PURE-ish (io-injected) — codex fallback: newest non-subagent rollout under
|
|
196
|
+
* `root` started at/after `startMs` whose recorded cwd matches the pane's. */
|
|
197
|
+
export function codexIdFromStateDir(
|
|
198
|
+
root: string,
|
|
199
|
+
paneCwd: string,
|
|
200
|
+
startMs: number,
|
|
201
|
+
io: StateDirIo,
|
|
202
|
+
nowMs: number = Date.now(),
|
|
203
|
+
): string | null {
|
|
204
|
+
const cutoff = startMs - START_SLACK_MS;
|
|
205
|
+
// Enumerate day dirs from the start date to today (session files are laid out
|
|
206
|
+
// by LOCAL date, matching the filename timestamps).
|
|
207
|
+
const candidates: Array<{ tsMs: number; path: string; id: string }> = [];
|
|
208
|
+
for (let offset = 0; offset <= MAX_SCAN_DAYS; offset++) {
|
|
209
|
+
const day = new Date(nowMs - offset * 86_400_000);
|
|
210
|
+
if (day.getTime() < cutoff - 86_400_000) break;
|
|
211
|
+
const dir = join(
|
|
212
|
+
root,
|
|
213
|
+
String(day.getFullYear()),
|
|
214
|
+
String(day.getMonth() + 1).padStart(2, "0"),
|
|
215
|
+
String(day.getDate()).padStart(2, "0"),
|
|
216
|
+
);
|
|
217
|
+
for (const name of io.listDir(dir)) {
|
|
218
|
+
const parsed = parseCodexRolloutName(name);
|
|
219
|
+
if (parsed && parsed.tsMs >= cutoff && parsed.tsMs <= nowMs + START_SLACK_MS) {
|
|
220
|
+
candidates.push({ tsMs: parsed.tsMs, path: join(dir, name), id: parsed.id });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
candidates.sort((a, b) => b.tsMs - a.tsMs);
|
|
225
|
+
for (const candidate of candidates) {
|
|
226
|
+
const line = io.readFirstLine(candidate.path);
|
|
227
|
+
if (!line) continue;
|
|
228
|
+
let meta: CodexSessionMeta | undefined;
|
|
229
|
+
try {
|
|
230
|
+
meta = (JSON.parse(line) as { payload?: CodexSessionMeta }).payload;
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!meta || meta.cwd !== paneCwd) continue;
|
|
235
|
+
// Skip subagent threads — same dir, same cwd, but not the pane's own
|
|
236
|
+
// conversation (their meta carries subagent provenance).
|
|
237
|
+
if (meta.thread_source === "subagent") continue;
|
|
238
|
+
if (typeof meta.source === "object" && meta.source !== null && "subagent" in meta.source) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
return candidate.id;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** PURE-ish (io-injected) — cursor fallback: newest chat dir under
|
|
247
|
+
* `chats/<md5(pane cwd)>` modified at/after the agent process start. */
|
|
248
|
+
export function cursorIdFromStateDir(
|
|
249
|
+
chatsRoot: string,
|
|
250
|
+
paneCwd: string,
|
|
251
|
+
startMs: number,
|
|
252
|
+
io: StateDirIo,
|
|
253
|
+
): string | null {
|
|
254
|
+
const hashed = join(chatsRoot, createHash("md5").update(paneCwd).digest("hex"));
|
|
255
|
+
const cutoff = startMs - START_SLACK_MS;
|
|
256
|
+
let best: { name: string; mtime: number } | null = null;
|
|
257
|
+
for (const name of io.listDir(hashed)) {
|
|
258
|
+
if (!SAFE_SESSION_ID.test(name)) continue;
|
|
259
|
+
const mtime = io.mtimeMs(join(hashed, name));
|
|
260
|
+
if (mtime === null || mtime < cutoff) continue;
|
|
261
|
+
if (!best || mtime > best.mtime) best = { name, mtime };
|
|
262
|
+
}
|
|
263
|
+
return best?.name ?? null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// The per-kind probe registry
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/** One capture-eligible pane, as the updater tick sees it. */
|
|
271
|
+
export interface CapturePane {
|
|
272
|
+
paneId: string;
|
|
273
|
+
/** Resolved agent kind (manifest id), or null for a non-agent pane. */
|
|
274
|
+
agent: string | null;
|
|
275
|
+
/** `pane_pid` — root of the pane's process tree. */
|
|
276
|
+
pid: number;
|
|
277
|
+
/** `pane_current_path`. */
|
|
278
|
+
dir: string;
|
|
279
|
+
/** Existing `@agent_session_id`, or null — capture only fills EMPTY stamps. */
|
|
280
|
+
sessionId: string | null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The io a live probe needs — injectable so probes are tested without ps/lsof. */
|
|
284
|
+
export interface ProbeIo {
|
|
285
|
+
processTable: () => ProcEntry[];
|
|
286
|
+
/** Absolute paths of a pid's open files ([] on any failure). */
|
|
287
|
+
openFiles: (pid: number) => string[];
|
|
288
|
+
/** Epoch ms a pid started at, or null. */
|
|
289
|
+
processStartMs: (pid: number) => number | null;
|
|
290
|
+
stateDir: StateDirIo;
|
|
291
|
+
/** State roots, overridable for tests. */
|
|
292
|
+
codexSessionsRoot: () => string;
|
|
293
|
+
cursorChatsRoot: () => string;
|
|
294
|
+
now: () => number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The binary tokens per kind worth an open-files look (see {@link agentPidsInSubtree}). */
|
|
298
|
+
const KIND_BINS: Record<string, string[]> = {
|
|
299
|
+
codex: ["codex", "codex.exe"],
|
|
300
|
+
cursor: ["cursor-agent", "cursor"],
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
function probeKind(pane: CapturePane, kind: "codex" | "cursor", io: ProbeIo): string | null {
|
|
304
|
+
const table = io.processTable();
|
|
305
|
+
const pids = agentPidsInSubtree(table, pane.pid, KIND_BINS[kind]!);
|
|
306
|
+
if (pids.length === 0) return null;
|
|
307
|
+
// 1. Exact: an open session file names the id directly.
|
|
308
|
+
const fromOpen = kind === "codex" ? codexIdFromOpenFiles : cursorIdFromOpenFiles;
|
|
309
|
+
for (const pid of pids) {
|
|
310
|
+
const id = fromOpen(io.openFiles(pid));
|
|
311
|
+
if (id) return id;
|
|
312
|
+
}
|
|
313
|
+
// 2. Fallback: deterministic state-dir probe anchored on the agent's start time.
|
|
314
|
+
const startMs = io.processStartMs(pids[0]!);
|
|
315
|
+
if (startMs === null) return null;
|
|
316
|
+
return kind === "codex"
|
|
317
|
+
? codexIdFromStateDir(io.codexSessionsRoot(), pane.dir, startMs, io.stateDir, io.now())
|
|
318
|
+
: cursorIdFromStateDir(io.cursorChatsRoot(), pane.dir, startMs, io.stateDir);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The kinds this module captures for, with their probes. claude/opencode are
|
|
323
|
+
* integration-captured (hooks/plugin); the rest of the resume table has no
|
|
324
|
+
* defensible disk surface (see the header verdicts).
|
|
325
|
+
*/
|
|
326
|
+
export const CAPTURE_PROBES: Record<string, (pane: CapturePane, io: ProbeIo) => string | null> = {
|
|
327
|
+
codex: (pane, io) => probeKind(pane, "codex", io),
|
|
328
|
+
cursor: (pane, io) => probeKind(pane, "cursor", io),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/** The kinds with a shipped probe (surfaced by `integration status`). */
|
|
332
|
+
export const PROBED_KINDS: readonly string[] = Object.keys(CAPTURE_PROBES);
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// The capturer — throttled, stamp-once, pulsed by the updater tick
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/** Probe cadence in updater ticks (~2s each) — sessions materialize lazily
|
|
339
|
+
* (codex writes its rollout only on the first turn), so capture RETRIES until
|
|
340
|
+
* a pane is stamped, but never more than once per window. */
|
|
341
|
+
export const CAPTURE_EVERY_TICKS = 5;
|
|
342
|
+
|
|
343
|
+
export interface SessionIdCapturerDeps {
|
|
344
|
+
/** Probe one pane → id | null. Defaults to {@link CAPTURE_PROBES} over live io. */
|
|
345
|
+
probe?: (pane: CapturePane) => string | null;
|
|
346
|
+
/** Stamp `@agent_session_id` on a pane. */
|
|
347
|
+
stamp: (paneId: string, id: string) => void;
|
|
348
|
+
everyTicks?: number;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface SessionIdCapturer {
|
|
352
|
+
/** Pulse with this tick's panes; probes at most every `everyTicks` pulses. */
|
|
353
|
+
onTick: (panes: CapturePane[]) => void;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Create the stateful capturer. Cheap by construction: panes are considered
|
|
358
|
+
* only when their resolved kind has a probe AND they carry no stamp yet (an
|
|
359
|
+
* integration-written or restore-re-stamped id short-circuits everything), and
|
|
360
|
+
* even then only every {@link CAPTURE_EVERY_TICKS} ticks. A stamped pane costs
|
|
361
|
+
* nothing on every later tick. Stamped ids are remembered so a pane is never
|
|
362
|
+
* re-stamped by us within one updater lifetime (the option itself is the
|
|
363
|
+
* durable record; the memory just bridges the ticks until the next scan
|
|
364
|
+
* reflects it).
|
|
365
|
+
*/
|
|
366
|
+
export function createSessionIdCapturer(deps: SessionIdCapturerDeps): SessionIdCapturer {
|
|
367
|
+
const every = deps.everyTicks ?? CAPTURE_EVERY_TICKS;
|
|
368
|
+
const probe = deps.probe ?? ((pane: CapturePane) => defaultProbe(pane));
|
|
369
|
+
const stampedByUs = new Set<string>();
|
|
370
|
+
let ticks = 0;
|
|
371
|
+
return {
|
|
372
|
+
onTick(panes: CapturePane[]): void {
|
|
373
|
+
ticks++;
|
|
374
|
+
if (every <= 0 || ticks % every !== 0) return;
|
|
375
|
+
for (const pane of panes) {
|
|
376
|
+
if (!pane.agent || pane.sessionId || stampedByUs.has(pane.paneId)) continue;
|
|
377
|
+
const kindProbe = CAPTURE_PROBES[pane.agent];
|
|
378
|
+
if (!kindProbe) continue;
|
|
379
|
+
let id: string | null;
|
|
380
|
+
try {
|
|
381
|
+
id = probe(pane);
|
|
382
|
+
} catch {
|
|
383
|
+
continue; // a failed probe must never hurt the tick
|
|
384
|
+
}
|
|
385
|
+
if (!id || !SAFE_SESSION_ID.test(id)) continue;
|
|
386
|
+
try {
|
|
387
|
+
deps.stamp(pane.paneId, id);
|
|
388
|
+
// Marked only AFTER a successful stamp — a failed set-option retries
|
|
389
|
+
// on the next capture window instead of silently never stamping.
|
|
390
|
+
stampedByUs.add(pane.paneId);
|
|
391
|
+
} catch {
|
|
392
|
+
// stamp failed (pane gone / tmux hiccup) — retry next window
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// io — the live defaults
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/** Live fs io for the state-dir probes. Never throws. */
|
|
404
|
+
export const liveStateDirIo: StateDirIo = {
|
|
405
|
+
listDir: (path) => {
|
|
406
|
+
try {
|
|
407
|
+
return readdirSync(path);
|
|
408
|
+
} catch {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
mtimeMs: (path) => {
|
|
413
|
+
try {
|
|
414
|
+
return statSync(path).mtimeMs;
|
|
415
|
+
} catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
readFirstLine: (path) => {
|
|
420
|
+
try {
|
|
421
|
+
// Session-meta lines are small; a bounded read keeps this cheap even on
|
|
422
|
+
// a large rollout file.
|
|
423
|
+
const fd = readFileSync(path, { encoding: "utf8", flag: "r" });
|
|
424
|
+
const newline = fd.indexOf("\n");
|
|
425
|
+
return newline === -1 ? fd : fd.slice(0, newline);
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* io — a pid's open regular files: `/proc/<pid>/fd` on Linux (no subprocess),
|
|
434
|
+
* `lsof -p <pid>` elsewhere. [] on any failure — the state-dir fallback covers.
|
|
435
|
+
*/
|
|
436
|
+
export function readOpenFiles(pid: number): string[] {
|
|
437
|
+
// Linux: readlink the fd table directly.
|
|
438
|
+
try {
|
|
439
|
+
const fdDir = `/proc/${pid}/fd`;
|
|
440
|
+
const names = readdirSync(fdDir);
|
|
441
|
+
const paths: string[] = [];
|
|
442
|
+
for (const name of names) {
|
|
443
|
+
try {
|
|
444
|
+
const target = readlinkSync(join(fdDir, name));
|
|
445
|
+
if (target.startsWith("/")) paths.push(target);
|
|
446
|
+
} catch {
|
|
447
|
+
// fd raced away — skip
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return paths;
|
|
451
|
+
} catch {
|
|
452
|
+
// not Linux (or unreadable) — fall through to lsof
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const raw = execFileSync("lsof", ["-p", String(pid), "-Fn"], {
|
|
456
|
+
encoding: "utf8",
|
|
457
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
458
|
+
timeout: 3000,
|
|
459
|
+
});
|
|
460
|
+
// -Fn output: one field per line; `n<path>` lines carry the name.
|
|
461
|
+
return raw
|
|
462
|
+
.split("\n")
|
|
463
|
+
.filter((line) => line.startsWith("n/"))
|
|
464
|
+
.map((line) => line.slice(1));
|
|
465
|
+
} catch {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** io — a pid's start time via `ps -o etime=` (start ≈ now − elapsed). */
|
|
471
|
+
export function processStartMs(pid: number, nowMs: number = Date.now()): number | null {
|
|
472
|
+
try {
|
|
473
|
+
const raw = execFileSync("ps", ["-o", "etime=", "-p", String(pid)], {
|
|
474
|
+
encoding: "utf8",
|
|
475
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
476
|
+
timeout: 2000,
|
|
477
|
+
});
|
|
478
|
+
const seconds = parseEtimeSeconds(raw);
|
|
479
|
+
return seconds === null ? null : nowMs - seconds * 1000;
|
|
480
|
+
} catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The live {@link ProbeIo}. State roots honor test overrides. */
|
|
486
|
+
export function liveProbeIo(): ProbeIo {
|
|
487
|
+
return {
|
|
488
|
+
processTable: readProcessTable,
|
|
489
|
+
openFiles: readOpenFiles,
|
|
490
|
+
processStartMs: (pid) => processStartMs(pid),
|
|
491
|
+
stateDir: liveStateDirIo,
|
|
492
|
+
codexSessionsRoot: () =>
|
|
493
|
+
process.env.TMUX_IDE_CODEX_SESSIONS ?? join(homedir(), ".codex", "sessions"),
|
|
494
|
+
cursorChatsRoot: () => process.env.TMUX_IDE_CURSOR_CHATS ?? join(homedir(), ".cursor", "chats"),
|
|
495
|
+
now: () => Date.now(),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The default live probe: kind-dispatched over {@link liveProbeIo}. */
|
|
500
|
+
export function defaultProbe(pane: CapturePane): string | null {
|
|
501
|
+
const kindProbe = pane.agent ? CAPTURE_PROBES[pane.agent] : undefined;
|
|
502
|
+
return kindProbe ? kindProbe(pane, liveProbeIo()) : null;
|
|
503
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode integration — session-id capture via a first-class opencode plugin.
|
|
3
|
+
*
|
|
4
|
+
* opencode (v1.17.x) auto-loads ESM plugins from the GLOBAL plugin dir
|
|
5
|
+
* (`~/.config/opencode/plugin/*.js` — XDG-aware) at startup; a plugin runs
|
|
6
|
+
* IN-PROCESS (Bun), so `process.env.TMUX_PANE` is exactly the pane opencode
|
|
7
|
+
* lives in, and its `event` hook sees the session lifecycle. That's the same
|
|
8
|
+
* authority-shaped surface Claude Code's hooks give us, so this integration
|
|
9
|
+
* mirrors {@link ./claude.ts}: `tmux-ide integration install opencode` writes
|
|
10
|
+
* one marker-tagged plugin file; uninstall removes exactly that file.
|
|
11
|
+
*
|
|
12
|
+
* The plugin stamps ONLY `@agent_session_id` (the key
|
|
13
|
+
* `tmux-ide restore --resume-agents` feeds to `opencode --session <id>`):
|
|
14
|
+
* - `session.updated` fires on create + every update; the id lives at
|
|
15
|
+
* `event.properties.info.id` (format `ses_<base62>`).
|
|
16
|
+
* - Child/subagent sessions carry `info.parentID` — those are NOT the pane's
|
|
17
|
+
* resumable conversation, so they never stamp.
|
|
18
|
+
* - `@agent_state` is deliberately NOT stamped: `session.updated` is not a
|
|
19
|
+
* truthful working/blocked signal, and a wrong authority stamp would
|
|
20
|
+
* suppress correct screen-scraping for the staleness window.
|
|
21
|
+
*
|
|
22
|
+
* Install takes effect for NEW opencode sessions (plugins load at startup).
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Marker every installed plugin contains — the ownership/removal key. */
|
|
29
|
+
export const PLUGIN_MARKER = "installed by: tmux-ide integration install opencode";
|
|
30
|
+
|
|
31
|
+
/** The plugin filename inside opencode's global plugin dir. */
|
|
32
|
+
export const PLUGIN_FILENAME = "tmux-ide.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Absolute path to the plugin file: `TMUX_IDE_OPENCODE_DIR` when set (tests /
|
|
36
|
+
* per-run overrides — points at the PLUGIN DIR), else XDG config, else
|
|
37
|
+
* `~/.config/opencode/plugin/`. The override keeps tests away from real state,
|
|
38
|
+
* matching `TMUX_IDE_CLAUDE_SETTINGS`.
|
|
39
|
+
*/
|
|
40
|
+
export function opencodePluginPath(): string {
|
|
41
|
+
const override = process.env.TMUX_IDE_OPENCODE_DIR;
|
|
42
|
+
if (override) return join(override, PLUGIN_FILENAME);
|
|
43
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
44
|
+
const configRoot = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
|
|
45
|
+
return join(configRoot, "opencode", "plugin", PLUGIN_FILENAME);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The plugin source. Plain ESM JS, zero dependencies beyond node builtins
|
|
50
|
+
* (opencode runs plugins under Bun, which provides them). Defensive by
|
|
51
|
+
* construction: no TMUX pane → inert; malformed events → ignored; the id is
|
|
52
|
+
* gated on the same uuid-ish charset restore trusts before it ever reaches
|
|
53
|
+
* `set-option`.
|
|
54
|
+
*/
|
|
55
|
+
export const PLUGIN_SOURCE = `/**
|
|
56
|
+
* tmux-ide opencode plugin (${PLUGIN_MARKER})
|
|
57
|
+
*
|
|
58
|
+
* Stamps this pane's @agent_session_id tmux option with the opencode session
|
|
59
|
+
* id so \`tmux-ide restore --resume-agents\` can revive the conversation via
|
|
60
|
+
* \`opencode --session <id>\` after a tmux server death.
|
|
61
|
+
*
|
|
62
|
+
* Remove with: tmux-ide integration uninstall opencode
|
|
63
|
+
*/
|
|
64
|
+
export const TmuxIde = async () => {
|
|
65
|
+
const pane = process.env.TMUX_PANE;
|
|
66
|
+
if (!pane) return {}; // not inside tmux — inert
|
|
67
|
+
const { execFile } = await import("node:child_process");
|
|
68
|
+
let last = "";
|
|
69
|
+
const stamp = (id) => {
|
|
70
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id) || id === last) return;
|
|
71
|
+
last = id;
|
|
72
|
+
execFile("tmux", ["set-option", "-p", "-t", pane, "@agent_session_id", id], () => {});
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
event: async ({ event }) => {
|
|
76
|
+
// session.updated fires on create + every update; info.id is the
|
|
77
|
+
// resumable session id. Child sessions (subagents) carry parentID and
|
|
78
|
+
// must never overwrite the pane's own conversation key.
|
|
79
|
+
if (event && event.type === "session.updated") {
|
|
80
|
+
const info = event.properties && event.properties.info;
|
|
81
|
+
if (info && !info.parentID) stamp(info.id);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
/** PURE — is this plugin file content ours? (contains the install marker) */
|
|
89
|
+
export function isOurPlugin(content: string): boolean {
|
|
90
|
+
return content.includes(PLUGIN_MARKER);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Install: write the marker-tagged plugin file. Idempotent. */
|
|
94
|
+
export function installOpencodeIntegration(): { pluginPath: string } {
|
|
95
|
+
const pluginPath = opencodePluginPath();
|
|
96
|
+
mkdirSync(dirname(pluginPath), { recursive: true });
|
|
97
|
+
writeFileSync(pluginPath, PLUGIN_SOURCE, "utf8");
|
|
98
|
+
return { pluginPath };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Uninstall: remove the plugin file — but only when it's OURS (marker present),
|
|
103
|
+
* so a user's hand-written `tmux-ide.js` is never deleted.
|
|
104
|
+
*/
|
|
105
|
+
export function uninstallOpencodeIntegration(): { pluginPath: string; wasInstalled: boolean } {
|
|
106
|
+
const pluginPath = opencodePluginPath();
|
|
107
|
+
const wasInstalled = opencodeIntegrationStatus().installed;
|
|
108
|
+
if (wasInstalled) rmSync(pluginPath, { force: true });
|
|
109
|
+
return { pluginPath, wasInstalled };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether OUR plugin is installed (file exists AND carries the marker). */
|
|
113
|
+
export function opencodeIntegrationStatus(): { installed: boolean } {
|
|
114
|
+
const pluginPath = opencodePluginPath();
|
|
115
|
+
try {
|
|
116
|
+
if (!existsSync(pluginPath)) return { installed: false };
|
|
117
|
+
return { installed: isOurPlugin(readFileSync(pluginPath, "utf8")) };
|
|
118
|
+
} catch {
|
|
119
|
+
return { installed: false };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -20,7 +20,16 @@
|
|
|
20
20
|
* exactly one surface's top-level `render()` side effect from firing.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
const SURFACES = [
|
|
23
|
+
const SURFACES = [
|
|
24
|
+
"team",
|
|
25
|
+
"app",
|
|
26
|
+
"explorer",
|
|
27
|
+
"changes",
|
|
28
|
+
"preview",
|
|
29
|
+
"config",
|
|
30
|
+
"setup",
|
|
31
|
+
"sidebar",
|
|
32
|
+
] as const;
|
|
24
33
|
|
|
25
34
|
type Surface = (typeof SURFACES)[number];
|
|
26
35
|
|
|
@@ -46,6 +55,9 @@ async function main(): Promise<void> {
|
|
|
46
55
|
case "team":
|
|
47
56
|
await import("./team/index.tsx");
|
|
48
57
|
break;
|
|
58
|
+
case "app":
|
|
59
|
+
await import("./mirror/app.tsx");
|
|
60
|
+
break;
|
|
49
61
|
case "explorer":
|
|
50
62
|
await import("../widgets/explorer/index.tsx");
|
|
51
63
|
break;
|