tmux-ide 2.7.0 → 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 +22 -5
- package/bin/cli.js +3532 -1090
- package/bin/cli.ts +368 -71
- package/package.json +2 -1
- 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/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/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/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 +103 -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/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 +582 -84
- package/packages/daemon/src/tui/chrome/updater.ts +268 -62
- package/packages/daemon/src/tui/detect/classify.ts +34 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
- package/packages/daemon/src/tui/detect/manifest.ts +24 -3
- package/packages/daemon/src/tui/detect/manifests.ts +240 -6
- package/packages/daemon/src/tui/detect/process-tree.ts +13 -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/mirror/agent-chip.ts +40 -11
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
- package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
- package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
- package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
- package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
- 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/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
- package/packages/daemon/src/tui/mirror/palette.ts +299 -9
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
- package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
- package/packages/daemon/src/tui/mirror/selection.ts +122 -8
- package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
- package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/sessions.ts +85 -7
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -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 +88 -2
|
@@ -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,24 +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,
|
|
37
40
|
enabledStates,
|
|
38
41
|
inQuietHours,
|
|
39
42
|
listAttachedClients,
|
|
43
|
+
playPingSound,
|
|
44
|
+
readAppFocus,
|
|
40
45
|
readNotificationPrefs,
|
|
41
46
|
sendSystemNotification,
|
|
42
47
|
sendToasts,
|
|
48
|
+
soundEligible,
|
|
49
|
+
writeTtys,
|
|
50
|
+
type AppFocus,
|
|
43
51
|
type AttachedClient,
|
|
44
52
|
type NotificationPrefs,
|
|
45
53
|
type NotifyEvent,
|
|
46
54
|
type SystemNotification,
|
|
47
55
|
type ToastTarget,
|
|
56
|
+
type TtyWrite,
|
|
48
57
|
} from "./notify.ts";
|
|
58
|
+
import { loadLastNotified, saveLastNotified } from "./notify-state.ts";
|
|
49
59
|
import {
|
|
50
60
|
collectFleetSnapshot,
|
|
51
61
|
createSnapshotter,
|
|
@@ -58,10 +68,15 @@ import { buildStatusline } from "./statusline.ts";
|
|
|
58
68
|
export const STATUS_OPTION = "@tmux_ide_status";
|
|
59
69
|
/** Per-PANE user option holding the pre-rendered agent chip (read by pane-border-format). */
|
|
60
70
|
export const CHIP_OPTION = "@tmux_ide_chip";
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
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";
|
|
65
80
|
/** Server option holding the running updater's pid (a lightweight single-owner guard). */
|
|
66
81
|
export const UPDATER_PID_OPTION = "@tmux_ide_updater_pid";
|
|
67
82
|
/** Default tick cadence — overridable via `updater.tickMs` in the app config. */
|
|
@@ -140,18 +155,49 @@ export interface UpdaterTickDeps {
|
|
|
140
155
|
prevState?: Map<string, AgentStatus>;
|
|
141
156
|
appendEvents?: (events: AgentEventInit[]) => void;
|
|
142
157
|
/**
|
|
143
|
-
* Notification dispatch (optional). When wired alongside `
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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.
|
|
148
167
|
*/
|
|
168
|
+
prevPaneState?: Map<string, AgentStatus>;
|
|
149
169
|
listClients?: () => AttachedClient[];
|
|
150
170
|
lastNotified?: Map<string, number>;
|
|
151
171
|
now?: () => number;
|
|
152
172
|
prefs?: NotificationPrefs;
|
|
153
173
|
sendToasts?: (toasts: ToastTarget[]) => void;
|
|
154
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;
|
|
155
201
|
/**
|
|
156
202
|
* Resolve a pane id to its human `session:window.pane` location for the ping
|
|
157
203
|
* text (optional). Wired to the live tmux {@link paneLocation}; tests inject a
|
|
@@ -168,6 +214,14 @@ export interface UpdaterTickDeps {
|
|
|
168
214
|
*/
|
|
169
215
|
maybeCheckForUpdate?: () => UpdateStatus;
|
|
170
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;
|
|
171
225
|
}
|
|
172
226
|
|
|
173
227
|
/**
|
|
@@ -181,8 +235,11 @@ export function updateSegment(status: UpdateStatus, theme: AppTheme): string {
|
|
|
181
235
|
return `#[range=user|update]#[fg=${theme.accent}]⬆ v${status.latest}#[default]#[norange]`;
|
|
182
236
|
}
|
|
183
237
|
|
|
184
|
-
/** Flatten the project view to a flat per-session status list for {@link diffFleet}.
|
|
185
|
-
|
|
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 }> {
|
|
186
243
|
return projects.flatMap((p) => p.sessions.map((s) => ({ name: s.name, status: s.status })));
|
|
187
244
|
}
|
|
188
245
|
|
|
@@ -213,15 +270,23 @@ export function runUpdaterTick(deps: UpdaterTickDeps): void {
|
|
|
213
270
|
deps.writeStatus(session, buildStatusline(projects, session, 12, theme, extra));
|
|
214
271
|
}
|
|
215
272
|
writeChips(deps, adopted, panes, theme);
|
|
273
|
+
deps.captureSessionIds?.(panes);
|
|
216
274
|
if (update?.updateAvailable && update.latest) dispatchUpdateToast(deps, update.latest);
|
|
217
275
|
if (deps.prevState && deps.appendEvents) {
|
|
218
276
|
const { events, state } = diffFleet(deps.prevState, fleetStatuses(projects));
|
|
219
277
|
deps.prevState.clear();
|
|
220
278
|
for (const [name, status] of state) deps.prevState.set(name, status);
|
|
221
|
-
if (events.length > 0)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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);
|
|
225
290
|
}
|
|
226
291
|
}
|
|
227
292
|
|
|
@@ -251,58 +316,72 @@ function writeChips(
|
|
|
251
316
|
}
|
|
252
317
|
|
|
253
318
|
/**
|
|
254
|
-
* PURE —
|
|
255
|
-
* panes
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
): PaneDetail | null {
|
|
265
|
-
const matching = panes.filter((p) => p.sessionName === session && p.status === to);
|
|
266
|
-
if (matching.length === 0) return null;
|
|
267
|
-
return matching.find((p) => p.agent !== null) ?? matching[0]!;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* PURE — enrich session-level transitions with the pane's `agent` id and human
|
|
272
|
-
* `location` so {@link notifyMessage} can name who needs the user. Only the
|
|
273
|
-
* notifiable states (blocked/done) get resolved — everything else keeps the bare
|
|
274
|
-
* session as its location and is filtered out downstream anyway, so `locate`
|
|
275
|
-
* (a live tmux call) only fires for a real ping.
|
|
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.
|
|
276
329
|
*/
|
|
277
|
-
export function
|
|
278
|
-
|
|
330
|
+
export function diffPaneTransitions(
|
|
331
|
+
prev: Map<string, AgentStatus>,
|
|
279
332
|
panes: PaneDetail[],
|
|
280
333
|
locate?: (paneId: string) => string,
|
|
281
334
|
): NotifyEvent[] {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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";
|
|
291
367
|
}
|
|
292
368
|
|
|
293
369
|
/**
|
|
294
370
|
* Ping the user about who needs them from this tick's transitions. Only runs
|
|
295
371
|
* when the notification deps are wired, notifications are `enabled`, AND at
|
|
296
372
|
* least one channel is on. `lastNotified` is mutated in place so the debounce
|
|
297
|
-
* (the flap guard) persists across ticks. The
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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.
|
|
300
379
|
*/
|
|
301
380
|
function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): void {
|
|
302
|
-
const { listClients, lastNotified, now, prefs, sendToasts: toast
|
|
381
|
+
const { listClients, lastNotified, now, prefs, sendToasts: toast } = deps;
|
|
303
382
|
if (!listClients || !lastNotified || !now || !prefs) return;
|
|
304
383
|
if (!prefs.enabled) return;
|
|
305
|
-
if (!prefs
|
|
384
|
+
if (!anyChannelOn(prefs)) return;
|
|
306
385
|
const nowMs = now();
|
|
307
386
|
const decision = decideNotifications(
|
|
308
387
|
events,
|
|
@@ -310,12 +389,79 @@ function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): vo
|
|
|
310
389
|
lastNotified,
|
|
311
390
|
nowMs,
|
|
312
391
|
enabledStates(prefs),
|
|
392
|
+
deps.appFocus?.() ?? null,
|
|
313
393
|
);
|
|
314
394
|
lastNotified.clear();
|
|
315
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);
|
|
316
399
|
if (prefs.toast && toast) toast(decision.toasts);
|
|
317
|
-
if (
|
|
318
|
-
|
|
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();
|
|
319
465
|
}
|
|
320
466
|
}
|
|
321
467
|
|
|
@@ -381,15 +527,14 @@ export function updaterRunning(): boolean {
|
|
|
381
527
|
|
|
382
528
|
/**
|
|
383
529
|
* Ensure the background updater is running: if the `_tmux-ide-chrome` session
|
|
384
|
-
* isn't up, start it detached running `tmux-ide chrome-updater`. `
|
|
385
|
-
* 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
|
|
386
531
|
* so it's hidden from the bar/switcher. Best-effort — a chrome failure must
|
|
387
532
|
* never break adopt/launch.
|
|
388
533
|
*/
|
|
389
534
|
export function startUpdaterIfNeeded(): void {
|
|
390
535
|
try {
|
|
391
536
|
if (updaterRunning()) return;
|
|
392
|
-
runTmux(
|
|
537
|
+
runTmux(updaterSpawnArgv());
|
|
393
538
|
} catch {
|
|
394
539
|
// best-effort — the bar still works via the last-written var
|
|
395
540
|
}
|
|
@@ -441,11 +586,43 @@ function releaseUpdater(): void {
|
|
|
441
586
|
}
|
|
442
587
|
}
|
|
443
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
|
+
|
|
444
618
|
/**
|
|
445
619
|
* Run the updater loop forever (the body of `tmux-ide chrome-updater`). Claims
|
|
446
620
|
* single-ownership, then rewrites every adopted session's bar immediately and
|
|
447
621
|
* every {@link TICK_MS} thereafter behind ONE persistent tracker (so `done`
|
|
448
|
-
* 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.
|
|
449
626
|
*/
|
|
450
627
|
export function runUpdaterLoop(): void {
|
|
451
628
|
if (!claimUpdater()) return;
|
|
@@ -456,10 +633,24 @@ export function runUpdaterLoop(): void {
|
|
|
456
633
|
const tracker = createStatusTracker();
|
|
457
634
|
// Persistent across ticks so `diffFleet` can spot working→done etc.
|
|
458
635
|
const prevState = new Map<string, AgentStatus>();
|
|
459
|
-
//
|
|
460
|
-
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[] = [];
|
|
461
644
|
// Persistent per-pane chip cache so we only rewrite a chip when it changed.
|
|
462
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
|
+
});
|
|
463
654
|
// The fleet snapshotter — pulsed each tick, self-throttled, writes only on a
|
|
464
655
|
// structural change so the fleet can be rebuilt after a tmux-server death.
|
|
465
656
|
const snapshotter = createSnapshotter({
|
|
@@ -468,6 +659,7 @@ export function runUpdaterLoop(): void {
|
|
|
468
659
|
write: writeSnapshot,
|
|
469
660
|
every: config.updater.snapshotEvery,
|
|
470
661
|
});
|
|
662
|
+
const shouldGiveUp = createUnreachableCounter();
|
|
471
663
|
const tick = () => {
|
|
472
664
|
try {
|
|
473
665
|
runUpdaterTick({
|
|
@@ -479,15 +671,22 @@ export function runUpdaterLoop(): void {
|
|
|
479
671
|
chipCache,
|
|
480
672
|
prevState,
|
|
481
673
|
appendEvents,
|
|
674
|
+
prevPaneState,
|
|
482
675
|
listClients: listAttachedClients,
|
|
483
676
|
lastNotified,
|
|
484
677
|
now: () => Date.now(),
|
|
485
678
|
prefs: readNotificationPrefs(),
|
|
486
679
|
sendToasts,
|
|
487
680
|
sendSystem: sendSystemNotification,
|
|
681
|
+
sendTerminal: writeTtys,
|
|
682
|
+
playSound: playPingSound,
|
|
683
|
+
pendingPings,
|
|
488
684
|
locatePane: paneLocation,
|
|
685
|
+
appFocus: () => readAppFocus(),
|
|
686
|
+
persistNotified: (map) => saveLastNotified(map),
|
|
489
687
|
maybeCheckForUpdate: () => maybeCheckForUpdate({ enabled: config.updates.check }),
|
|
490
688
|
markUpdateNotified,
|
|
689
|
+
captureSessionIds: (panes) => capturer.onTick(panes),
|
|
491
690
|
});
|
|
492
691
|
} catch {
|
|
493
692
|
// never let one bad tick kill the loop
|
|
@@ -497,8 +696,14 @@ export function runUpdaterLoop(): void {
|
|
|
497
696
|
} catch {
|
|
498
697
|
// a failed snapshot just means staler disaster-recovery state
|
|
499
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
|
+
}
|
|
500
706
|
};
|
|
501
|
-
tick();
|
|
502
707
|
const timer = setInterval(tick, config.updater.tickMs);
|
|
503
708
|
const shutdown = () => {
|
|
504
709
|
clearInterval(timer);
|
|
@@ -507,4 +712,5 @@ export function runUpdaterLoop(): void {
|
|
|
507
712
|
};
|
|
508
713
|
process.on("SIGTERM", shutdown);
|
|
509
714
|
process.on("SIGINT", shutdown);
|
|
715
|
+
tick();
|
|
510
716
|
}
|
|
@@ -49,6 +49,40 @@ 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
|
+
|
|
52
86
|
/**
|
|
53
87
|
* Extract the epoch STAMP from an `@agent_state` value (`"<state>:<epoch>"`) —
|
|
54
88
|
* the "since" timestamp of the reported state. PURE — returns the epoch, or
|
|
@@ -30,9 +30,20 @@ import { join } from "node:path";
|
|
|
30
30
|
import type { AgentManifest, Rule, StateRules } from "./manifest.ts";
|
|
31
31
|
import { BUNDLED_MANIFESTS } from "./manifests.ts";
|
|
32
32
|
|
|
33
|
-
/** Directory scanned for user override manifests.
|
|
33
|
+
/** Directory scanned for user override manifests. Honors `TMUX_IDE_HOME` (the
|
|
34
|
+
* state-home override every other `~/.tmux-ide` consumer respects) so tests
|
|
35
|
+
* never read a real user's overrides. */
|
|
34
36
|
export function overrideDir(): string {
|
|
35
|
-
|
|
37
|
+
const home = process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
|
|
38
|
+
return join(home, "agent-detection");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The fetched manifest-pack file (`tmux-ide update --manifests` installs it;
|
|
42
|
+
* see `lib/manifest-pack.ts` for the format + publish contract). Lives in a
|
|
43
|
+
* SUBDIRECTORY so {@link readOverrideManifests}'s top-level `*.json` scan
|
|
44
|
+
* never sees it — that is what keeps user files ABOVE the pack. */
|
|
45
|
+
export function packFile(dir = overrideDir()): string {
|
|
46
|
+
return join(dir, "pack", "manifest-pack.json");
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
/**
|
|
@@ -167,11 +178,49 @@ function warnOnce(path: string, reason: string): void {
|
|
|
167
178
|
}
|
|
168
179
|
|
|
169
180
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
181
|
+
* Read the installed manifest PACK (fetched by `tmux-ide update --manifests`),
|
|
182
|
+
* or `[]` when none is installed. Thin io — a malformed pack file is skipped
|
|
183
|
+
* with a one-time stderr warning, never a throw (the pack was schema-validated
|
|
184
|
+
* at install time; this guards a hand-edited/corrupted file). Each entry gets
|
|
185
|
+
* the same structural validation + normalization as a user override file.
|
|
186
|
+
*/
|
|
187
|
+
export function readPackManifests(dir = overrideDir()): AgentManifest[] {
|
|
188
|
+
const path = packFile(dir);
|
|
189
|
+
let raw: string;
|
|
190
|
+
try {
|
|
191
|
+
raw = readFileSync(path, "utf8");
|
|
192
|
+
} catch {
|
|
193
|
+
// No pack installed (the common case).
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const parsed: unknown = JSON.parse(raw);
|
|
198
|
+
const manifests = (parsed as { manifests?: unknown })?.manifests;
|
|
199
|
+
if (!Array.isArray(manifests)) {
|
|
200
|
+
warnOnce(path, "not a manifest pack (missing manifests[])");
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
const valid: AgentManifest[] = [];
|
|
204
|
+
for (const entry of manifests) {
|
|
205
|
+
if (validateManifestShape(entry)) valid.push(normalizeStates(entry));
|
|
206
|
+
else warnOnce(path, "pack entry is not a valid AgentManifest — entry skipped");
|
|
207
|
+
}
|
|
208
|
+
return valid;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
warnOnce(path, err instanceof Error ? err.message : String(err));
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Load the full manifest set: bundled + pack + user overrides, merged in that
|
|
217
|
+
* PRECEDENCE order — a fetched pack refines the bundled set, and a user's own
|
|
218
|
+
* `agent-detection/*.json` file always beats both (it is merged LAST). Does io
|
|
219
|
+
* on every call — prefer {@link getManifests} for hot paths.
|
|
172
220
|
*/
|
|
173
221
|
export function loadManifests(): AgentManifest[] {
|
|
174
|
-
|
|
222
|
+
const withPack = mergeManifests(BUNDLED_MANIFESTS, readPackManifests());
|
|
223
|
+
return mergeManifests(withPack, readOverrideManifests());
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
/** Process-lifetime cache for the merged manifest set. */
|
|
@@ -175,10 +175,31 @@ export function explain(
|
|
|
175
175
|
return { state: winner ? winner.state : null, checked };
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/** Escape a string for literal use inside a RegExp source. */
|
|
179
|
+
function escapeRegex(s: string): string {
|
|
180
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Does `haystack` contain `needle` as a WHOLE alphanumeric segment (delimited
|
|
185
|
+
* by start/end or any non-alphanumeric)? `grok-build` contains the segment
|
|
186
|
+
* `grok`; `pip` does NOT contain the segment `pi`. This is what keeps short
|
|
187
|
+
* manifest command tokens (pi, devin, kimi — M25.4) from substring-matching
|
|
188
|
+
* unrelated commands (`pip`, `vi`, `api-server`).
|
|
189
|
+
*/
|
|
190
|
+
function containsSegment(haystack: string, needle: string): boolean {
|
|
191
|
+
if (!haystack.includes(needle)) return false;
|
|
192
|
+
return new RegExp(`(^|[^a-z0-9])${escapeRegex(needle)}([^a-z0-9]|$)`).test(haystack);
|
|
193
|
+
}
|
|
194
|
+
|
|
178
195
|
/**
|
|
179
196
|
* Pick the manifest whose `commands` best match a pane's current command.
|
|
180
|
-
* Prefers an exact (case-insensitive) match, then a
|
|
181
|
-
* direction
|
|
197
|
+
* Prefers an exact (case-insensitive) match, then a SEGMENT match in either
|
|
198
|
+
* direction — the command contains a manifest token as a whole segment
|
|
199
|
+
* (`grok-build` → `grok`, `.kilo` → `kilo`) or vice versa (`cursor` →
|
|
200
|
+
* `cursor-agent`). Raw substring matching was retired in M25.4: it made every
|
|
201
|
+
* short token a false-positive machine (`pi` ⊂ `pip`, `vi` ⊂ `devin`).
|
|
202
|
+
* Returns undefined when nothing applies.
|
|
182
203
|
*/
|
|
183
204
|
export function pickManifest(
|
|
184
205
|
command: string,
|
|
@@ -193,7 +214,7 @@ export function pickManifest(
|
|
|
193
214
|
return manifests.find((m) =>
|
|
194
215
|
m.commands.some((c) => {
|
|
195
216
|
const name = c.toLowerCase();
|
|
196
|
-
return cmd
|
|
217
|
+
return containsSegment(cmd, name) || containsSegment(name, cmd);
|
|
197
218
|
}),
|
|
198
219
|
);
|
|
199
220
|
}
|