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
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* without a live tmux; `adoptedSessionsFrom` is a pure parser.
|
|
22
22
|
*/
|
|
23
23
|
import { hasSession, isProcessAlive, runTmux } from "@tmux-ide/tmux-bridge";
|
|
24
|
+
import { ADOPTED_OPTION, UPDATER_SESSION, updaterSpawnArgv } from "./front-door.ts";
|
|
24
25
|
import { DEFAULT_THEME, getAppConfig, type AppTheme } from "../../lib/app-config.ts";
|
|
25
26
|
import {
|
|
26
27
|
maybeCheckForUpdate,
|
|
@@ -28,21 +29,33 @@ import {
|
|
|
28
29
|
type UpdateStatus,
|
|
29
30
|
} from "../../lib/update-check.ts";
|
|
30
31
|
import { createStatusTracker, type AgentStatus } from "../detect/classify.ts";
|
|
32
|
+
import { createSessionIdCapturer } from "../detect/session-id.ts";
|
|
31
33
|
import { listTeamProjects, type TeamProject } from "../team/projects.ts";
|
|
32
34
|
import type { PaneDetail } from "../team/sessions.ts";
|
|
33
35
|
import { paneChip } from "./chip.ts";
|
|
34
36
|
import { appendEvents, diffFleet, type AgentEventInit } from "./events.ts";
|
|
35
37
|
import {
|
|
36
38
|
decideNotifications,
|
|
39
|
+
decideTtyWrites,
|
|
40
|
+
enabledStates,
|
|
41
|
+
inQuietHours,
|
|
37
42
|
listAttachedClients,
|
|
43
|
+
playPingSound,
|
|
44
|
+
readAppFocus,
|
|
38
45
|
readNotificationPrefs,
|
|
39
46
|
sendSystemNotification,
|
|
40
47
|
sendToasts,
|
|
48
|
+
soundEligible,
|
|
49
|
+
writeTtys,
|
|
50
|
+
type AppFocus,
|
|
41
51
|
type AttachedClient,
|
|
42
52
|
type NotificationPrefs,
|
|
43
53
|
type NotifyEvent,
|
|
54
|
+
type SystemNotification,
|
|
44
55
|
type ToastTarget,
|
|
56
|
+
type TtyWrite,
|
|
45
57
|
} from "./notify.ts";
|
|
58
|
+
import { loadLastNotified, saveLastNotified } from "./notify-state.ts";
|
|
46
59
|
import {
|
|
47
60
|
collectFleetSnapshot,
|
|
48
61
|
createSnapshotter,
|
|
@@ -55,10 +68,15 @@ import { buildStatusline } from "./statusline.ts";
|
|
|
55
68
|
export const STATUS_OPTION = "@tmux_ide_status";
|
|
56
69
|
/** Per-PANE user option holding the pre-rendered agent chip (read by pane-border-format). */
|
|
57
70
|
export const CHIP_OPTION = "@tmux_ide_chip";
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
export
|
|
71
|
+
// The adopted marker, the updater session name, and their argv builders live
|
|
72
|
+
// in the LEAF module front-door.ts (the unified app imports them without
|
|
73
|
+
// pulling this module's fleet-scan graph); re-exported here for the chrome.
|
|
74
|
+
export {
|
|
75
|
+
ADOPTED_OPTION,
|
|
76
|
+
UPDATER_SESSION,
|
|
77
|
+
updaterProbeArgv,
|
|
78
|
+
updaterSpawnArgv,
|
|
79
|
+
} from "./front-door.ts";
|
|
62
80
|
/** Server option holding the running updater's pid (a lightweight single-owner guard). */
|
|
63
81
|
export const UPDATER_PID_OPTION = "@tmux_ide_updater_pid";
|
|
64
82
|
/** Default tick cadence — overridable via `updater.tickMs` in the app config. */
|
|
@@ -137,18 +155,55 @@ export interface UpdaterTickDeps {
|
|
|
137
155
|
prevState?: Map<string, AgentStatus>;
|
|
138
156
|
appendEvents?: (events: AgentEventInit[]) => void;
|
|
139
157
|
/**
|
|
140
|
-
* Notification dispatch (optional). When wired alongside `
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
158
|
+
* Notification dispatch (optional). When wired alongside `prevPaneState`
|
|
159
|
+
* (M25.1 — notifications are PANE-granular so a second agent blocking in an
|
|
160
|
+
* already-blocked session still pings), the tick turns THIS tick's pane
|
|
161
|
+
* transitions into user pings — toasts on attached clients and/or a macOS
|
|
162
|
+
* notification — via {@link decideNotifications}, gated on `prefs`.
|
|
163
|
+
* `lastNotified` is the persistent debounce map, mutated in place (and
|
|
164
|
+
* persisted via `persistNotified` when a ping fired, so a restart can't
|
|
165
|
+
* re-ping inside the window). All deps-injected so the routing is
|
|
166
|
+
* unit-tested without a live tmux.
|
|
145
167
|
*/
|
|
168
|
+
prevPaneState?: Map<string, AgentStatus>;
|
|
146
169
|
listClients?: () => AttachedClient[];
|
|
147
170
|
lastNotified?: Map<string, number>;
|
|
148
171
|
now?: () => number;
|
|
149
172
|
prefs?: NotificationPrefs;
|
|
150
173
|
sendToasts?: (toasts: ToastTarget[]) => void;
|
|
151
|
-
sendSystem?: (
|
|
174
|
+
sendSystem?: (n: SystemNotification) => void;
|
|
175
|
+
/**
|
|
176
|
+
* The OS-level channel io (M25.2), both optional and deps-injected like the
|
|
177
|
+
* rest: `sendTerminal` writes OSC 9/99 + BEL bytes to client ttys
|
|
178
|
+
* ({@link writeTtys}); `playSound` fires the platform ping sound
|
|
179
|
+
* ({@link playPingSound}). Which clients/states qualify is decided purely
|
|
180
|
+
* ({@link decideTtyWrites} / {@link soundEligible}).
|
|
181
|
+
*/
|
|
182
|
+
sendTerminal?: (writes: TtyWrite[]) => void;
|
|
183
|
+
playSound?: () => void;
|
|
184
|
+
/**
|
|
185
|
+
* The DELAYED RE-VERIFY queue (M25.2), loop-owned and mutated in place like
|
|
186
|
+
* `prevPaneState`. With `notifications.delaySeconds > 0`, a notify-eligible
|
|
187
|
+
* transition queues its OS-LEVEL channels here instead of firing them; each
|
|
188
|
+
* tick {@link firePendingOsPings} fires the due entries whose pane is STILL
|
|
189
|
+
* in the notified state (this tick's scan is the re-verify — no setTimeout
|
|
190
|
+
* racing the tick), so a flappy blocked→working inside the window fires
|
|
191
|
+
* nothing OS-level. Toasts never queue — the in-app note stays immediate.
|
|
192
|
+
* Omitted (tests, delay 0) → OS channels fire immediately.
|
|
193
|
+
*/
|
|
194
|
+
pendingPings?: PendingOsPing[];
|
|
195
|
+
/** The unified app's focus record (see {@link AppFocus}) — pings for panes
|
|
196
|
+
* on the app's screen are suppressed. Wired to {@link readAppFocus}. */
|
|
197
|
+
appFocus?: () => AppFocus | null;
|
|
198
|
+
/** Persist the debounce map after a tick that fired a ping. Wired to
|
|
199
|
+
* {@link saveLastNotified}. */
|
|
200
|
+
persistNotified?: (map: ReadonlyMap<string, number>) => void;
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a pane id to its human `session:window.pane` location for the ping
|
|
203
|
+
* text (optional). Wired to the live tmux {@link paneLocation}; tests inject a
|
|
204
|
+
* pure stub. Only ever called for the pane behind a blocked/done transition.
|
|
205
|
+
*/
|
|
206
|
+
locatePane?: (paneId: string) => string;
|
|
152
207
|
/**
|
|
153
208
|
* Update-flow surfacing (optional). When wired, the tick calls this cheap,
|
|
154
209
|
* cache-backed check each tick (throttled internally to 24h; it kicks off a
|
|
@@ -159,6 +214,14 @@ export interface UpdaterTickDeps {
|
|
|
159
214
|
*/
|
|
160
215
|
maybeCheckForUpdate?: () => UpdateStatus;
|
|
161
216
|
markUpdateNotified?: (version: string) => boolean;
|
|
217
|
+
/**
|
|
218
|
+
* Session-id capture (optional). When wired, receives this tick's per-pane
|
|
219
|
+
* detail so the capturer ({@link ../detect/session-id.ts}) can stamp
|
|
220
|
+
* `@agent_session_id` on codex/cursor panes that lack one — the key
|
|
221
|
+
* `restore --resume-agents` resumes from. Self-throttled and stamp-once, so
|
|
222
|
+
* a fleet with no unstamped capturable panes costs nothing.
|
|
223
|
+
*/
|
|
224
|
+
captureSessionIds?: (panes: PaneDetail[]) => void;
|
|
162
225
|
}
|
|
163
226
|
|
|
164
227
|
/**
|
|
@@ -172,8 +235,11 @@ export function updateSegment(status: UpdateStatus, theme: AppTheme): string {
|
|
|
172
235
|
return `#[range=user|update]#[fg=${theme.accent}]⬆ v${status.latest}#[default]#[norange]`;
|
|
173
236
|
}
|
|
174
237
|
|
|
175
|
-
/** Flatten the project view to a flat per-session status list for {@link diffFleet}.
|
|
176
|
-
|
|
238
|
+
/** Flatten the project view to a flat per-session status list for {@link diffFleet}.
|
|
239
|
+
* Exported: the control server's event tick diffs the same projection. */
|
|
240
|
+
export function fleetStatuses(
|
|
241
|
+
projects: TeamProject[],
|
|
242
|
+
): Array<{ name: string; status: AgentStatus }> {
|
|
177
243
|
return projects.flatMap((p) => p.sessions.map((s) => ({ name: s.name, status: s.status })));
|
|
178
244
|
}
|
|
179
245
|
|
|
@@ -204,15 +270,23 @@ export function runUpdaterTick(deps: UpdaterTickDeps): void {
|
|
|
204
270
|
deps.writeStatus(session, buildStatusline(projects, session, 12, theme, extra));
|
|
205
271
|
}
|
|
206
272
|
writeChips(deps, adopted, panes, theme);
|
|
273
|
+
deps.captureSessionIds?.(panes);
|
|
207
274
|
if (update?.updateAvailable && update.latest) dispatchUpdateToast(deps, update.latest);
|
|
208
275
|
if (deps.prevState && deps.appendEvents) {
|
|
209
276
|
const { events, state } = diffFleet(deps.prevState, fleetStatuses(projects));
|
|
210
277
|
deps.prevState.clear();
|
|
211
278
|
for (const [name, status] of state) deps.prevState.set(name, status);
|
|
212
|
-
if (events.length > 0)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
279
|
+
if (events.length > 0) deps.appendEvents(events);
|
|
280
|
+
}
|
|
281
|
+
// Notifications ride PANE transitions (M25.1), not the session rollup — a
|
|
282
|
+
// second agent blocking in an already-blocked session is invisible at the
|
|
283
|
+
// session level but is exactly who the user needs to hear about. Due delayed
|
|
284
|
+
// pings re-verify against THIS tick's pane states before the diff mutates
|
|
285
|
+
// anything (M25.2).
|
|
286
|
+
if (deps.prevPaneState) {
|
|
287
|
+
firePendingOsPings(deps, panes);
|
|
288
|
+
const events = diffPaneTransitions(deps.prevPaneState, panes, deps.locatePane);
|
|
289
|
+
if (events.length > 0) dispatchNotifications(deps, events);
|
|
216
290
|
}
|
|
217
291
|
}
|
|
218
292
|
|
|
@@ -241,21 +315,174 @@ function writeChips(
|
|
|
241
315
|
}
|
|
242
316
|
}
|
|
243
317
|
|
|
318
|
+
/**
|
|
319
|
+
* PURE (given `locate`) — diff the previous per-pane states against this
|
|
320
|
+
* tick's panes into ready-to-ping {@link NotifyEvent}s: each carries its
|
|
321
|
+
* `paneId` (the debounce/visibility key), `windowIndex` (window-granular toast
|
|
322
|
+
* suppression), the pane's resolved `agent`, and — only for a notifiable
|
|
323
|
+
* blocked/done transition that isn't first-sight — the human `location`
|
|
324
|
+
* (`locate` is a live tmux call, so it only fires for a potential ping).
|
|
325
|
+
* `prev` is mutated in place to the fresh state, like the session-level
|
|
326
|
+
* `prevState`; a pane that vanished simply drops out. First sight of a pane
|
|
327
|
+
* emits `from: null`, which {@link decideNotifications} ignores — that's the
|
|
328
|
+
* restart/first-tick grace.
|
|
329
|
+
*/
|
|
330
|
+
export function diffPaneTransitions(
|
|
331
|
+
prev: Map<string, AgentStatus>,
|
|
332
|
+
panes: PaneDetail[],
|
|
333
|
+
locate?: (paneId: string) => string,
|
|
334
|
+
): NotifyEvent[] {
|
|
335
|
+
const events: NotifyEvent[] = [];
|
|
336
|
+
const next = new Map<string, AgentStatus>();
|
|
337
|
+
for (const pane of panes) {
|
|
338
|
+
const before = prev.has(pane.paneId) ? prev.get(pane.paneId)! : null;
|
|
339
|
+
next.set(pane.paneId, pane.status);
|
|
340
|
+
if (before === pane.status) continue;
|
|
341
|
+
const notifiable = before !== null && (pane.status === "blocked" || pane.status === "done");
|
|
342
|
+
events.push({
|
|
343
|
+
session: pane.sessionName,
|
|
344
|
+
from: before,
|
|
345
|
+
to: pane.status,
|
|
346
|
+
paneId: pane.paneId,
|
|
347
|
+
windowIndex: pane.windowIndex,
|
|
348
|
+
agent: pane.agent,
|
|
349
|
+
location: notifiable && locate ? locate(pane.paneId) : pane.sessionName,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
prev.clear();
|
|
353
|
+
for (const [paneId, status] of next) prev.set(paneId, status);
|
|
354
|
+
return events;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A queued OS-level ping: the {@link SystemNotification} payload plus when it
|
|
358
|
+
* becomes due. Fires only if its pane is STILL in `state` at fire time. */
|
|
359
|
+
export interface PendingOsPing extends SystemNotification {
|
|
360
|
+
dueAtMs: number;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** PURE — whether ANY channel beyond the master switch is on; an all-off
|
|
364
|
+
* config costs the tick nothing. */
|
|
365
|
+
function anyChannelOn(prefs: NotificationPrefs): boolean {
|
|
366
|
+
return prefs.toast || prefs.macos || prefs.terminal || prefs.sound !== "none";
|
|
367
|
+
}
|
|
368
|
+
|
|
244
369
|
/**
|
|
245
370
|
* Ping the user about who needs them from this tick's transitions. Only runs
|
|
246
|
-
* when the notification deps are wired
|
|
247
|
-
* `lastNotified` is mutated in place so the debounce
|
|
371
|
+
* when the notification deps are wired, notifications are `enabled`, AND at
|
|
372
|
+
* least one channel is on. `lastNotified` is mutated in place so the debounce
|
|
373
|
+
* (the flap guard) persists across ticks. The in-app toast fires IMMEDIATELY
|
|
374
|
+
* (cheap, low-annoyance); the OS-LEVEL channels (banner / terminal escape /
|
|
375
|
+
* sound / BEL) either fire now (`delaySeconds` 0 or no queue wired) or queue
|
|
376
|
+
* onto `pendingPings` for the delayed re-verify (M25.2). The debounce stamp
|
|
377
|
+
* lands at DECIDE time either way — a flap that later cancels its OS ping
|
|
378
|
+
* already toasted, so it spent its debounce slot honestly.
|
|
248
379
|
*/
|
|
249
380
|
function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): void {
|
|
250
|
-
const { listClients, lastNotified, now, prefs, sendToasts: toast
|
|
381
|
+
const { listClients, lastNotified, now, prefs, sendToasts: toast } = deps;
|
|
251
382
|
if (!listClients || !lastNotified || !now || !prefs) return;
|
|
252
|
-
if (!prefs.
|
|
253
|
-
|
|
383
|
+
if (!prefs.enabled) return;
|
|
384
|
+
if (!anyChannelOn(prefs)) return;
|
|
385
|
+
const nowMs = now();
|
|
386
|
+
const decision = decideNotifications(
|
|
387
|
+
events,
|
|
388
|
+
listClients(),
|
|
389
|
+
lastNotified,
|
|
390
|
+
nowMs,
|
|
391
|
+
enabledStates(prefs),
|
|
392
|
+
deps.appFocus?.() ?? null,
|
|
393
|
+
);
|
|
254
394
|
lastNotified.clear();
|
|
255
395
|
for (const [key, ts] of decision.nextLastNotified) lastNotified.set(key, ts);
|
|
396
|
+
// A system entry exists for every event that actually fired (stamped the
|
|
397
|
+
// debounce map), so this is exactly "the map changed — persist it".
|
|
398
|
+
if (decision.system.length > 0) deps.persistNotified?.(lastNotified);
|
|
256
399
|
if (prefs.toast && toast) toast(decision.toasts);
|
|
257
|
-
if (
|
|
258
|
-
|
|
400
|
+
if (decision.system.length === 0) return;
|
|
401
|
+
const delayMs = prefs.delaySeconds * 1000;
|
|
402
|
+
if (delayMs > 0 && deps.pendingPings) {
|
|
403
|
+
for (const n of decision.system) deps.pendingPings.push({ ...n, dueAtMs: nowMs + delayMs });
|
|
404
|
+
} else {
|
|
405
|
+
fireOsChannels(deps, prefs, decision.system, nowMs);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Fire the DUE queued OS pings whose transition still holds (M25.2). The
|
|
411
|
+
* re-verify is tick-native: this tick's fresh pane scan is the source of truth
|
|
412
|
+
* — a pane that flapped out of the notified state, vanished, or is now on the
|
|
413
|
+
* unified app's screen ({@link AppFocus} — the user got there on their own)
|
|
414
|
+
* fires nothing and is dropped. Prefs are the CURRENT tick's fresh read, so a
|
|
415
|
+
* config change during the delay is honored. Runs before this tick's diff so
|
|
416
|
+
* "the next tick's pane states" is literally what confirms each ping.
|
|
417
|
+
*/
|
|
418
|
+
function firePendingOsPings(deps: UpdaterTickDeps, panes: PaneDetail[]): void {
|
|
419
|
+
const { pendingPings: pending, now, prefs } = deps;
|
|
420
|
+
if (!pending || pending.length === 0 || !now || !prefs) return;
|
|
421
|
+
const nowMs = now();
|
|
422
|
+
const due: PendingOsPing[] = [];
|
|
423
|
+
const keep: PendingOsPing[] = [];
|
|
424
|
+
for (const p of pending) (p.dueAtMs <= nowMs ? due : keep).push(p);
|
|
425
|
+
if (due.length === 0) return;
|
|
426
|
+
pending.length = 0;
|
|
427
|
+
for (const p of keep) pending.push(p);
|
|
428
|
+
if (!prefs.enabled) return;
|
|
429
|
+
const statusByPane = new Map(panes.map((p) => [p.paneId, p.status]));
|
|
430
|
+
const focus = deps.appFocus?.() ?? null;
|
|
431
|
+
const confirmed = due.filter((p) => {
|
|
432
|
+
// A pane-less ping (session-granular caller) can't be re-verified — fire it.
|
|
433
|
+
if (p.paneId !== null && statusByPane.get(p.paneId) !== p.state) return false;
|
|
434
|
+
if (focus?.attached && p.paneId !== null && focus.panes.includes(p.paneId)) return false;
|
|
435
|
+
return true;
|
|
436
|
+
});
|
|
437
|
+
fireOsChannels(deps, prefs, confirmed, nowMs);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The OS-LEVEL fan-out (M25.2): system banner, terminal escapes + BEL to
|
|
442
|
+
* eligible client ttys, and the ping sound (at most ONE sound per batch — a
|
|
443
|
+
* fleet-wide flap must not ring a carillon). ALL of it is gated on quiet hours
|
|
444
|
+
* — inside the window nothing OS-level fires (the in-app toast and the event
|
|
445
|
+
* log have already surfaced the transition, so history stays honest).
|
|
446
|
+
*/
|
|
447
|
+
function fireOsChannels(
|
|
448
|
+
deps: UpdaterTickDeps,
|
|
449
|
+
prefs: NotificationPrefs,
|
|
450
|
+
entries: SystemNotification[],
|
|
451
|
+
nowMs: number,
|
|
452
|
+
): void {
|
|
453
|
+
if (entries.length === 0) return;
|
|
454
|
+
if (inQuietHours(new Date(nowMs), prefs.quietHours)) return;
|
|
455
|
+
if (prefs.macos && deps.sendSystem) {
|
|
456
|
+
for (const n of entries) deps.sendSystem(n);
|
|
457
|
+
}
|
|
458
|
+
if ((prefs.terminal || prefs.sound !== "none") && deps.sendTerminal && deps.listClients) {
|
|
459
|
+
const clients = deps.listClients();
|
|
460
|
+
const writes = entries.flatMap((n) => decideTtyWrites(n, clients, prefs));
|
|
461
|
+
if (writes.length > 0) deps.sendTerminal(writes);
|
|
462
|
+
}
|
|
463
|
+
if (deps.playSound && entries.some((n) => soundEligible(n.state, prefs.sound))) {
|
|
464
|
+
deps.playSound();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* io — resolve a pane id to `session:window.pane` (e.g. `myproj:1.2`) for the
|
|
470
|
+
* ping text. Best-effort: a gone pane / failed call degrades to the raw pane id.
|
|
471
|
+
*/
|
|
472
|
+
export function paneLocation(paneId: string): string {
|
|
473
|
+
try {
|
|
474
|
+
const raw = runTmux([
|
|
475
|
+
"display-message",
|
|
476
|
+
"-p",
|
|
477
|
+
"-t",
|
|
478
|
+
paneId,
|
|
479
|
+
"#{session_name}:#{window_index}.#{pane_index}",
|
|
480
|
+
])
|
|
481
|
+
.toString()
|
|
482
|
+
.trim();
|
|
483
|
+
return raw || paneId;
|
|
484
|
+
} catch {
|
|
485
|
+
return paneId;
|
|
259
486
|
}
|
|
260
487
|
}
|
|
261
488
|
|
|
@@ -300,15 +527,14 @@ export function updaterRunning(): boolean {
|
|
|
300
527
|
|
|
301
528
|
/**
|
|
302
529
|
* Ensure the background updater is running: if the `_tmux-ide-chrome` session
|
|
303
|
-
* isn't up, start it detached running `tmux-ide chrome-updater`. `
|
|
304
|
-
* the shell so the pane IS the loop; killing the session stops it. `_`-internal
|
|
530
|
+
* isn't up, start it detached running `tmux-ide chrome-updater`. `_`-internal
|
|
305
531
|
* so it's hidden from the bar/switcher. Best-effort — a chrome failure must
|
|
306
532
|
* never break adopt/launch.
|
|
307
533
|
*/
|
|
308
534
|
export function startUpdaterIfNeeded(): void {
|
|
309
535
|
try {
|
|
310
536
|
if (updaterRunning()) return;
|
|
311
|
-
runTmux(
|
|
537
|
+
runTmux(updaterSpawnArgv());
|
|
312
538
|
} catch {
|
|
313
539
|
// best-effort — the bar still works via the last-written var
|
|
314
540
|
}
|
|
@@ -360,11 +586,43 @@ function releaseUpdater(): void {
|
|
|
360
586
|
}
|
|
361
587
|
}
|
|
362
588
|
|
|
589
|
+
/** Exit the loop after this many CONSECUTIVE ticks with the tmux server gone —
|
|
590
|
+
* a dead server means nothing to watch and nobody to ping, and an immortal
|
|
591
|
+
* interval would zombie (two were found live before M25.1). */
|
|
592
|
+
export const UPDATER_UNREACHABLE_EXIT_TICKS = 5;
|
|
593
|
+
|
|
594
|
+
/** PURE — a consecutive-failure counter: feed it each tick's reachability;
|
|
595
|
+
* it answers "give up now?" after `threshold` misses in a row (any success
|
|
596
|
+
* resets the run). */
|
|
597
|
+
export function createUnreachableCounter(
|
|
598
|
+
threshold: number = UPDATER_UNREACHABLE_EXIT_TICKS,
|
|
599
|
+
): (reachable: boolean) => boolean {
|
|
600
|
+
let consecutive = 0;
|
|
601
|
+
return (reachable: boolean): boolean => {
|
|
602
|
+
consecutive = reachable ? 0 : consecutive + 1;
|
|
603
|
+
return consecutive >= threshold;
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** io — can we still talk to the tmux server? (A server with zero sessions
|
|
608
|
+
* exits, so `list-sessions` failing means the server itself is gone.) */
|
|
609
|
+
function isServerReachable(): boolean {
|
|
610
|
+
try {
|
|
611
|
+
runTmux(["list-sessions", "-F", "#{session_name}"]);
|
|
612
|
+
return true;
|
|
613
|
+
} catch {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
363
618
|
/**
|
|
364
619
|
* Run the updater loop forever (the body of `tmux-ide chrome-updater`). Claims
|
|
365
620
|
* single-ownership, then rewrites every adopted session's bar immediately and
|
|
366
621
|
* every {@link TICK_MS} thereafter behind ONE persistent tracker (so `done`
|
|
367
|
-
* transitions surface). Blocks — the interval keeps the event loop alive
|
|
622
|
+
* transitions surface). Blocks — the interval keeps the event loop alive —
|
|
623
|
+
* until the tmux server has been unreachable for
|
|
624
|
+
* {@link UPDATER_UNREACHABLE_EXIT_TICKS} consecutive ticks, at which point the
|
|
625
|
+
* loop logs once and exits instead of running headless forever.
|
|
368
626
|
*/
|
|
369
627
|
export function runUpdaterLoop(): void {
|
|
370
628
|
if (!claimUpdater()) return;
|
|
@@ -375,10 +633,24 @@ export function runUpdaterLoop(): void {
|
|
|
375
633
|
const tracker = createStatusTracker();
|
|
376
634
|
// Persistent across ticks so `diffFleet` can spot working→done etc.
|
|
377
635
|
const prevState = new Map<string, AgentStatus>();
|
|
378
|
-
//
|
|
379
|
-
const
|
|
636
|
+
// Per-PANE states for the notification path (M25.1 — pane-granular pings).
|
|
637
|
+
const prevPaneState = new Map<string, AgentStatus>();
|
|
638
|
+
// The notification debounce map — restored from disk so a restart can't
|
|
639
|
+
// re-ping inside the window, persisted back on every ping.
|
|
640
|
+
const lastNotified = loadLastNotified();
|
|
641
|
+
// Queued OS-level pings awaiting their delayed re-verify (M25.2). In-memory
|
|
642
|
+
// only: the window is seconds, so a restart just drops the queue.
|
|
643
|
+
const pendingPings: PendingOsPing[] = [];
|
|
380
644
|
// Persistent per-pane chip cache so we only rewrite a chip when it changed.
|
|
381
645
|
const chipCache = new Map<string, string>();
|
|
646
|
+
// Session-id capture for kinds without a hook integration (codex/cursor):
|
|
647
|
+
// stamps @agent_session_id so restore --resume-agents has its key. Throttled
|
|
648
|
+
// internally; probes only unstamped panes of capturable kinds.
|
|
649
|
+
const capturer = createSessionIdCapturer({
|
|
650
|
+
// Throwing is fine here — the capturer treats a failed stamp as "retry on
|
|
651
|
+
// the next capture window".
|
|
652
|
+
stamp: (paneId, id) => runTmux(["set-option", "-p", "-t", paneId, "@agent_session_id", id]),
|
|
653
|
+
});
|
|
382
654
|
// The fleet snapshotter — pulsed each tick, self-throttled, writes only on a
|
|
383
655
|
// structural change so the fleet can be rebuilt after a tmux-server death.
|
|
384
656
|
const snapshotter = createSnapshotter({
|
|
@@ -387,6 +659,7 @@ export function runUpdaterLoop(): void {
|
|
|
387
659
|
write: writeSnapshot,
|
|
388
660
|
every: config.updater.snapshotEvery,
|
|
389
661
|
});
|
|
662
|
+
const shouldGiveUp = createUnreachableCounter();
|
|
390
663
|
const tick = () => {
|
|
391
664
|
try {
|
|
392
665
|
runUpdaterTick({
|
|
@@ -398,14 +671,22 @@ export function runUpdaterLoop(): void {
|
|
|
398
671
|
chipCache,
|
|
399
672
|
prevState,
|
|
400
673
|
appendEvents,
|
|
674
|
+
prevPaneState,
|
|
401
675
|
listClients: listAttachedClients,
|
|
402
676
|
lastNotified,
|
|
403
677
|
now: () => Date.now(),
|
|
404
678
|
prefs: readNotificationPrefs(),
|
|
405
679
|
sendToasts,
|
|
406
680
|
sendSystem: sendSystemNotification,
|
|
681
|
+
sendTerminal: writeTtys,
|
|
682
|
+
playSound: playPingSound,
|
|
683
|
+
pendingPings,
|
|
684
|
+
locatePane: paneLocation,
|
|
685
|
+
appFocus: () => readAppFocus(),
|
|
686
|
+
persistNotified: (map) => saveLastNotified(map),
|
|
407
687
|
maybeCheckForUpdate: () => maybeCheckForUpdate({ enabled: config.updates.check }),
|
|
408
688
|
markUpdateNotified,
|
|
689
|
+
captureSessionIds: (panes) => capturer.onTick(panes),
|
|
409
690
|
});
|
|
410
691
|
} catch {
|
|
411
692
|
// never let one bad tick kill the loop
|
|
@@ -415,8 +696,14 @@ export function runUpdaterLoop(): void {
|
|
|
415
696
|
} catch {
|
|
416
697
|
// a failed snapshot just means staler disaster-recovery state
|
|
417
698
|
}
|
|
699
|
+
// Self-exit when the server we exist to watch is gone (logged ONCE).
|
|
700
|
+
if (shouldGiveUp(isServerReachable())) {
|
|
701
|
+
console.error(
|
|
702
|
+
`tmux-ide chrome-updater: tmux server unreachable for ${UPDATER_UNREACHABLE_EXIT_TICKS} consecutive ticks — exiting`,
|
|
703
|
+
);
|
|
704
|
+
shutdown();
|
|
705
|
+
}
|
|
418
706
|
};
|
|
419
|
-
tick();
|
|
420
707
|
const timer = setInterval(tick, config.updater.tickMs);
|
|
421
708
|
const shutdown = () => {
|
|
422
709
|
clearInterval(timer);
|
|
@@ -425,4 +712,5 @@ export function runUpdaterLoop(): void {
|
|
|
425
712
|
};
|
|
426
713
|
process.on("SIGTERM", shutdown);
|
|
427
714
|
process.on("SIGINT", shutdown);
|
|
715
|
+
tick();
|
|
428
716
|
}
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Order is "checkout first, binary second": a dev machine that happens to have
|
|
11
11
|
* built the binary still uses its live `.tsx` sources. The binary is consulted
|
|
12
|
-
* only when the checkout sources or `bun` are missing.
|
|
12
|
+
* only when the checkout sources or `bun` are missing. {@link findCompiledTui}
|
|
13
|
+
* probes the shipped/local compiled binary first, then a per-platform binary
|
|
14
|
+
* downloaded at runtime into `~/.tmux-ide/bin/` (see lib/tui-binary.ts).
|
|
13
15
|
*
|
|
14
16
|
* {@link resolveTuiLaunch} is a PURE decision (unit-tested); {@link findCompiledTui}
|
|
15
17
|
* and {@link isBunAvailable} are the thin io probes that feed it.
|
|
@@ -18,6 +20,7 @@ import { existsSync } from "node:fs";
|
|
|
18
20
|
import { dirname, resolve } from "node:path";
|
|
19
21
|
import { fileURLToPath } from "node:url";
|
|
20
22
|
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { findDownloadedTui } from "../lib/tui-binary.ts";
|
|
21
24
|
|
|
22
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
26
|
|
|
@@ -63,7 +66,7 @@ export function resolveTuiLaunch(input: TuiResolveInput): TuiLaunch {
|
|
|
63
66
|
reasons.push("the `bun` runtime is not installed (https://bun.sh)");
|
|
64
67
|
}
|
|
65
68
|
reasons.push(
|
|
66
|
-
"no compiled `tmux-ide-tui` binary was found (build one with `pnpm build:tui`, or reinstall a release that ships it)",
|
|
69
|
+
"no compiled `tmux-ide-tui` binary was found (build one with `pnpm build:tui`, download it with `tmux-ide update --tui-binary`, or reinstall a release that ships it)",
|
|
67
70
|
);
|
|
68
71
|
return { mode: "unavailable", reasons };
|
|
69
72
|
}
|
|
@@ -99,7 +102,12 @@ export function findCompiledTui(): string | null {
|
|
|
99
102
|
if (existsSync(candidate)) return candidate;
|
|
100
103
|
}
|
|
101
104
|
}
|
|
102
|
-
|
|
105
|
+
|
|
106
|
+
// Last: a per-platform binary fetched at runtime by `tmux-ide update
|
|
107
|
+
// --tui-binary` into `~/.tmux-ide/bin/` — the fallback for an npm install with
|
|
108
|
+
// no bun and no shipped compiled binary. Version-stamped, so it only matches
|
|
109
|
+
// the running version (see lib/tui-binary.ts).
|
|
110
|
+
return findDownloadedTui();
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
/** io — is the `bun` runtime callable? */
|
|
@@ -49,6 +49,55 @@ export function parseAuthority(raw: string | undefined, nowSec: number): AgentSt
|
|
|
49
49
|
return state as AgentStatus;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Display-metadata budget — `@agent_status_text` / `@agent_display_name`
|
|
53
|
+
* values are clamped to this many characters (ellipsis included). */
|
|
54
|
+
export const AGENT_TEXT_MAX = 32;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sanitize a self-reported display-metadata pane option
|
|
58
|
+
* (`@agent_status_text` / `@agent_display_name`) — PURE, never throws.
|
|
59
|
+
*
|
|
60
|
+
* ANSI escape sequences are stripped, remaining control characters become
|
|
61
|
+
* spaces, runs of whitespace collapse to one space, and the result is clamped
|
|
62
|
+
* to {@link AGENT_TEXT_MAX} characters (with a trailing ellipsis when cut).
|
|
63
|
+
* Returns undefined for an absent or effectively-empty value, so callers can
|
|
64
|
+
* spread the field additively (`...(text ? { statusText: text } : {})`).
|
|
65
|
+
*
|
|
66
|
+
* Freshness is NOT judged here: the metadata options carry no epoch of their
|
|
67
|
+
* own — they ride the pane's `@agent_state` stamp, and the caller surfaces
|
|
68
|
+
* them only while {@link parseAuthority} reports that stamp fresh.
|
|
69
|
+
*/
|
|
70
|
+
export function sanitizeAgentText(raw: string | undefined): string | undefined {
|
|
71
|
+
if (!raw) return undefined;
|
|
72
|
+
const cleaned = raw
|
|
73
|
+
// CSI escape sequences (colors, cursor moves) — drop entirely.
|
|
74
|
+
// eslint-disable-next-line no-control-regex
|
|
75
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[\x40-\x7e]/g, "")
|
|
76
|
+
// Any remaining C0 control chars (incl. bare ESC, tabs, newlines) + DEL.
|
|
77
|
+
// eslint-disable-next-line no-control-regex
|
|
78
|
+
.replace(/[\x00-\x1f\x7f]/g, " ")
|
|
79
|
+
.replace(/\s+/g, " ")
|
|
80
|
+
.trim();
|
|
81
|
+
if (cleaned.length === 0) return undefined;
|
|
82
|
+
if (cleaned.length <= AGENT_TEXT_MAX) return cleaned;
|
|
83
|
+
return cleaned.slice(0, AGENT_TEXT_MAX - 1) + "…";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract the epoch STAMP from an `@agent_state` value (`"<state>:<epoch>"`) —
|
|
88
|
+
* the "since" timestamp of the reported state. PURE — returns the epoch, or
|
|
89
|
+
* null when the value is absent or its stamp isn't a finite number. Staleness
|
|
90
|
+
* is NOT considered here (that's {@link parseAuthority}'s job); callers pair
|
|
91
|
+
* this with a fresh `parseAuthority` result to surface a state's `since`.
|
|
92
|
+
*/
|
|
93
|
+
export function parseAuthorityEpoch(raw: string | undefined): number | null {
|
|
94
|
+
if (!raw) return null;
|
|
95
|
+
const sep = raw.lastIndexOf(":");
|
|
96
|
+
if (sep === -1) return null;
|
|
97
|
+
const epoch = Number(raw.slice(sep + 1));
|
|
98
|
+
return Number.isFinite(epoch) ? epoch : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
52
101
|
/**
|
|
53
102
|
* Classify a single snapshot against a manifest — PURE, never throws.
|
|
54
103
|
*
|