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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-platform TUI binary: the runtime-download fallback that lets a clean
|
|
3
|
+
* `npm i -g tmux-ide` run the full OpenTUI/Solid cockpit WITHOUT `bun`.
|
|
4
|
+
*
|
|
5
|
+
* The dev checkout runs the `.tsx` surfaces via bun; an npm install with no bun
|
|
6
|
+
* needs a self-contained binary. We do NOT bundle that ~70MB blob in the npm
|
|
7
|
+
* tarball (a surprise on every install) — instead the release workflow
|
|
8
|
+
* (`.github/workflows/release-binaries.yml`) cross-compiles one per platform and
|
|
9
|
+
* uploads them as GitHub release assets, and this module downloads the right one
|
|
10
|
+
* on demand (explicit `tmux-ide update --tui-binary`, or a consented first run).
|
|
11
|
+
*
|
|
12
|
+
* The mapping/URL/path helpers are PURE (unit-tested); {@link downloadTuiBinary}
|
|
13
|
+
* and {@link findDownloadedTui} are the thin io that fetch and probe.
|
|
14
|
+
*/
|
|
15
|
+
import { chmodSync, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { gunzipSync } from "node:zlib";
|
|
19
|
+
import { getCurrentVersion } from "./update-check.ts";
|
|
20
|
+
|
|
21
|
+
/** The `<os>-<arch>` tags we publish a prebuilt TUI binary for. */
|
|
22
|
+
export type TuiPlatformTag = "darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64";
|
|
23
|
+
|
|
24
|
+
/** The GitHub repo the release assets live under. */
|
|
25
|
+
export const RELEASE_REPO = "wavyrai/tmux-ide";
|
|
26
|
+
|
|
27
|
+
/** A downloaded binary smaller than this is treated as corrupt/truncated. */
|
|
28
|
+
export const MIN_TUI_BINARY_BYTES = 10 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
const SUPPORTED: Record<string, TuiPlatformTag> = {
|
|
31
|
+
"darwin-arm64": "darwin-arm64",
|
|
32
|
+
"darwin-x64": "darwin-x64",
|
|
33
|
+
"linux-x64": "linux-x64",
|
|
34
|
+
"linux-arm64": "linux-arm64",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Pure
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* PURE — map a Node `process.platform`/`process.arch` pair to the release tag,
|
|
43
|
+
* or null when we don't publish a binary for it (e.g. windows, freebsd) so the
|
|
44
|
+
* caller can fall back to the "install bun" message.
|
|
45
|
+
*/
|
|
46
|
+
export function tuiPlatformTag(
|
|
47
|
+
platform: NodeJS.Platform = process.platform,
|
|
48
|
+
arch: string = process.arch,
|
|
49
|
+
): TuiPlatformTag | null {
|
|
50
|
+
return SUPPORTED[`${platform}-${arch}`] ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** PURE — the `bun build --compile --target` flag for a tag (`bun-<tag>`). */
|
|
54
|
+
export function bunTargetForTag(tag: TuiPlatformTag): string {
|
|
55
|
+
return `bun-${tag}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** PURE — the release asset filename for a tag (gzip-compressed binary). */
|
|
59
|
+
export function releaseAssetName(tag: TuiPlatformTag): string {
|
|
60
|
+
return `tmux-ide-tui-${tag}.gz`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** PURE — strip a leading `v` from a version string; `2.6.1` and `v2.6.1` both → `2.6.1`. */
|
|
64
|
+
export function normalizeVersion(version: string): string {
|
|
65
|
+
return version.startsWith("v") ? version.slice(1) : version;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* PURE — the GitHub download URL for a platform's asset at a given version:
|
|
70
|
+
* `https://github.com/<repo>/releases/download/v<version>/tmux-ide-tui-<tag>.gz`.
|
|
71
|
+
*/
|
|
72
|
+
export function releaseAssetUrl(version: string, tag: TuiPlatformTag): string {
|
|
73
|
+
return `https://github.com/${RELEASE_REPO}/releases/download/v${normalizeVersion(version)}/${releaseAssetName(tag)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* PURE — where a downloaded binary lives: `<home>/bin/tmux-ide-tui-<tag>-<version>`.
|
|
78
|
+
* The version is stamped INTO the name so a `tmux-ide update` to a new version
|
|
79
|
+
* misses the old download and re-fetches (rather than launching a stale binary).
|
|
80
|
+
*/
|
|
81
|
+
export function downloadedTuiPath(home: string, tag: TuiPlatformTag, version: string): string {
|
|
82
|
+
return join(home, "bin", `tmux-ide-tui-${tag}-${normalizeVersion(version)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// io
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* io — the tmux-ide state home (`TMUX_IDE_HOME` override, else `~/.tmux-ide`),
|
|
91
|
+
* the same resolution the update-check cache and welcome marker use.
|
|
92
|
+
*/
|
|
93
|
+
export function tuiStateHome(): string {
|
|
94
|
+
return process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* io — locate a previously downloaded per-platform binary for THIS version and
|
|
99
|
+
* platform, or null. Feeds the resolution order in `tui/compiled.ts` after the
|
|
100
|
+
* shipped/local compiled binary and before the honest "unavailable" error.
|
|
101
|
+
*/
|
|
102
|
+
export function findDownloadedTui(version: string = getCurrentVersion()): string | null {
|
|
103
|
+
const tag = tuiPlatformTag();
|
|
104
|
+
if (!tag) return null;
|
|
105
|
+
const path = downloadedTuiPath(tuiStateHome(), tag, version);
|
|
106
|
+
return existsSync(path) ? path : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* io — download, verify, and install the per-platform TUI binary for the running
|
|
111
|
+
* version. Fetches the gzip asset, inflates it, rejects anything under
|
|
112
|
+
* {@link MIN_TUI_BINARY_BYTES} (a truncated download or an HTML error page),
|
|
113
|
+
* writes it `0o755`, and atomically renames it into place (temp-in-same-dir →
|
|
114
|
+
* rename, so a crashed download never leaves a half-written executable).
|
|
115
|
+
*
|
|
116
|
+
* Throws with an actionable message on an unsupported platform or a failed
|
|
117
|
+
* fetch. Returns the installed path and byte size.
|
|
118
|
+
*/
|
|
119
|
+
export async function downloadTuiBinary(
|
|
120
|
+
opts: {
|
|
121
|
+
version?: string;
|
|
122
|
+
log?: (msg: string) => void;
|
|
123
|
+
} = {},
|
|
124
|
+
): Promise<{ path: string; bytes: number }> {
|
|
125
|
+
const log = opts.log ?? (() => {});
|
|
126
|
+
const version = normalizeVersion(opts.version ?? getCurrentVersion());
|
|
127
|
+
const tag = tuiPlatformTag();
|
|
128
|
+
if (!tag) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`no prebuilt TUI binary is published for ${process.platform}-${process.arch} — ` +
|
|
131
|
+
`install bun (https://bun.sh) to run the TUI surfaces from source instead`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const url = releaseAssetUrl(version, tag);
|
|
136
|
+
const dest = downloadedTuiPath(tuiStateHome(), tag, version);
|
|
137
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
138
|
+
|
|
139
|
+
log(`downloading ${url}`);
|
|
140
|
+
const res = await fetch(url);
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`could not download the TUI binary (${url} → HTTP ${res.status} ${res.statusText}). ` +
|
|
144
|
+
`Check that release v${version} exists and published its assets.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const gz = Buffer.from(await res.arrayBuffer());
|
|
149
|
+
const bin = gunzipSync(gz);
|
|
150
|
+
if (bin.byteLength < MIN_TUI_BINARY_BYTES) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`the downloaded TUI binary is only ${bin.byteLength} bytes (expected >10MB) — ` +
|
|
153
|
+
`treating it as corrupt and leaving the previous binary (if any) in place`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
158
|
+
writeFileSync(tmp, bin, { mode: 0o755 });
|
|
159
|
+
chmodSync(tmp, 0o755);
|
|
160
|
+
renameSync(tmp, dest);
|
|
161
|
+
|
|
162
|
+
const mb = (bin.byteLength / 1024 / 1024).toFixed(1);
|
|
163
|
+
log(`installed ${dest} (${mb} MB)`);
|
|
164
|
+
return { path: dest, bytes: bin.byteLength };
|
|
165
|
+
}
|
|
@@ -252,6 +252,11 @@ export function getUpdateStatus({
|
|
|
252
252
|
export async function runUpdateCheck({ now = Date.now() }: { now?: number } = {}): Promise<void> {
|
|
253
253
|
const cache = readUpdateCache();
|
|
254
254
|
if (!shouldCheck(cache?.lastCheckedAt ?? null, now)) return;
|
|
255
|
+
// The agent-detection manifest-pack refresh RIDES this same daily throttle
|
|
256
|
+
// (M25.4) — no timer of its own, gated again inside on `updates.manifests`
|
|
257
|
+
// (default false) and never throwing. Dynamic import: manifest-pack imports
|
|
258
|
+
// this module for getCurrentVersion, so a static import would be a cycle.
|
|
259
|
+
void import("./manifest-pack.ts").then((m) => m.maybeRefreshManifestPack()).catch(() => {});
|
|
255
260
|
const fetched = await fetchLatestVersion();
|
|
256
261
|
writeUpdateCache({
|
|
257
262
|
lastCheckedAt: now,
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
*
|
|
14
14
|
* `--resume-agents` (or `{ "restore": { "resumeAgents": true } }` in
|
|
15
15
|
* `~/.tmux-ide/config.json`) layers native agent-resume on top: a rebuilt
|
|
16
|
-
*
|
|
17
|
-
* `claude --resume <id>`,
|
|
18
|
-
*
|
|
16
|
+
* agent pane that carries a recorded `@agent_session_id` relaunches with its
|
|
17
|
+
* kind's VERIFIED resume invocation (`claude --resume <id>`, `codex resume
|
|
18
|
+
* <id>`, … — {@link AGENT_RESUME_COMMANDS}), reviving the actual conversation
|
|
19
|
+
* rather than opening a fresh shell. See {@link paneResumeCommand} for the
|
|
20
|
+
* exact decision table.
|
|
19
21
|
*
|
|
20
22
|
* {@link buildRestorePlan} + {@link paneResumeCommand} + {@link restorePrefs}
|
|
21
23
|
* are PURE (unit-tested); {@link restore} is the thin io wrapper that reads the
|
|
@@ -92,22 +94,57 @@ export function buildRestorePlan(
|
|
|
92
94
|
// Pure — agent resume decision
|
|
93
95
|
// ---------------------------------------------------------------------------
|
|
94
96
|
|
|
95
|
-
/** A session id is trusted only if it's
|
|
96
|
-
|
|
97
|
+
/** A session id is trusted only if it's uuid-ish (`[A-Za-z0-9_-]` — underscore
|
|
98
|
+
* covers opencode's `ses_…` ids) — nothing shell-active. */
|
|
99
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The native session-resume invocation per agent kind (M24.1 — the table was
|
|
103
|
+
* claude-only until then). ONLY VERIFIED entries ship; each spelling's source:
|
|
104
|
+
* - `claude --resume <id>`: the shipped integration (its hooks record the id).
|
|
105
|
+
* - `codex resume <id>`: `codex resume --help` — "Usage: codex resume
|
|
106
|
+
* [OPTIONS] [SESSION_ID] … Session id (UUID) or session name".
|
|
107
|
+
* - `opencode --session <id>`: `opencode --help` — "-s, --session session id
|
|
108
|
+
* to continue".
|
|
109
|
+
* - `cursor-agent --resume <id>`: `cursor-agent --help` — "--resume [chatId]
|
|
110
|
+
* Select a session to resume".
|
|
111
|
+
* - `copilot --resume=<id>`: GitHub Copilot CLI command reference —
|
|
112
|
+
* "--resume[=VALUE] Resume a previous interactive session … Optionally
|
|
113
|
+
* specify a session ID" (the `=` form, since the value is optional).
|
|
114
|
+
* Unverified (no entry): gemini, aider, goose, amp — no confirmed native
|
|
115
|
+
* per-session resume invocation at the time of writing.
|
|
116
|
+
*
|
|
117
|
+
* How `@agent_session_id` gets recorded per kind (M25.3):
|
|
118
|
+
* - claude — the hooks integration (`integration install claude`).
|
|
119
|
+
* - opencode — the plugin integration (`integration install opencode`).
|
|
120
|
+
* - codex, cursor — automatic: the chrome updater probes the agent's own
|
|
121
|
+
* on-disk session state ({@link ./tui/detect/session-id.ts}).
|
|
122
|
+
* - copilot — no automatic capture (skipped with evidence — see the
|
|
123
|
+
* session-id module header); fires only for panes whose agent self-reported
|
|
124
|
+
* the id per the agent contract
|
|
125
|
+
* (`tmux set-option -p @agent_session_id <id>`), which any kind may do.
|
|
126
|
+
*/
|
|
127
|
+
export const AGENT_RESUME_COMMANDS: Record<string, (id: string) => string> = {
|
|
128
|
+
claude: (id) => `claude --resume ${id}`,
|
|
129
|
+
codex: (id) => `codex resume ${id}`,
|
|
130
|
+
opencode: (id) => `opencode --session ${id}`,
|
|
131
|
+
cursor: (id) => `cursor-agent --resume ${id}`,
|
|
132
|
+
copilot: (id) => `copilot --resume=${id}`,
|
|
133
|
+
};
|
|
97
134
|
|
|
98
135
|
/**
|
|
99
136
|
* PURE — the command to relaunch a pane so its agent conversation is revived,
|
|
100
137
|
* or `null` for "no resume — leave it a plain shell (or replay `command`)".
|
|
101
138
|
*
|
|
102
139
|
* Decision table (only fires when `resumeAgents` is on):
|
|
103
|
-
* -
|
|
104
|
-
*
|
|
105
|
-
* {@link SAFE_SESSION_ID}
|
|
106
|
-
* feeding shell metacharacters into send-keys.
|
|
107
|
-
* -
|
|
108
|
-
* magical here (it resumes the most-recent conversation
|
|
109
|
-
* belong to a different pane); a fresh shell is the safe
|
|
110
|
-
* - any other agent → `null`. No
|
|
140
|
+
* - a kind in {@link AGENT_RESUME_COMMANDS} + a recorded `@agent_session_id` →
|
|
141
|
+
* that kind's resume invocation. The id is uuid-ish, but we don't trust the
|
|
142
|
+
* snapshot: unless it matches {@link SAFE_SESSION_ID} we bail to `null`
|
|
143
|
+
* rather than risk feeding shell metacharacters into send-keys.
|
|
144
|
+
* - a known kind with NO session id → `null`. A "continue most recent" flag
|
|
145
|
+
* would be too magical here (it resumes the cwd's most-recent conversation,
|
|
146
|
+
* which may belong to a different pane); a fresh shell is the safe default.
|
|
147
|
+
* - any other agent → `null`. No VERIFIED resume story.
|
|
111
148
|
* - `resumeAgents` off → always `null`.
|
|
112
149
|
*/
|
|
113
150
|
export function paneResumeCommand(
|
|
@@ -115,10 +152,11 @@ export function paneResumeCommand(
|
|
|
115
152
|
opts: { resumeAgents: boolean },
|
|
116
153
|
): string | null {
|
|
117
154
|
if (!opts.resumeAgents) return null;
|
|
118
|
-
|
|
155
|
+
const resume = pane.agent ? AGENT_RESUME_COMMANDS[pane.agent] : undefined;
|
|
156
|
+
if (!resume) return null;
|
|
119
157
|
const id = pane.agentSessionId;
|
|
120
158
|
if (!id || !SAFE_SESSION_ID.test(id)) return null;
|
|
121
|
-
return
|
|
159
|
+
return resume(id);
|
|
122
160
|
}
|
|
123
161
|
|
|
124
162
|
/** PURE — how many of a session's panes would resume under `resumeAgents`. */
|
|
@@ -86,24 +86,36 @@ function prepareMessage(message: string, busyStatus: PaneBusyStatus): string {
|
|
|
86
86
|
return message;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
89
|
+
/** What a delivery reports back (the `send --json` payload, and the control
|
|
90
|
+
* socket's `send` verb data). */
|
|
91
|
+
export interface DeliverResult {
|
|
92
|
+
ok: true;
|
|
93
|
+
session: string;
|
|
94
|
+
target: { paneId: string; name: string | null; title: string; role: string | null };
|
|
95
|
+
message: string;
|
|
96
|
+
busyStatus: PaneBusyStatus;
|
|
97
|
+
sentViaFile: boolean;
|
|
98
|
+
warning?: "agent_busy";
|
|
99
|
+
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Deliver `message` to a pane of `session` — the SHARED core behind the
|
|
103
|
+
* `tmux-ide send` CLI case and the control socket's `send` verb. Resolves
|
|
104
|
+
* `target` (pane id / @ide_name / title / role / partial title), adapts to
|
|
105
|
+
* the pane's busy status, and routes long messages through a dispatch file
|
|
106
|
+
* under `dir` when one is given (without a `dir` the text is sent directly).
|
|
107
|
+
* Throws `IdeError` (SESSION_NOT_FOUND / PANE_NOT_FOUND) — no printing here.
|
|
108
|
+
*/
|
|
109
|
+
export function deliverMessage(opts: {
|
|
110
|
+
session: string;
|
|
111
|
+
target: string;
|
|
112
|
+
message: string;
|
|
113
|
+
noEnter?: boolean;
|
|
114
|
+
/** The project dir long messages are dispatched through as a file. */
|
|
115
|
+
dir?: string;
|
|
116
|
+
}): DeliverResult {
|
|
117
|
+
const { session, target, noEnter, dir } = opts;
|
|
105
118
|
|
|
106
|
-
// Verify session is running
|
|
107
119
|
const state = getSessionState(session);
|
|
108
120
|
if (!state.running) {
|
|
109
121
|
throw new IdeError(`Session "${session}" is not running`, {
|
|
@@ -126,13 +138,13 @@ export async function send(targetDir: string | undefined, opts: SendOptions): Pr
|
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
const busyStatus = getPaneBusyStatus(session, pane.id);
|
|
129
|
-
const message = prepareMessage(
|
|
141
|
+
const message = prepareMessage(opts.message, busyStatus);
|
|
130
142
|
|
|
131
143
|
let sentViaFile = false;
|
|
132
144
|
if (noEnter) {
|
|
133
145
|
sendText(session, pane.id, message);
|
|
134
146
|
} else {
|
|
135
|
-
const dispatch = writeDispatchFile(dir, pane.id, message);
|
|
147
|
+
const dispatch = dir ? writeDispatchFile(dir, pane.id, message) : null;
|
|
136
148
|
if (dispatch) {
|
|
137
149
|
sendCommand(session, pane.id, dispatch.triggerCmd);
|
|
138
150
|
sentViaFile = true;
|
|
@@ -141,7 +153,7 @@ export async function send(targetDir: string | undefined, opts: SendOptions): Pr
|
|
|
141
153
|
}
|
|
142
154
|
}
|
|
143
155
|
|
|
144
|
-
|
|
156
|
+
return {
|
|
145
157
|
ok: true,
|
|
146
158
|
session,
|
|
147
159
|
target: {
|
|
@@ -153,8 +165,30 @@ export async function send(targetDir: string | undefined, opts: SendOptions): Pr
|
|
|
153
165
|
message,
|
|
154
166
|
busyStatus,
|
|
155
167
|
sentViaFile,
|
|
156
|
-
...(busyStatus === "agent" ? { warning: "agent_busy" } : {}),
|
|
168
|
+
...(busyStatus === "agent" ? { warning: "agent_busy" as const } : {}),
|
|
157
169
|
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function send(targetDir: string | undefined, opts: SendOptions): Promise<void> {
|
|
173
|
+
const dir = resolve(targetDir ?? ".");
|
|
174
|
+
const { name: session } = getSessionName(dir);
|
|
175
|
+
const { json, to: target, message: rawMessage, noEnter } = opts;
|
|
176
|
+
|
|
177
|
+
if (!target) {
|
|
178
|
+
throw new IdeError("Missing target. Usage: tmux-ide send <target> <message>", {
|
|
179
|
+
code: "USAGE",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!rawMessage) {
|
|
184
|
+
throw new IdeError("Missing message. Usage: tmux-ide send <target> <message>", {
|
|
185
|
+
code: "USAGE",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = deliverMessage({ session, target, message: rawMessage, noEnter, dir });
|
|
190
|
+
const { message, busyStatus } = result;
|
|
191
|
+
const pane = result.target;
|
|
158
192
|
|
|
159
193
|
if (json) {
|
|
160
194
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -163,7 +197,7 @@ export async function send(targetDir: string | undefined, opts: SendOptions): Pr
|
|
|
163
197
|
|
|
164
198
|
const label = pane.name ?? pane.title;
|
|
165
199
|
const preview = message.length > 60 ? message.slice(0, 60) + "..." : message;
|
|
166
|
-
console.log(`Sent to "${label}" (${pane.
|
|
200
|
+
console.log(`Sent to "${label}" (${pane.paneId}): ${preview}`);
|
|
167
201
|
|
|
168
202
|
if (busyStatus === "agent") {
|
|
169
203
|
console.log("Warning: agent appears busy. Message sent anyway.");
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* file, and appends.
|
|
14
14
|
*/
|
|
15
15
|
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
16
|
import { join } from "node:path";
|
|
17
|
+
import { stateHome } from "../../lib/state-home.ts";
|
|
18
18
|
import type { AgentStatus } from "../detect/classify.ts";
|
|
19
19
|
|
|
20
20
|
/** A single session-level status transition. `from` is null the first time a session is seen. */
|
|
@@ -86,9 +86,9 @@ export function formatEventLine(
|
|
|
86
86
|
return `${isoTime(ev.ts)} ${ev.session} ${from} → ${paint(ev.to, ev.to)}`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/** Absolute path to the fleet event log. */
|
|
89
|
+
/** Absolute path to the fleet event log (under the `TMUX_IDE_HOME`-aware home). */
|
|
90
90
|
export function eventsPath(): string {
|
|
91
|
-
return join(
|
|
91
|
+
return join(stateHome(), "events.jsonl");
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
@@ -106,7 +106,7 @@ export function appendEvents(
|
|
|
106
106
|
if (events.length === 0) return;
|
|
107
107
|
const path = eventsPath();
|
|
108
108
|
try {
|
|
109
|
-
mkdirSync(
|
|
109
|
+
mkdirSync(stateHome(), { recursive: true });
|
|
110
110
|
if (existsSync(path) && shouldRotate(statSync(path).size)) {
|
|
111
111
|
renameSync(path, `${path}.1`);
|
|
112
112
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chrome front door (M25.1) — the tiny, LEAF constants + argv builders the
|
|
3
|
+
* unified app needs to get a session watched without importing the chrome/data
|
|
4
|
+
* graph (app.tsx deliberately never imports the sync fleet-scan modules that
|
|
5
|
+
* {@link ./updater.ts} pulls in; see the FleetSession note there).
|
|
6
|
+
*
|
|
7
|
+
* "Watched" means: stamped with {@link ADOPTED_OPTION} so the background
|
|
8
|
+
* updater enumerates it (VERIFIED inert for a session that never ran `adopt` —
|
|
9
|
+
* none of adopt's status-row/border options are set, so the updater's status/
|
|
10
|
+
* chip var writes land on options nothing reads), and the updater itself
|
|
11
|
+
* ensured up. The sync twin of that flow lives in `updater.ts`
|
|
12
|
+
* (`startUpdaterIfNeeded`); the app uses these argvs with async execFile (the
|
|
13
|
+
* render-loop law).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Per-session marker option set on adopt so the updater can enumerate adopted
|
|
17
|
+
* sessions. (Owned here; re-exported by `updater.ts` for its callers.) */
|
|
18
|
+
export const ADOPTED_OPTION = "@tmux_ide_adopted";
|
|
19
|
+
|
|
20
|
+
/** The hidden internal session that hosts the updater loop. */
|
|
21
|
+
export const UPDATER_SESSION = "_tmux-ide-chrome";
|
|
22
|
+
|
|
23
|
+
/** PURE — stamp `session` watched (see the header: inert re: chrome painting). */
|
|
24
|
+
export function adoptMarkArgv(session: string): string[] {
|
|
25
|
+
return ["set-option", "-t", session, ADOPTED_OPTION, "1"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** PURE — the existence probe for the updater session (`=` exact-match, so a
|
|
29
|
+
* user session that merely starts with the name can't shadow it). Exit 0 =
|
|
30
|
+
* running. */
|
|
31
|
+
export function updaterProbeArgv(): string[] {
|
|
32
|
+
return ["has-session", "-t", `=${UPDATER_SESSION}`];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** PURE — the argv that spawns the updater loop, detached. `exec` replaces the
|
|
36
|
+
* shell so the pane IS the loop; killing the session stops it. */
|
|
37
|
+
export function updaterSpawnArgv(): string[] {
|
|
38
|
+
return ["new-session", "-d", "-s", UPDATER_SESSION, "exec tmux-ide chrome-updater"];
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification PREFERENCES — the pure shapes and parsing, hoisted out of
|
|
3
|
+
* {@link ./notify.ts} (which is io: node:child_process, node:fs, tmux).
|
|
4
|
+
*
|
|
5
|
+
* Why the split: `settings-model.ts` is pure and only needs `NotificationPrefs`
|
|
6
|
+
* + `parseHHMM`, but importing them from notify.ts dragged the whole io graph in
|
|
7
|
+
* behind them. That's invisible in the daemon (node has `process`), and it broke
|
|
8
|
+
* the moment a pure surface was imported anywhere else — the web host loads
|
|
9
|
+
* these same modules in a browser, where `process` does not exist.
|
|
10
|
+
*
|
|
11
|
+
* The rule this restores is the repo's own: pure core, thin io. Anything a pure
|
|
12
|
+
* module needs belongs in a pure module.
|
|
13
|
+
*
|
|
14
|
+
* notify.ts re-exports these, so existing importers are untouched.
|
|
15
|
+
*/
|
|
16
|
+
import type { NotificationSound } from "../../lib/app-config.ts";
|
|
17
|
+
|
|
18
|
+
/** A local-time window, `HH:MM`–`HH:MM`. */
|
|
19
|
+
export interface QuietHours {
|
|
20
|
+
/** Local `HH:MM` the window opens (e.g. `22:00`). */
|
|
21
|
+
start: string;
|
|
22
|
+
/** Local `HH:MM` the window closes (e.g. `08:00`). */
|
|
23
|
+
end: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface NotificationPrefs {
|
|
27
|
+
/** Master switch — false silences every channel. */
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
/** In-terminal status-line toasts. */
|
|
30
|
+
toast: boolean;
|
|
31
|
+
/** System banners (native macOS helper, Linux `notify-send`). */
|
|
32
|
+
macos: boolean;
|
|
33
|
+
/** Terminal-native banners — OSC 9/99 escapes to eligible client ttys (M25.2). */
|
|
34
|
+
terminal: boolean;
|
|
35
|
+
/** Seconds the OS-level channels wait + re-verify before firing (0 = immediate). */
|
|
36
|
+
delaySeconds: number;
|
|
37
|
+
/** Sound channel — ping sound + BEL routing (M25.2). */
|
|
38
|
+
sound: NotificationSound;
|
|
39
|
+
/** Ping when an agent goes `blocked`. */
|
|
40
|
+
onBlocked: boolean;
|
|
41
|
+
/** Ping when an agent goes `done`. */
|
|
42
|
+
onDone: boolean;
|
|
43
|
+
/** Optional local-time window that suppresses every OS-LEVEL channel (banner,
|
|
44
|
+
* terminal escape, sound, BEL — since M25.2). In-app toasts and the event
|
|
45
|
+
* log stay on. */
|
|
46
|
+
quietHours: QuietHours | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** PURE — parse `HH:MM` to minutes-since-midnight, or null when malformed / out of range. */
|
|
50
|
+
export function parseHHMM(value: unknown): number | null {
|
|
51
|
+
if (typeof value !== "string") return null;
|
|
52
|
+
const m = /^(\d{2}):(\d{2})$/.exec(value.trim());
|
|
53
|
+
if (!m) return null;
|
|
54
|
+
const hours = Number(m[1]);
|
|
55
|
+
const minutes = Number(m[2]);
|
|
56
|
+
if (hours > 23 || minutes > 59) return null;
|
|
57
|
+
return hours * 60 + minutes;
|
|
58
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for the notification debounce map (M25.1) — `lastNotified`
|
|
3
|
+
* survives updater restarts, so a respawned updater can't re-ping inside the
|
|
4
|
+
* debounce window (the old in-memory map made every restart an amnesty).
|
|
5
|
+
*
|
|
6
|
+
* Same split and same home discipline as {@link ../../lib/update-check.ts}:
|
|
7
|
+
* {@link serializeLastNotified} + {@link parseLastNotified} are PURE (and prune
|
|
8
|
+
* entries older than the debounce window — a stamp that can no longer suppress
|
|
9
|
+
* anything is dead weight); {@link loadLastNotified} / {@link saveLastNotified}
|
|
10
|
+
* are the thin, never-throwing io wrappers over
|
|
11
|
+
* `<TMUX_IDE_HOME>/notify-state.json`.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { stateHome } from "../../lib/state-home.ts";
|
|
16
|
+
import { NOTIFY_DEBOUNCE_MS } from "./notify.ts";
|
|
17
|
+
|
|
18
|
+
/** Absolute path to the persisted debounce map. */
|
|
19
|
+
export function notifyStatePath(): string {
|
|
20
|
+
return join(stateHome(), "notify-state.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** PURE — the JSON body for a debounce map, dropping entries whose timestamp
|
|
24
|
+
* is already outside the debounce window (they can't suppress anything). */
|
|
25
|
+
export function serializeLastNotified(map: ReadonlyMap<string, number>, nowMs: number): string {
|
|
26
|
+
const lastNotified: Record<string, number> = {};
|
|
27
|
+
for (const [key, ts] of map) {
|
|
28
|
+
if (nowMs - ts < NOTIFY_DEBOUNCE_MS) lastNotified[key] = ts;
|
|
29
|
+
}
|
|
30
|
+
return JSON.stringify({ lastNotified });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** PURE — parse a persisted body back into the map, pruning stale entries.
|
|
34
|
+
* Malformed input (or a FUTURE timestamp beyond the window — clock skew)
|
|
35
|
+
* yields an empty/partial map, never a throw. */
|
|
36
|
+
export function parseLastNotified(json: string, nowMs: number): Map<string, number> {
|
|
37
|
+
const out = new Map<string, number>();
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(json) as { lastNotified?: unknown };
|
|
40
|
+
const raw = parsed?.lastNotified;
|
|
41
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
|
42
|
+
for (const [key, ts] of Object.entries(raw as Record<string, unknown>)) {
|
|
43
|
+
if (typeof ts !== "number") continue;
|
|
44
|
+
if (nowMs - ts >= NOTIFY_DEBOUNCE_MS) continue; // expired
|
|
45
|
+
if (ts - nowMs > NOTIFY_DEBOUNCE_MS) continue; // absurd future stamp
|
|
46
|
+
out.set(key, ts);
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// unreadable state just means a fresh debounce map
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** io — load the persisted map (empty when missing/unreadable). Never throws. */
|
|
55
|
+
export function loadLastNotified(nowMs: number = Date.now()): Map<string, number> {
|
|
56
|
+
const path = notifyStatePath();
|
|
57
|
+
if (!existsSync(path)) return new Map();
|
|
58
|
+
try {
|
|
59
|
+
return parseLastNotified(readFileSync(path, "utf-8"), nowMs);
|
|
60
|
+
} catch {
|
|
61
|
+
return new Map();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** io — persist the map (creating the home if needed). Best-effort. */
|
|
66
|
+
export function saveLastNotified(
|
|
67
|
+
map: ReadonlyMap<string, number>,
|
|
68
|
+
nowMs: number = Date.now(),
|
|
69
|
+
): void {
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(stateHome(), { recursive: true });
|
|
72
|
+
writeFileSync(notifyStatePath(), serializeLastNotified(map, nowMs));
|
|
73
|
+
} catch {
|
|
74
|
+
// an unwritable map just means a restart re-arms the debounce — never fatal
|
|
75
|
+
}
|
|
76
|
+
}
|