tmux-ide 2.6.1 → 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 +1013 -516
- package/bin/cli.ts +62 -5
- package/package.json +9 -6
- 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 +34 -6
- package/packages/daemon/src/lib/app-config.ts +93 -2
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/tui/chrome/notify.ts +221 -34
- package/packages/daemon/src/tui/chrome/updater.ts +89 -7
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +15 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
- package/packages/daemon/src/tui/detect/manifest.ts +15 -0
- package/packages/daemon/src/tui/detect/manifests.ts +161 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +20 -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 +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 +80 -9
- 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 +499 -19
- 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/entry.ts +34 -7
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +102 -13
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- 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
- package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* per-concern readers ({@link ../tui/chrome/notify.ts}, {@link ../restore.ts})
|
|
21
21
|
* now delegate here.
|
|
22
22
|
*/
|
|
23
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
|
-
import { join } from "node:path";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
26
|
import type { AgentStatus } from "../tui/detect/classify.ts";
|
|
27
27
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
@@ -133,6 +133,17 @@ export interface AppIntegrations {
|
|
|
133
133
|
offer: boolean;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** The unified-app front-door decision (M22.6). */
|
|
137
|
+
export interface AppApp {
|
|
138
|
+
/**
|
|
139
|
+
* Whether bare `tmux-ide` (no ide.yml here, no `--team`) launches the unified
|
|
140
|
+
* app (`tmux-ide app`) instead of the classic team cockpit. Default false —
|
|
141
|
+
* the flip is opt-in until the default-entry decision is made. `tmux-ide team`
|
|
142
|
+
* (the explicit cockpit) and a project's `ide.yml` auto-launch are unaffected.
|
|
143
|
+
*/
|
|
144
|
+
frontDoor: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
136
147
|
/** Worktree flow config (`tmux-ide worktree`). */
|
|
137
148
|
export interface AppWorktrees {
|
|
138
149
|
/**
|
|
@@ -154,6 +165,7 @@ export interface AppConfig {
|
|
|
154
165
|
welcome: AppWelcome;
|
|
155
166
|
integrations: AppIntegrations;
|
|
156
167
|
worktrees: AppWorktrees;
|
|
168
|
+
app: AppApp;
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
// ---------------------------------------------------------------------------
|
|
@@ -190,6 +202,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|
|
190
202
|
welcome: { show: true },
|
|
191
203
|
integrations: { offer: true },
|
|
192
204
|
worktrees: { dir: "" },
|
|
205
|
+
app: { frontDoor: false },
|
|
193
206
|
};
|
|
194
207
|
|
|
195
208
|
/** The default theme tokens — the fallback threaded into the pure builders. */
|
|
@@ -245,6 +258,7 @@ export function parseAppConfig(input: unknown): AppConfig {
|
|
|
245
258
|
const welcome = asObject(root.welcome);
|
|
246
259
|
const integrations = asObject(root.integrations);
|
|
247
260
|
const worktrees = asObject(root.worktrees);
|
|
261
|
+
const app = asObject(root.app);
|
|
248
262
|
return {
|
|
249
263
|
keys: {
|
|
250
264
|
popup: pickString(keys.popup, D.keys.popup),
|
|
@@ -287,6 +301,7 @@ export function parseAppConfig(input: unknown): AppConfig {
|
|
|
287
301
|
welcome: { show: pickBool(welcome.show, D.welcome.show) },
|
|
288
302
|
integrations: { offer: pickBool(integrations.offer, D.integrations.offer) },
|
|
289
303
|
worktrees: { dir: pickString(worktrees.dir, D.worktrees.dir) },
|
|
304
|
+
app: { frontDoor: pickBool(app.frontDoor, D.app.frontDoor) },
|
|
290
305
|
};
|
|
291
306
|
}
|
|
292
307
|
|
|
@@ -334,3 +349,79 @@ export function getAppConfig(): AppConfig {
|
|
|
334
349
|
export function _resetForTests(): void {
|
|
335
350
|
cached = null;
|
|
336
351
|
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Writes (M22.4 — the settings surface persists through here)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* io — the RAW parsed config file as the user wrote it (`{}` when missing or
|
|
359
|
+
* malformed). Writers merge over THIS, not over {@link parseAppConfig}'s output:
|
|
360
|
+
* round-tripping through the parser would materialize every default into the
|
|
361
|
+
* hand-editable file and silently DROP fields the typed shape doesn't model yet
|
|
362
|
+
* (e.g. the notification polish fields `notifications.enabled` /
|
|
363
|
+
* `notifications.quietHours` that {@link ../tui/chrome/notify.ts} reads raw).
|
|
364
|
+
*/
|
|
365
|
+
export function loadRawAppConfig(): Record<string, unknown> {
|
|
366
|
+
const path = appConfigPath();
|
|
367
|
+
if (!existsSync(path)) return {};
|
|
368
|
+
try {
|
|
369
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
370
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
371
|
+
? (parsed as Record<string, unknown>)
|
|
372
|
+
: {};
|
|
373
|
+
} catch {
|
|
374
|
+
return {};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** A partial config write: plain objects merge recursively, scalars/arrays
|
|
379
|
+
* replace, and an explicit `undefined` DELETES the key (how "reset to
|
|
380
|
+
* defaults" removes a block so the parser's defaults take over). */
|
|
381
|
+
export type AppConfigPatch = { [key: string]: unknown };
|
|
382
|
+
|
|
383
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
384
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* PURE — merge `patch` into `raw` without touching either input. Objects merge
|
|
389
|
+
* key-by-key (recursively), anything else replaces, and a key whose patch value
|
|
390
|
+
* is `undefined` is REMOVED from the result. Unknown user fields survive — the
|
|
391
|
+
* merge only visits keys the patch names.
|
|
392
|
+
*/
|
|
393
|
+
export function mergeConfigPatch(
|
|
394
|
+
raw: Record<string, unknown>,
|
|
395
|
+
patch: AppConfigPatch,
|
|
396
|
+
): Record<string, unknown> {
|
|
397
|
+
const out: Record<string, unknown> = { ...raw };
|
|
398
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
399
|
+
if (value === undefined) {
|
|
400
|
+
delete out[key];
|
|
401
|
+
} else if (isPlainObject(value) && isPlainObject(out[key])) {
|
|
402
|
+
out[key] = mergeConfigPatch(out[key] as Record<string, unknown>, value as AppConfigPatch);
|
|
403
|
+
} else if (isPlainObject(value)) {
|
|
404
|
+
out[key] = mergeConfigPatch({}, value as AppConfigPatch);
|
|
405
|
+
} else {
|
|
406
|
+
out[key] = value;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* io — apply a patch to the config file ATOMICALLY (temp + rename, the same
|
|
414
|
+
* discipline as the editor save): read raw → {@link mergeConfigPatch} → write.
|
|
415
|
+
* Busts the {@link getAppConfig} process cache and returns the new parsed
|
|
416
|
+
* config. Honors `TMUX_IDE_CONFIG`, so tests never touch the real file.
|
|
417
|
+
*/
|
|
418
|
+
export function updateAppConfig(patch: AppConfigPatch): AppConfig {
|
|
419
|
+
const path = appConfigPath();
|
|
420
|
+
const merged = mergeConfigPatch(loadRawAppConfig(), patch);
|
|
421
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
422
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
423
|
+
writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
424
|
+
renameSync(tmp, path);
|
|
425
|
+
cached = null;
|
|
426
|
+
return parseAppConfig(merged);
|
|
427
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,23 +7,36 @@
|
|
|
7
7
|
* signals out to the human: a tmux toast on each attached client
|
|
8
8
|
* (`display-message -c`), and optionally a macOS notification.
|
|
9
9
|
*
|
|
10
|
-
* Split as usual: {@link decideNotifications} + {@link
|
|
11
|
-
* {@link
|
|
12
|
-
*
|
|
10
|
+
* Split as usual: {@link decideNotifications} + {@link notifyMessage} +
|
|
11
|
+
* {@link enabledStates} + {@link inQuietHours} + {@link parseNotificationPrefs}
|
|
12
|
+
* + {@link applyKillSwitch} + {@link parseClients} + {@link terminalNotifierArgs}
|
|
13
|
+
* are PURE (unit-tested without a live tmux / filesystem); {@link sendToasts},
|
|
14
|
+
* {@link sendSystemNotification}, {@link hasTerminalNotifier},
|
|
13
15
|
* {@link listAttachedClients} and {@link readNotificationPrefs} are the thin io
|
|
14
16
|
* wrappers. Every io path is best-effort — a failed ping must never break the
|
|
15
17
|
* updater loop.
|
|
16
18
|
*/
|
|
17
19
|
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
21
|
import { runTmux } from "@tmux-ide/tmux-bridge";
|
|
19
|
-
import { appConfigPath,
|
|
22
|
+
import { appConfigPath, parseAppConfig } from "../../lib/app-config.ts";
|
|
20
23
|
import type { AgentStatus } from "../detect/classify.ts";
|
|
21
24
|
|
|
22
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* A fleet transition this tick — the shape {@link ./events.ts diffFleet} emits,
|
|
27
|
+
* ENRICHED by the updater with the pane's resolved `agent` id and human
|
|
28
|
+
* `location` (`session:window.pane`) so the ping can name who needs the user.
|
|
29
|
+
* Both are optional: a caller that can't resolve them falls back to a generic
|
|
30
|
+
* `agent` label and the bare session name (see {@link notifyMessage}).
|
|
31
|
+
*/
|
|
23
32
|
export interface NotifyEvent {
|
|
24
33
|
session: string;
|
|
25
34
|
from: AgentStatus | null;
|
|
26
35
|
to: AgentStatus;
|
|
36
|
+
/** Resolved agent id (e.g. `claude`), or null/absent when unknown. */
|
|
37
|
+
agent?: string | null;
|
|
38
|
+
/** Human location `session:window.pane` (e.g. `myproj:1.2`); falls back to `session`. */
|
|
39
|
+
location?: string;
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
/** An attached tmux client and the session it's currently viewing. */
|
|
@@ -38,9 +51,13 @@ export interface ToastTarget {
|
|
|
38
51
|
message: string;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* A macOS-notification payload. `session` rides along so the click-through path
|
|
56
|
+
* ({@link terminalNotifierArgs}) can focus the right session on click.
|
|
57
|
+
*/
|
|
42
58
|
export interface SystemNotification {
|
|
43
59
|
message: string;
|
|
60
|
+
session: string;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
/** The verdict of {@link decideNotifications}. */
|
|
@@ -53,25 +70,58 @@ export interface NotifyDecision {
|
|
|
53
70
|
|
|
54
71
|
/** The only two states worth pinging the user over. */
|
|
55
72
|
const NOTIFY_STATES: ReadonlySet<AgentStatus> = new Set<AgentStatus>(["blocked", "done"]);
|
|
56
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Don't re-ping the same session+state more than once inside this window. This
|
|
75
|
+
* is what tames a FLAPPING agent (working↔blocked every few seconds): the first
|
|
76
|
+
* `blocked` fires, and every subsequent `blocked` inside the window is dropped,
|
|
77
|
+
* so a flap notifies at most once per 30s instead of on every bounce.
|
|
78
|
+
*/
|
|
57
79
|
export const NOTIFY_DEBOUNCE_MS = 30_000;
|
|
80
|
+
/** Cap the ping text so a macOS banner never truncates mid-word (banners clip ~200+). */
|
|
81
|
+
export const NOTIFY_MAX_LEN = 120;
|
|
82
|
+
|
|
83
|
+
/** PURE — the trailing clause for a notifiable state. */
|
|
84
|
+
function statusPhrase(to: AgentStatus): string {
|
|
85
|
+
return to === "blocked" ? "needs input" : "finished";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* PURE — the human-readable ping for an (enriched) transition, e.g.
|
|
90
|
+
* `claude blocked · myproj:1.2 — needs input`. Falls back to a generic `agent`
|
|
91
|
+
* label and the bare session name when the updater couldn't resolve the pane's
|
|
92
|
+
* agent/location. Clamped to {@link NOTIFY_MAX_LEN} so it fits a macOS banner.
|
|
93
|
+
*/
|
|
94
|
+
export function notifyMessage(ev: NotifyEvent): string {
|
|
95
|
+
const agent = ev.agent && ev.agent.length > 0 ? ev.agent : "agent";
|
|
96
|
+
const where = ev.location && ev.location.length > 0 ? ev.location : ev.session;
|
|
97
|
+
const text = `${agent} ${ev.to} · ${where} — ${statusPhrase(ev.to)}`;
|
|
98
|
+
return text.length > NOTIFY_MAX_LEN ? `${text.slice(0, NOTIFY_MAX_LEN - 1)}…` : text;
|
|
99
|
+
}
|
|
58
100
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
101
|
+
/**
|
|
102
|
+
* PURE — which states this user has opted into pinging on, from the
|
|
103
|
+
* `onBlocked`/`onDone` prefs. Empty when both are off (→ no notifications).
|
|
104
|
+
*/
|
|
105
|
+
export function enabledStates(prefs: NotificationPrefs): ReadonlySet<AgentStatus> {
|
|
106
|
+
const states = new Set<AgentStatus>();
|
|
107
|
+
if (prefs.onBlocked) states.add("blocked");
|
|
108
|
+
if (prefs.onDone) states.add("done");
|
|
109
|
+
return states;
|
|
62
110
|
}
|
|
63
111
|
|
|
64
112
|
/**
|
|
65
113
|
* PURE — decide who to ping from this tick's transitions.
|
|
66
114
|
*
|
|
67
115
|
* Rules:
|
|
68
|
-
* - only
|
|
69
|
-
*
|
|
116
|
+
* - only states in `states` qualify (default {@link NOTIFY_STATES}; the caller
|
|
117
|
+
* narrows it via {@link enabledStates} to honor `onBlocked`/`onDone`);
|
|
118
|
+
* - DEBOUNCE: skip a session+state that fired within {@link NOTIFY_DEBOUNCE_MS}
|
|
119
|
+
* — this is the flap guard (see {@link NOTIFY_DEBOUNCE_MS});
|
|
70
120
|
* - SUPPRESS the toast for any client already viewing that session (they can
|
|
71
121
|
* see the bar flip themselves) — other clients still get toasted;
|
|
72
122
|
* - a `system` entry is produced per qualifying, non-debounced event regardless
|
|
73
123
|
* of clients (so the macOS path fires even with nothing attached — the
|
|
74
|
-
* caller gates it on prefs).
|
|
124
|
+
* caller gates it on prefs / quiet hours).
|
|
75
125
|
*
|
|
76
126
|
* Returns the toasts/system to dispatch plus `nextLastNotified` (a copy of the
|
|
77
127
|
* input with fresh timestamps for the events we acted on) to thread onward.
|
|
@@ -81,22 +131,23 @@ export function decideNotifications(
|
|
|
81
131
|
clients: AttachedClient[],
|
|
82
132
|
lastNotified: Map<string, number>,
|
|
83
133
|
nowMs: number,
|
|
134
|
+
states: ReadonlySet<AgentStatus> = NOTIFY_STATES,
|
|
84
135
|
): NotifyDecision {
|
|
85
136
|
const nextLastNotified = new Map(lastNotified);
|
|
86
137
|
const toasts: ToastTarget[] = [];
|
|
87
138
|
const system: SystemNotification[] = [];
|
|
88
139
|
for (const ev of events) {
|
|
89
|
-
if (!
|
|
140
|
+
if (!states.has(ev.to)) continue;
|
|
90
141
|
const key = `${ev.session}:${ev.to}`;
|
|
91
142
|
const last = nextLastNotified.get(key);
|
|
92
143
|
if (last !== undefined && nowMs - last < NOTIFY_DEBOUNCE_MS) continue;
|
|
93
144
|
nextLastNotified.set(key, nowMs);
|
|
94
|
-
const message = notifyMessage(ev
|
|
145
|
+
const message = notifyMessage(ev);
|
|
95
146
|
for (const c of clients) {
|
|
96
147
|
if (c.session === ev.session) continue; // they're already looking at it
|
|
97
148
|
toasts.push({ client: c.client, message });
|
|
98
149
|
}
|
|
99
|
-
system.push({ message });
|
|
150
|
+
system.push({ message, session: ev.session });
|
|
100
151
|
}
|
|
101
152
|
return { toasts, system, nextLastNotified };
|
|
102
153
|
}
|
|
@@ -137,47 +188,172 @@ export function sendToasts(toasts: ToastTarget[]): void {
|
|
|
137
188
|
}
|
|
138
189
|
}
|
|
139
190
|
|
|
191
|
+
/** io — whether `terminal-notifier` is on PATH (enables click-through banners). */
|
|
192
|
+
export function hasTerminalNotifier(): boolean {
|
|
193
|
+
try {
|
|
194
|
+
execFileSync("which", ["terminal-notifier"], { stdio: "ignore" });
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** PURE — single-quote a string for safe interpolation into a `/bin/sh -c` command. */
|
|
202
|
+
function shellSingleQuote(value: string): string {
|
|
203
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
204
|
+
}
|
|
205
|
+
|
|
140
206
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
207
|
+
* PURE — the `terminal-notifier` argv for a click-through banner: clicking it
|
|
208
|
+
* runs `tmux switch-client -t <session>`, jumping the user's most-recent client
|
|
209
|
+
* straight to the session that needs them. (`switch-client` without `-c` targets
|
|
210
|
+
* the last-active client — the best we can do without knowing which terminal the
|
|
211
|
+
* click came from.)
|
|
143
212
|
*/
|
|
144
|
-
export function
|
|
213
|
+
export function terminalNotifierArgs(n: SystemNotification): string[] {
|
|
214
|
+
return [
|
|
215
|
+
"-title",
|
|
216
|
+
"tmux-ide",
|
|
217
|
+
"-message",
|
|
218
|
+
n.message,
|
|
219
|
+
"-execute",
|
|
220
|
+
`tmux switch-client -t ${shellSingleQuote(n.session)}`,
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* io — fire a macOS notification. macOS-only (guarded), best-effort. When
|
|
226
|
+
* `terminal-notifier` is available we use it for a CLICK-THROUGH banner
|
|
227
|
+
* ({@link terminalNotifierArgs}) that focuses the session on click; otherwise we
|
|
228
|
+
* fall back to `osascript`, whose `display notification` has NO click action —
|
|
229
|
+
* so on a stock machine the banner informs but can't be clicked to jump.
|
|
230
|
+
*/
|
|
231
|
+
export function sendSystemNotification(n: SystemNotification): void {
|
|
145
232
|
if (process.platform !== "darwin") return;
|
|
146
|
-
const escaped = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
147
233
|
try {
|
|
234
|
+
if (hasTerminalNotifier()) {
|
|
235
|
+
execFileSync("terminal-notifier", terminalNotifierArgs(n), { stdio: "ignore" });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const escaped = n.message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
148
239
|
execFileSync("osascript", ["-e", `display notification "${escaped}" with title "tmux-ide"`], {
|
|
149
240
|
stdio: "ignore",
|
|
150
241
|
});
|
|
151
242
|
} catch {
|
|
152
|
-
// osascript missing
|
|
243
|
+
// osascript / terminal-notifier missing or notification blocked — never fatal
|
|
153
244
|
}
|
|
154
245
|
}
|
|
155
246
|
|
|
156
|
-
/**
|
|
247
|
+
/** A "quiet hours" window — banners are suppressed while the wall clock is inside it. */
|
|
248
|
+
export interface QuietHours {
|
|
249
|
+
/** Local `HH:MM` the window opens (e.g. `22:00`). */
|
|
250
|
+
start: string;
|
|
251
|
+
/** Local `HH:MM` the window closes (e.g. `08:00`). */
|
|
252
|
+
end: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** User notification preferences. */
|
|
157
256
|
export interface NotificationPrefs {
|
|
257
|
+
/** Master switch — false silences every channel. */
|
|
258
|
+
enabled: boolean;
|
|
259
|
+
/** In-terminal status-line toasts. */
|
|
158
260
|
toast: boolean;
|
|
261
|
+
/** macOS system banners. */
|
|
159
262
|
macos: boolean;
|
|
263
|
+
/** Ping when an agent goes `blocked`. */
|
|
264
|
+
onBlocked: boolean;
|
|
265
|
+
/** Ping when an agent goes `done`. */
|
|
266
|
+
onDone: boolean;
|
|
267
|
+
/** Optional local-time window that suppresses macOS banners (events still record). */
|
|
268
|
+
quietHours: QuietHours | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Defaults: enabled, tmux toasts on, macOS banners off, both states pinged, no quiet window. */
|
|
272
|
+
export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
|
273
|
+
enabled: true,
|
|
274
|
+
toast: true,
|
|
275
|
+
macos: false,
|
|
276
|
+
onBlocked: true,
|
|
277
|
+
onDone: true,
|
|
278
|
+
quietHours: null,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** A plain object, or `{}` for anything that isn't one (arrays included). */
|
|
282
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
283
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
284
|
+
? (value as Record<string, unknown>)
|
|
285
|
+
: {};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A boolean, else the default. */
|
|
289
|
+
function pickBool(value: unknown, fallback: boolean): boolean {
|
|
290
|
+
return typeof value === "boolean" ? value : fallback;
|
|
160
291
|
}
|
|
161
292
|
|
|
162
|
-
/**
|
|
163
|
-
export
|
|
293
|
+
/** PURE — parse `HH:MM` to minutes-since-midnight, or null when malformed / out of range. */
|
|
294
|
+
export function parseHHMM(value: unknown): number | null {
|
|
295
|
+
if (typeof value !== "string") return null;
|
|
296
|
+
const m = /^(\d{2}):(\d{2})$/.exec(value.trim());
|
|
297
|
+
if (!m) return null;
|
|
298
|
+
const hours = Number(m[1]);
|
|
299
|
+
const minutes = Number(m[2]);
|
|
300
|
+
if (hours > 23 || minutes > 59) return null;
|
|
301
|
+
return hours * 60 + minutes;
|
|
302
|
+
}
|
|
164
303
|
|
|
165
304
|
/**
|
|
166
|
-
* PURE —
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* parsing can't drift from the rest of the config.
|
|
305
|
+
* PURE — is `now` (local time) inside the quiet window? Handles a window that
|
|
306
|
+
* WRAPS midnight (`22:00`–`08:00`). A null window, or a malformed / zero-width
|
|
307
|
+
* (`start === end`) one, is never quiet.
|
|
170
308
|
*/
|
|
171
|
-
export function
|
|
172
|
-
|
|
309
|
+
export function inQuietHours(now: Date, quiet: QuietHours | null): boolean {
|
|
310
|
+
if (!quiet) return false;
|
|
311
|
+
const start = parseHHMM(quiet.start);
|
|
312
|
+
const end = parseHHMM(quiet.end);
|
|
313
|
+
if (start === null || end === null || start === end) return false;
|
|
314
|
+
const nowMin = now.getHours() * 60 + now.getMinutes();
|
|
315
|
+
return start < end ? nowMin >= start && nowMin < end : nowMin >= start || nowMin < end;
|
|
173
316
|
}
|
|
174
317
|
|
|
175
|
-
/** PURE —
|
|
318
|
+
/** PURE — parse a `{ start, end }` quiet-hours block, or null when absent / malformed. */
|
|
319
|
+
function parseQuietHours(value: unknown): QuietHours | null {
|
|
320
|
+
const o = asObject(value);
|
|
321
|
+
const start = typeof o.start === "string" ? o.start : null;
|
|
322
|
+
const end = typeof o.end === "string" ? o.end : null;
|
|
323
|
+
if (start === null || end === null) return null;
|
|
324
|
+
if (parseHHMM(start) === null || parseHHMM(end) === null) return null;
|
|
325
|
+
return { start, end };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* PURE — resolve full {@link NotificationPrefs} from a parsed config object.
|
|
330
|
+
*
|
|
331
|
+
* `toast`/`macos` delegate to the shared {@link parseAppConfig} (so those two
|
|
332
|
+
* can't drift from the rest of the config); the polish-era fields
|
|
333
|
+
* (`enabled`/`onBlocked`/`onDone`/`quietHours`) are read straight off the raw
|
|
334
|
+
* `notifications` block here — `parseAppConfig` doesn't model them, so reading
|
|
335
|
+
* them locally keeps this change inside the chrome layer. Anything missing or
|
|
336
|
+
* mistyped falls back to {@link DEFAULT_NOTIFICATION_PREFS}.
|
|
337
|
+
*/
|
|
338
|
+
export function parseNotificationPrefs(rawConfig: unknown): NotificationPrefs {
|
|
339
|
+
const base = parseAppConfig(rawConfig).notifications;
|
|
340
|
+
const n = asObject(asObject(rawConfig).notifications);
|
|
341
|
+
return {
|
|
342
|
+
enabled: pickBool(n.enabled, DEFAULT_NOTIFICATION_PREFS.enabled),
|
|
343
|
+
toast: base.toast,
|
|
344
|
+
macos: base.macos,
|
|
345
|
+
onBlocked: pickBool(n.onBlocked, DEFAULT_NOTIFICATION_PREFS.onBlocked),
|
|
346
|
+
onDone: pickBool(n.onDone, DEFAULT_NOTIFICATION_PREFS.onDone),
|
|
347
|
+
quietHours: parseQuietHours(n.quietHours),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** PURE — the `TMUX_IDE_NOTIFY=0` kill-switch: disables everything. */
|
|
176
352
|
export function applyKillSwitch(
|
|
177
353
|
prefs: NotificationPrefs,
|
|
178
354
|
envValue: string | undefined,
|
|
179
355
|
): NotificationPrefs {
|
|
180
|
-
return envValue === "0" ? { toast: false, macos: false } : prefs;
|
|
356
|
+
return envValue === "0" ? { ...prefs, enabled: false, toast: false, macos: false } : prefs;
|
|
181
357
|
}
|
|
182
358
|
|
|
183
359
|
/** Absolute path to the shared config (honors `TMUX_IDE_CONFIG`). */
|
|
@@ -185,11 +361,22 @@ export function notifyConfigPath(): string {
|
|
|
185
361
|
return appConfigPath();
|
|
186
362
|
}
|
|
187
363
|
|
|
364
|
+
/** io — read + JSON-parse the raw config file; undefined when missing / malformed. */
|
|
365
|
+
function readRawConfig(): unknown {
|
|
366
|
+
const path = appConfigPath();
|
|
367
|
+
if (!existsSync(path)) return undefined;
|
|
368
|
+
try {
|
|
369
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
370
|
+
} catch {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
188
375
|
/**
|
|
189
376
|
* io — resolve effective prefs from the shared app config (defaults for a
|
|
190
377
|
* missing/invalid file), then apply the `TMUX_IDE_NOTIFY=0` kill-switch. Reads
|
|
191
|
-
* fresh so the env kill-switch
|
|
378
|
+
* fresh so the env kill-switch and config edits are honored each call.
|
|
192
379
|
*/
|
|
193
380
|
export function readNotificationPrefs(): NotificationPrefs {
|
|
194
|
-
return applyKillSwitch(
|
|
381
|
+
return applyKillSwitch(parseNotificationPrefs(readRawConfig()), process.env.TMUX_IDE_NOTIFY);
|
|
195
382
|
}
|