@phnx-labs/agents-cli 1.22.71 → 1.22.73
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/CHANGELOG.md +45 -0
- package/README.md +2 -0
- package/dist/cli/command-registry.d.ts +1 -1
- package/dist/cli/command-registry.js +2 -1
- package/dist/commands/packages-materialize.d.ts +17 -0
- package/dist/commands/packages-materialize.js +93 -0
- package/dist/commands/packages.d.ts +6 -4
- package/dist/commands/packages.js +8 -4
- package/dist/commands/repo.js +8 -8
- package/dist/commands/sessions-picker.js +38 -7
- package/dist/commands/sessions.js +6 -5
- package/dist/lib/actor.d.ts +36 -4
- package/dist/lib/actor.js +73 -9
- package/dist/lib/agent-spec/index.d.ts +4 -0
- package/dist/lib/agent-spec/index.js +5 -0
- package/dist/lib/agent-spec/materialize.d.ts +13 -0
- package/dist/lib/agent-spec/materialize.js +414 -0
- package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
- package/dist/lib/agent-spec/package-resolve.js +274 -0
- package/dist/lib/agent-spec/package-schema.d.ts +5 -0
- package/dist/lib/agent-spec/package-schema.js +147 -0
- package/dist/lib/agent-spec/package-types.d.ts +121 -0
- package/dist/lib/agent-spec/package-types.js +11 -0
- package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
- package/dist/lib/daemon/usage-sync-service.js +27 -5
- package/dist/lib/exec.js +3 -0
- package/dist/lib/fleet-shared-state.d.ts +30 -0
- package/dist/lib/fleet-shared-state.js +5 -0
- package/dist/lib/hooks/install.d.ts +31 -1
- package/dist/lib/hooks/install.js +44 -2
- package/dist/lib/mcp.d.ts +14 -2
- package/dist/lib/mcp.js +12 -2
- package/dist/lib/packages/output-home.d.ts +39 -0
- package/dist/lib/packages/output-home.js +203 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.js +26 -0
- package/dist/lib/project-resources.js +14 -5
- package/dist/lib/session/active.d.ts +12 -0
- package/dist/lib/session/active.js +5 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +60 -1
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/mirror.d.ts +67 -0
- package/dist/lib/session/mirror.js +158 -0
- package/dist/lib/session/types.d.ts +18 -0
- package/dist/lib/spinner.d.ts +39 -0
- package/dist/lib/spinner.js +41 -0
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { type ToolScanResumePoint } from './tool-store.js';
|
|
|
13
13
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
14
14
|
* assert against the constant instead of hardcoding a number that every bump
|
|
15
15
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
16
|
-
export declare const SCHEMA_VERSION =
|
|
16
|
+
export declare const SCHEMA_VERSION = 47;
|
|
17
17
|
/**
|
|
18
18
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
19
19
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -443,6 +443,65 @@ export declare function readSessionContent(id: string): string | undefined;
|
|
|
443
443
|
* survives for this session.
|
|
444
444
|
*/
|
|
445
445
|
export declare function readArchivedSessionPreview<T>(id: string): T | undefined;
|
|
446
|
+
/** A local-origin session, projected to the compact fields the fleet mirror publishes. */
|
|
447
|
+
export interface LocalMirrorSource {
|
|
448
|
+
id: string;
|
|
449
|
+
shortId: string;
|
|
450
|
+
agent: string;
|
|
451
|
+
version: string | null;
|
|
452
|
+
machine: string | null;
|
|
453
|
+
cwd: string | null;
|
|
454
|
+
topic: string | null;
|
|
455
|
+
firstUserMessage: string | null;
|
|
456
|
+
label: string | null;
|
|
457
|
+
lastActivity: string | null;
|
|
458
|
+
timestamp: string;
|
|
459
|
+
ticketId: string | null;
|
|
460
|
+
prUrl: string | null;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The most-recently-active sessions whose transcript is genuinely local to this
|
|
464
|
+
* box — a real `file_path`, not a peer mirror — for publishing to the fleet
|
|
465
|
+
* session mirror (PHNX-3792). Bounded and team-origin-excluded so the published
|
|
466
|
+
* payload stays the size a picker would show. Never returns a row this box is
|
|
467
|
+
* itself mirroring from a peer (`mirror_synced_at IS NULL`).
|
|
468
|
+
*/
|
|
469
|
+
export declare function queryLocalOriginSessionsForMirror(self: string, limit: number): LocalMirrorSource[];
|
|
470
|
+
/** One peer session digest to write into this box's local mirror. */
|
|
471
|
+
export interface MirrorSessionUpsert {
|
|
472
|
+
id: string;
|
|
473
|
+
shortId: string;
|
|
474
|
+
agent: string;
|
|
475
|
+
version?: string | null;
|
|
476
|
+
machine: string;
|
|
477
|
+
cwd?: string | null;
|
|
478
|
+
topic?: string | null;
|
|
479
|
+
firstUser?: string | null;
|
|
480
|
+
label?: string | null;
|
|
481
|
+
lastActivity?: string | null;
|
|
482
|
+
timestamp: string;
|
|
483
|
+
ticketId?: string | null;
|
|
484
|
+
prUrl?: string | null;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Write one peer session's digest into the local `sessions` index as a mirror
|
|
488
|
+
* row (PHNX-3792), so the picker/list/focus render its topic/preview inline
|
|
489
|
+
* with no per-row SSH. The upsert is GUARDED: it never overwrites a genuine
|
|
490
|
+
* local transcript row (`file_path` non-empty and not itself a mirror), so a
|
|
491
|
+
* mirror can only create a new peer-only row or enrich an existing empty-file
|
|
492
|
+
* host-dispatch stub / older mirror. Returns true when a row was written (the
|
|
493
|
+
* caller then persists the matching preview digest); false when a real row was
|
|
494
|
+
* protected. `machine` carries the publisher's recorded execution host so the
|
|
495
|
+
* row collapses with the live fan-out row under the same `machine:id` key.
|
|
496
|
+
*/
|
|
497
|
+
export declare function upsertMirrorSession(row: MirrorSessionUpsert, source: string, syncedAt: number): boolean;
|
|
498
|
+
/**
|
|
499
|
+
* Drop peer mirror rows (and their cached digests) whose last sync predates the
|
|
500
|
+
* cutoff — the size ceiling / staleness pruner for the fleet session mirror
|
|
501
|
+
* (PHNX-3792). Only ever touches mirror rows (`mirror_synced_at IS NOT NULL`),
|
|
502
|
+
* never a genuine local or host-dispatch row. Returns the number of rows pruned.
|
|
503
|
+
*/
|
|
504
|
+
export declare function pruneMirrorSessions(cutoffMs: number): number;
|
|
446
505
|
/** Plugin provenance already indexed for resources used by one session. */
|
|
447
506
|
export declare function getSessionPlugins(id: string): string[];
|
|
448
507
|
export type UsageRollupGroup = 'agent' | 'project' | 'day' | 'account';
|
package/dist/lib/session/db.js
CHANGED
|
@@ -28,7 +28,7 @@ const DB_PATH = getSessionsDbPath();
|
|
|
28
28
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
29
29
|
* assert against the constant instead of hardcoding a number that every bump
|
|
30
30
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
31
|
-
export const SCHEMA_VERSION =
|
|
31
|
+
export const SCHEMA_VERSION = 47;
|
|
32
32
|
/**
|
|
33
33
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
34
34
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -135,6 +135,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
135
135
|
linear_project_url TEXT,
|
|
136
136
|
actor TEXT,
|
|
137
137
|
initiated_by TEXT,
|
|
138
|
+
phoenix_id TEXT,
|
|
138
139
|
used_browser INTEGER,
|
|
139
140
|
used_computer INTEGER,
|
|
140
141
|
-- Epoch ms of the first time a previously-scanned transcript was confirmed
|
|
@@ -143,7 +144,14 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
143
144
|
-- DB and flagged, instead of being dropped when the file vanishes. A row whose
|
|
144
145
|
-- file is missing but which has NO cached content is a phantom (a stale/moved
|
|
145
146
|
-- file_path), never stamped, still suppressed — see querySessions.
|
|
146
|
-
archived_at INTEGER
|
|
147
|
+
archived_at INTEGER,
|
|
148
|
+
-- Epoch ms this row was last written from a PEER's synced session mirror
|
|
149
|
+
-- (PHNX-3792), NULL for a genuine local/host-dispatch row. Non-NULL marks a
|
|
150
|
+
-- row created/enriched purely from a fleet-synced digest so it can be pruned
|
|
151
|
+
-- by age without touching real rows; mirror_source names the publishing
|
|
152
|
+
-- device. A row keeps a NULL mirror_synced_at once it gains a real transcript.
|
|
153
|
+
mirror_synced_at INTEGER,
|
|
154
|
+
mirror_source TEXT
|
|
147
155
|
);
|
|
148
156
|
CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
|
|
149
157
|
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
@@ -151,7 +159,8 @@ CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
|
|
|
151
159
|
CREATE INDEX IF NOT EXISTS idx_sessions_file_path ON sessions(file_path);
|
|
152
160
|
CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
|
|
153
161
|
-- idx_sessions_machine_ts / idx_sessions_agent_ts are created after migration
|
|
154
|
-
-- v17 guarantees the machine column exists (same pattern as last_activity)
|
|
162
|
+
-- v17 guarantees the machine column exists (same pattern as last_activity);
|
|
163
|
+
-- idx_sessions_mirror_synced likewise waits for migration v46's column add.
|
|
155
164
|
|
|
156
165
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
|
|
157
166
|
session_id UNINDEXED,
|
|
@@ -1247,6 +1256,29 @@ function migrateSchema(db, fromVersion) {
|
|
|
1247
1256
|
if (!cols.has('first_user_message'))
|
|
1248
1257
|
db.exec(`ALTER TABLE sessions ADD COLUMN first_user_message TEXT`);
|
|
1249
1258
|
}
|
|
1259
|
+
if (fromVersion < 46) {
|
|
1260
|
+
// v45 -> v46: mirror provenance for fleet-synced peer session digests
|
|
1261
|
+
// (PHNX-3792). Additive columns, no ledger wipe: they are written only by the
|
|
1262
|
+
// session-mirror consume path (never a transcript scan), so re-parsing every
|
|
1263
|
+
// indexed transcript would be pure churn — a genuine local row keeps them NULL.
|
|
1264
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
|
|
1265
|
+
if (!cols.has('mirror_synced_at'))
|
|
1266
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN mirror_synced_at INTEGER`);
|
|
1267
|
+
if (!cols.has('mirror_source'))
|
|
1268
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN mirror_source TEXT`);
|
|
1269
|
+
// The idx_sessions_mirror_synced index is created unconditionally after this
|
|
1270
|
+
// block (fresh DBs skip migrations), alongside idx_sessions_last_activity.
|
|
1271
|
+
}
|
|
1272
|
+
if (fromVersion < 47) {
|
|
1273
|
+
// v46 -> v47: carry the actor's Phoenix id (PHNX-3798). Additive, write-once
|
|
1274
|
+
// launch metadata joined from the actor sidecar — never transcript-derived,
|
|
1275
|
+
// exactly like actor/initiated_by (v19) — so there is no ledger wipe: a rescan
|
|
1276
|
+
// can't backfill it and forcing a full re-parse would be pure churn. Existing
|
|
1277
|
+
// rows stay NULL until the sidecar-join fills them (the ON CONFLICT COALESCEs).
|
|
1278
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
|
|
1279
|
+
if (!cols.has('phoenix_id'))
|
|
1280
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN phoenix_id TEXT`);
|
|
1281
|
+
}
|
|
1250
1282
|
}
|
|
1251
1283
|
/**
|
|
1252
1284
|
* Stamp `account_key` / `account_org` / `account` on every Claude row from its
|
|
@@ -1322,6 +1354,10 @@ export function getDB() {
|
|
|
1322
1354
|
// DB would fail the index build on a column it doesn't have yet.
|
|
1323
1355
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
|
|
1324
1356
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_origin ON sessions(origin)`);
|
|
1357
|
+
// Same fresh-vs-migrated rule: the column is guaranteed above (fresh from
|
|
1358
|
+
// CREATE TABLE, existing from migration v46), so index the mirror pruner's
|
|
1359
|
+
// scan column here rather than in SCHEMA (PHNX-3792).
|
|
1360
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_mirror_synced ON sessions(mirror_synced_at)`);
|
|
1325
1361
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_routine_run_id ON sessions(routine_run_id)`);
|
|
1326
1362
|
// Account attribution repair. Two ways a Claude row ends up wrong even at v33:
|
|
1327
1363
|
// an older CLI (whose INSERT does not name the column) writes NULL, and a DB
|
|
@@ -1796,7 +1832,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1796
1832
|
pr_url, pr_number, worktree_slug, ticket_id, spawned_team,
|
|
1797
1833
|
sub_agent_count, background_shell_count, plan, todos,
|
|
1798
1834
|
recent_directories_touched, linear_project, linear_project_url, machine,
|
|
1799
|
-
actor, initiated_by, used_browser, used_computer
|
|
1835
|
+
actor, initiated_by, phoenix_id, used_browser, used_computer
|
|
1800
1836
|
) VALUES (
|
|
1801
1837
|
@id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
|
|
1802
1838
|
@version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
|
|
@@ -1807,7 +1843,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1807
1843
|
@pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team,
|
|
1808
1844
|
@sub_agent_count, @background_shell_count, @plan, @todos,
|
|
1809
1845
|
@recent_directories_touched, @linear_project, @linear_project_url, @machine,
|
|
1810
|
-
@actor, @initiated_by, @used_browser, @used_computer
|
|
1846
|
+
@actor, @initiated_by, @phoenix_id, @used_browser, @used_computer
|
|
1811
1847
|
)
|
|
1812
1848
|
ON CONFLICT(id) DO UPDATE SET
|
|
1813
1849
|
short_id = excluded.short_id,
|
|
@@ -1891,7 +1927,28 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1891
1927
|
-- the actor sidecar landed — once the sidecar-join finally provides one. Plain
|
|
1892
1928
|
-- exclusion locked those rows to NULL forever (RUSH-2018/2019 fix).
|
|
1893
1929
|
actor = COALESCE(sessions.actor, excluded.actor),
|
|
1894
|
-
initiated_by = COALESCE(sessions.initiated_by, excluded.initiated_by)
|
|
1930
|
+
initiated_by = COALESCE(sessions.initiated_by, excluded.initiated_by),
|
|
1931
|
+
-- Phoenix id rides the same write-once join as actor/initiated_by: a rescan
|
|
1932
|
+
-- carries none (excluded.phoenix_id is NULL -> stored value wins), but a row
|
|
1933
|
+
-- indexed NULL-first backfills once the sidecar-join finally provides one.
|
|
1934
|
+
phoenix_id = COALESCE(sessions.phoenix_id, excluded.phoenix_id),
|
|
1935
|
+
-- A genuine local transcript write (the scanner always carries a non-empty
|
|
1936
|
+
-- file_path) reclaims a row that was first seeded as a peer mirror: clear the
|
|
1937
|
+
-- mirror provenance so pruneMirrorSessions (which deletes mirror_synced_at IS
|
|
1938
|
+
-- NOT NULL rows past the age cutoff) can never delete real local content, and
|
|
1939
|
+
-- so queryLocalOriginSessionsForMirror (mirror_synced_at IS NULL) re-publishes
|
|
1940
|
+
-- it. Without this the stamp set by upsertMirrorSession survived a later real
|
|
1941
|
+
-- scan, since an omitted column keeps its prior value on upsert (PHNX-3792).
|
|
1942
|
+
-- An empty-file write (a host-dispatch stub) is NOT a real transcript, so it
|
|
1943
|
+
-- leaves the stamp intact — matching the mirror guard's own file_path test.
|
|
1944
|
+
mirror_synced_at = CASE
|
|
1945
|
+
WHEN excluded.file_path IS NOT NULL AND excluded.file_path <> '' THEN NULL
|
|
1946
|
+
ELSE sessions.mirror_synced_at
|
|
1947
|
+
END,
|
|
1948
|
+
mirror_source = CASE
|
|
1949
|
+
WHEN excluded.file_path IS NOT NULL AND excluded.file_path <> '' THEN NULL
|
|
1950
|
+
ELSE sessions.mirror_source
|
|
1951
|
+
END
|
|
1895
1952
|
`);
|
|
1896
1953
|
/**
|
|
1897
1954
|
* Did this session emit at least one browser/computer-automation event?
|
|
@@ -2159,6 +2216,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
|
|
|
2159
2216
|
machine: resolveMachine(meta),
|
|
2160
2217
|
actor: meta.actor ?? actorRec?.actor ?? null,
|
|
2161
2218
|
initiated_by: meta.initiatedBy ?? actorRec?.initiatedBy ?? null,
|
|
2219
|
+
phoenix_id: meta.phoenixId ?? actorRec?.phoenixId ?? null,
|
|
2162
2220
|
used_browser: toolUsage.usedBrowser ? 1 : 0,
|
|
2163
2221
|
used_computer: toolUsage.usedComputer ? 1 : 0,
|
|
2164
2222
|
};
|
|
@@ -2398,6 +2456,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
2398
2456
|
machine: resolveMachine(meta),
|
|
2399
2457
|
actor: meta.actor ?? actorIndex.get(meta.id)?.actor ?? null,
|
|
2400
2458
|
initiated_by: meta.initiatedBy ?? actorIndex.get(meta.id)?.initiatedBy ?? null,
|
|
2459
|
+
phoenix_id: meta.phoenixId ?? actorIndex.get(meta.id)?.phoenixId ?? null,
|
|
2401
2460
|
used_browser: toolUsage.usedBrowser ? 1 : 0,
|
|
2402
2461
|
used_computer: toolUsage.usedComputer ? 1 : 0,
|
|
2403
2462
|
};
|
|
@@ -2633,6 +2692,7 @@ function rowToMeta(row) {
|
|
|
2633
2692
|
// Narrow the free-text column to the known kinds; an unexpected value maps
|
|
2634
2693
|
// to undefined rather than being asserted as a valid kind.
|
|
2635
2694
|
initiatedBy: row.initiated_by === 'human' || row.initiated_by === 'agent' ? row.initiated_by : undefined,
|
|
2695
|
+
phoenixId: row.phoenix_id ?? undefined,
|
|
2636
2696
|
// NULL = never computed by this scanner (legacy row) — leave undefined so
|
|
2637
2697
|
// the sessions picker knows to fall back to the transcript-regex detection
|
|
2638
2698
|
// instead of trusting a false "never used browser/computer".
|
|
@@ -2643,6 +2703,8 @@ function rowToMeta(row) {
|
|
|
2643
2703
|
// flagged, never dropped (RUSH-2436). NULL leaves both undefined (live row).
|
|
2644
2704
|
archivedAt: row.archived_at ?? undefined,
|
|
2645
2705
|
archived: row.archived_at != null ? true : undefined,
|
|
2706
|
+
mirrorSyncedAt: row.mirror_synced_at ?? undefined,
|
|
2707
|
+
mirrorSource: row.mirror_source ?? undefined,
|
|
2646
2708
|
};
|
|
2647
2709
|
}
|
|
2648
2710
|
function isSessionRunMode(value) {
|
|
@@ -3278,6 +3340,129 @@ export function readArchivedSessionPreview(id) {
|
|
|
3278
3340
|
return undefined;
|
|
3279
3341
|
}
|
|
3280
3342
|
}
|
|
3343
|
+
/**
|
|
3344
|
+
* The most-recently-active sessions whose transcript is genuinely local to this
|
|
3345
|
+
* box — a real `file_path`, not a peer mirror — for publishing to the fleet
|
|
3346
|
+
* session mirror (PHNX-3792). Bounded and team-origin-excluded so the published
|
|
3347
|
+
* payload stays the size a picker would show. Never returns a row this box is
|
|
3348
|
+
* itself mirroring from a peer (`mirror_synced_at IS NULL`).
|
|
3349
|
+
*/
|
|
3350
|
+
export function queryLocalOriginSessionsForMirror(self, limit) {
|
|
3351
|
+
const rows = getDB().prepare(`
|
|
3352
|
+
SELECT id, short_id, agent, version, machine, cwd, topic, first_user_message,
|
|
3353
|
+
label, last_activity, timestamp, ticket_id, pr_url
|
|
3354
|
+
FROM sessions
|
|
3355
|
+
WHERE mirror_synced_at IS NULL
|
|
3356
|
+
AND file_path IS NOT NULL AND file_path <> ''
|
|
3357
|
+
AND machine = ?
|
|
3358
|
+
AND is_team_origin = 0
|
|
3359
|
+
AND (topic IS NOT NULL OR first_user_message IS NOT NULL OR label IS NOT NULL)
|
|
3360
|
+
ORDER BY last_activity DESC, timestamp DESC
|
|
3361
|
+
LIMIT ?
|
|
3362
|
+
`).all(self, limit);
|
|
3363
|
+
return rows.map((r) => ({
|
|
3364
|
+
id: r.id, shortId: r.short_id, agent: r.agent, version: r.version, machine: r.machine,
|
|
3365
|
+
cwd: r.cwd, topic: r.topic, firstUserMessage: r.first_user_message, label: r.label,
|
|
3366
|
+
lastActivity: r.last_activity, timestamp: r.timestamp, ticketId: r.ticket_id, prUrl: r.pr_url,
|
|
3367
|
+
}));
|
|
3368
|
+
}
|
|
3369
|
+
/**
|
|
3370
|
+
* Write one peer session's digest into the local `sessions` index as a mirror
|
|
3371
|
+
* row (PHNX-3792), so the picker/list/focus render its topic/preview inline
|
|
3372
|
+
* with no per-row SSH. The upsert is GUARDED: it never overwrites a genuine
|
|
3373
|
+
* local transcript row (`file_path` non-empty and not itself a mirror), so a
|
|
3374
|
+
* mirror can only create a new peer-only row or enrich an existing empty-file
|
|
3375
|
+
* host-dispatch stub / older mirror. Returns true when a row was written (the
|
|
3376
|
+
* caller then persists the matching preview digest); false when a real row was
|
|
3377
|
+
* protected. `machine` carries the publisher's recorded execution host so the
|
|
3378
|
+
* row collapses with the live fan-out row under the same `machine:id` key.
|
|
3379
|
+
*/
|
|
3380
|
+
export function upsertMirrorSession(row, source, syncedAt) {
|
|
3381
|
+
const db = getDB();
|
|
3382
|
+
const lastActivity = row.lastActivity ?? row.timestamp;
|
|
3383
|
+
const result = db.prepare(`
|
|
3384
|
+
INSERT INTO sessions (
|
|
3385
|
+
id, short_id, agent, origin, version, timestamp, last_activity, cwd,
|
|
3386
|
+
topic, first_user_message, label, message_count, file_path, scanned_at,
|
|
3387
|
+
is_team_origin, ticket_id, pr_url, machine, mirror_synced_at, mirror_source
|
|
3388
|
+
) VALUES (
|
|
3389
|
+
@id, @short_id, @agent, 'cli', @version, @timestamp, @last_activity, @cwd,
|
|
3390
|
+
@topic, @first_user, @label, NULL, '', @scanned_at,
|
|
3391
|
+
0, @ticket_id, @pr_url, @machine, @synced_at, @source
|
|
3392
|
+
)
|
|
3393
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3394
|
+
short_id = excluded.short_id,
|
|
3395
|
+
agent = excluded.agent,
|
|
3396
|
+
version = COALESCE(excluded.version, sessions.version),
|
|
3397
|
+
timestamp = excluded.timestamp,
|
|
3398
|
+
last_activity = excluded.last_activity,
|
|
3399
|
+
cwd = COALESCE(excluded.cwd, sessions.cwd),
|
|
3400
|
+
topic = COALESCE(excluded.topic, sessions.topic),
|
|
3401
|
+
first_user_message = COALESCE(excluded.first_user_message, sessions.first_user_message),
|
|
3402
|
+
-- The peer is authoritative for the session's real name, so OVERWRITE (not
|
|
3403
|
+
-- COALESCE) the label: a host-dispatch stub carries the [host/peer]
|
|
3404
|
+
-- placeholder, and coalescing would keep it forever — the exact bare-row
|
|
3405
|
+
-- symptom this feature fixes. A null peer label clears it so the list falls
|
|
3406
|
+
-- back to the synced topic.
|
|
3407
|
+
label = excluded.label,
|
|
3408
|
+
ticket_id = COALESCE(excluded.ticket_id, sessions.ticket_id),
|
|
3409
|
+
pr_url = COALESCE(excluded.pr_url, sessions.pr_url),
|
|
3410
|
+
machine = excluded.machine,
|
|
3411
|
+
mirror_synced_at = excluded.mirror_synced_at,
|
|
3412
|
+
mirror_source = excluded.mirror_source
|
|
3413
|
+
WHERE sessions.file_path IS NULL OR sessions.file_path = '' OR sessions.mirror_synced_at IS NOT NULL
|
|
3414
|
+
`).run({
|
|
3415
|
+
id: row.id,
|
|
3416
|
+
short_id: row.shortId,
|
|
3417
|
+
agent: row.agent,
|
|
3418
|
+
version: row.version ?? null,
|
|
3419
|
+
timestamp: row.timestamp,
|
|
3420
|
+
last_activity: lastActivity,
|
|
3421
|
+
cwd: row.cwd ?? null,
|
|
3422
|
+
topic: row.topic ?? null,
|
|
3423
|
+
first_user: row.firstUser ?? null,
|
|
3424
|
+
label: row.label ?? null,
|
|
3425
|
+
ticket_id: row.ticketId ?? null,
|
|
3426
|
+
pr_url: row.prUrl ?? null,
|
|
3427
|
+
machine: row.machine,
|
|
3428
|
+
scanned_at: syncedAt,
|
|
3429
|
+
synced_at: syncedAt,
|
|
3430
|
+
source,
|
|
3431
|
+
});
|
|
3432
|
+
if (result.changes === 0)
|
|
3433
|
+
return false;
|
|
3434
|
+
// Keep the mirror row searchable by topic + first user turn, like a local row.
|
|
3435
|
+
db.prepare(`DELETE FROM session_text WHERE session_id = ?`).run(row.id);
|
|
3436
|
+
db.prepare(`
|
|
3437
|
+
INSERT INTO session_text (session_id, label, topic, project, content, assistant)
|
|
3438
|
+
VALUES (?, ?, ?, '', ?, '')
|
|
3439
|
+
`).run(row.id, row.label ?? '', row.topic ?? '', row.firstUser ?? '');
|
|
3440
|
+
return true;
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* Drop peer mirror rows (and their cached digests) whose last sync predates the
|
|
3444
|
+
* cutoff — the size ceiling / staleness pruner for the fleet session mirror
|
|
3445
|
+
* (PHNX-3792). Only ever touches mirror rows (`mirror_synced_at IS NOT NULL`),
|
|
3446
|
+
* never a genuine local or host-dispatch row. Returns the number of rows pruned.
|
|
3447
|
+
*/
|
|
3448
|
+
export function pruneMirrorSessions(cutoffMs) {
|
|
3449
|
+
const db = getDB();
|
|
3450
|
+
const stale = db.prepare(`SELECT id FROM sessions WHERE mirror_synced_at IS NOT NULL AND mirror_synced_at < ?`).all(cutoffMs);
|
|
3451
|
+
if (stale.length === 0)
|
|
3452
|
+
return 0;
|
|
3453
|
+
const delRow = db.prepare(`DELETE FROM sessions WHERE id = ?`);
|
|
3454
|
+
const delText = db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
|
|
3455
|
+
const delPreview = db.prepare(`DELETE FROM session_preview_cache WHERE session_id = ?`);
|
|
3456
|
+
const txn = db.transaction(() => {
|
|
3457
|
+
for (const { id } of stale) {
|
|
3458
|
+
delRow.run(id);
|
|
3459
|
+
delText.run(id);
|
|
3460
|
+
delPreview.run(id);
|
|
3461
|
+
}
|
|
3462
|
+
});
|
|
3463
|
+
txn();
|
|
3464
|
+
return stale.length;
|
|
3465
|
+
}
|
|
3281
3466
|
/** Plugin provenance already indexed for resources used by one session. */
|
|
3282
3467
|
export function getSessionPlugins(id) {
|
|
3283
3468
|
const rows = getDB().prepare(`
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet distribution of lightweight per-session preview/metadata (PHNX-3792).
|
|
3
|
+
*
|
|
4
|
+
* On the interactive/personal device, a session that originated on another host
|
|
5
|
+
* used to render as a bare `[host/<peer>]` row with no topic, and its preview
|
|
6
|
+
* pane fetched the peer's digest LIVE over SSH per row — slow, and blank when
|
|
7
|
+
* the peer is asleep. This module mirrors each box's own session digests into
|
|
8
|
+
* its conflict-free `~/.agents/devices/<device>/daemon-state.json`, which the
|
|
9
|
+
* daemon's existing bounded Git transport (`fleet-shared-repo-sync.ts`) already
|
|
10
|
+
* delivers fleet-wide with no operator step. The consuming device folds peer
|
|
11
|
+
* digests into its local `sessions` index as mirror rows, so the picker/list/
|
|
12
|
+
* focus read a LOCAL row instead of dialing the peer. No transcript is shipped —
|
|
13
|
+
* only topic/label, a first-user-message snippet, last-activity, agent+version,
|
|
14
|
+
* cwd, ticket, and PR — and the mirror is bounded and pruned by age.
|
|
15
|
+
*
|
|
16
|
+
* Direction is deliberately the inverse of usage-sync: EVERY box publishes its
|
|
17
|
+
* own local sessions (workers are exactly where the remote sessions the personal
|
|
18
|
+
* box lacks previews for are created), and every box EXCEPT a marked worker folds
|
|
19
|
+
* peers' digests in (the picker is an interactive surface). A worker skips the
|
|
20
|
+
* consume so its DB is not written with rows it never renders.
|
|
21
|
+
*/
|
|
22
|
+
import { type ConfiguredDeviceRole } from '../device-config.js';
|
|
23
|
+
/** Cap on sessions published per device — the payload stays what a picker shows. */
|
|
24
|
+
export declare const SESSION_MIRROR_MAX_ROWS = 200;
|
|
25
|
+
/** First-user-message snippet ceiling; the full turn stays on the owning box. */
|
|
26
|
+
export declare const SESSION_MIRROR_SNIPPET_MAX = 280;
|
|
27
|
+
/** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
|
|
28
|
+
export declare const SESSION_MIRROR_MAX_AGE_MS: number;
|
|
29
|
+
export interface PublishSessionMirrorOptions {
|
|
30
|
+
userAgentsDir?: string;
|
|
31
|
+
device?: string;
|
|
32
|
+
limit?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface PublishSessionMirrorResult {
|
|
35
|
+
published: boolean;
|
|
36
|
+
changed: boolean;
|
|
37
|
+
count: number;
|
|
38
|
+
skipped: string | null;
|
|
39
|
+
error: string | null;
|
|
40
|
+
path: string | null;
|
|
41
|
+
}
|
|
42
|
+
/** Publish this device's most-recent local sessions into its owned shared-state file. */
|
|
43
|
+
export declare function publishSessionMirrorToSharedStore(options?: PublishSessionMirrorOptions): Promise<PublishSessionMirrorResult>;
|
|
44
|
+
export interface ConsumeSessionMirrorOptions {
|
|
45
|
+
userAgentsDir?: string;
|
|
46
|
+
device?: string;
|
|
47
|
+
role?: ConfiguredDeviceRole;
|
|
48
|
+
now?: number;
|
|
49
|
+
maxAgeMs?: number;
|
|
50
|
+
}
|
|
51
|
+
export interface ConsumeSessionMirrorResult {
|
|
52
|
+
sources: string[];
|
|
53
|
+
merged: number;
|
|
54
|
+
pruned: number;
|
|
55
|
+
skipped: string | null;
|
|
56
|
+
errors: Array<{
|
|
57
|
+
device: string;
|
|
58
|
+
message: string;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Fold peers' published session digests into this box's local `sessions` index
|
|
63
|
+
* as mirror rows, then prune stale ones. Reads only the local user-repo checkout
|
|
64
|
+
* (the daemon's Git transport already delivered peers' files) — no network. A
|
|
65
|
+
* marked worker skips: the mirror feeds an interactive picker it never renders.
|
|
66
|
+
*/
|
|
67
|
+
export declare function consumeSessionMirrorFromSharedStore(options?: ConsumeSessionMirrorOptions): ConsumeSessionMirrorResult;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet distribution of lightweight per-session preview/metadata (PHNX-3792).
|
|
3
|
+
*
|
|
4
|
+
* On the interactive/personal device, a session that originated on another host
|
|
5
|
+
* used to render as a bare `[host/<peer>]` row with no topic, and its preview
|
|
6
|
+
* pane fetched the peer's digest LIVE over SSH per row — slow, and blank when
|
|
7
|
+
* the peer is asleep. This module mirrors each box's own session digests into
|
|
8
|
+
* its conflict-free `~/.agents/devices/<device>/daemon-state.json`, which the
|
|
9
|
+
* daemon's existing bounded Git transport (`fleet-shared-repo-sync.ts`) already
|
|
10
|
+
* delivers fleet-wide with no operator step. The consuming device folds peer
|
|
11
|
+
* digests into its local `sessions` index as mirror rows, so the picker/list/
|
|
12
|
+
* focus read a LOCAL row instead of dialing the peer. No transcript is shipped —
|
|
13
|
+
* only topic/label, a first-user-message snippet, last-activity, agent+version,
|
|
14
|
+
* cwd, ticket, and PR — and the mirror is bounded and pruned by age.
|
|
15
|
+
*
|
|
16
|
+
* Direction is deliberately the inverse of usage-sync: EVERY box publishes its
|
|
17
|
+
* own local sessions (workers are exactly where the remote sessions the personal
|
|
18
|
+
* box lacks previews for are created), and every box EXCEPT a marked worker folds
|
|
19
|
+
* peers' digests in (the picker is an interactive surface). A worker skips the
|
|
20
|
+
* consume so its DB is not written with rows it never renders.
|
|
21
|
+
*/
|
|
22
|
+
import { selfConfiguredDeviceRole } from '../device-config.js';
|
|
23
|
+
import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
|
|
24
|
+
import { getUserAgentsDir } from '../state.js';
|
|
25
|
+
import { machineId, normalizeHost } from './sync/config.js';
|
|
26
|
+
import { pruneMirrorSessions, queryLocalOriginSessionsForMirror, upsertMirrorSession, } from './db.js';
|
|
27
|
+
/** Cap on sessions published per device — the payload stays what a picker shows. */
|
|
28
|
+
export const SESSION_MIRROR_MAX_ROWS = 200;
|
|
29
|
+
/** First-user-message snippet ceiling; the full turn stays on the owning box. */
|
|
30
|
+
export const SESSION_MIRROR_SNIPPET_MAX = 280;
|
|
31
|
+
/** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
|
|
32
|
+
export const SESSION_MIRROR_MAX_AGE_MS = 14 * 24 * 60 * 60_000;
|
|
33
|
+
function snippet(value, max = SESSION_MIRROR_SNIPPET_MAX) {
|
|
34
|
+
if (!value)
|
|
35
|
+
return undefined;
|
|
36
|
+
const trimmed = value.replace(/\s+/g, ' ').trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
return undefined;
|
|
39
|
+
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
|
|
40
|
+
}
|
|
41
|
+
/** Publish this device's most-recent local sessions into its owned shared-state file. */
|
|
42
|
+
export async function publishSessionMirrorToSharedStore(options = {}) {
|
|
43
|
+
const result = {
|
|
44
|
+
published: false, changed: false, count: 0, skipped: null, error: null, path: null,
|
|
45
|
+
};
|
|
46
|
+
const device = options.device ?? machineId();
|
|
47
|
+
const self = normalizeHost(device);
|
|
48
|
+
const limit = options.limit ?? SESSION_MIRROR_MAX_ROWS;
|
|
49
|
+
const capturedAt = Date.now();
|
|
50
|
+
const sources = queryLocalOriginSessionsForMirror(self, limit);
|
|
51
|
+
const rows = sources.map((s) => ({
|
|
52
|
+
id: s.id,
|
|
53
|
+
shortId: s.shortId,
|
|
54
|
+
agent: s.agent,
|
|
55
|
+
...(s.version ? { version: s.version } : {}),
|
|
56
|
+
machine: s.machine?.trim() || self,
|
|
57
|
+
...(s.cwd ? { cwd: s.cwd } : {}),
|
|
58
|
+
...(snippet(s.topic, 200) ? { topic: snippet(s.topic, 200) } : {}),
|
|
59
|
+
...(s.label ? { label: s.label } : {}),
|
|
60
|
+
...(snippet(s.firstUserMessage) ? { firstUser: snippet(s.firstUserMessage) } : {}),
|
|
61
|
+
...(s.lastActivity ? { lastActivity: s.lastActivity } : {}),
|
|
62
|
+
timestamp: s.timestamp,
|
|
63
|
+
...(s.ticketId ? { ticketId: s.ticketId } : {}),
|
|
64
|
+
...(s.prUrl ? { prUrl: s.prUrl } : {}),
|
|
65
|
+
capturedAt,
|
|
66
|
+
}));
|
|
67
|
+
try {
|
|
68
|
+
const write = await updateFleetSharedDeviceStateAsync(device, { sessions: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
|
|
69
|
+
result.published = true;
|
|
70
|
+
result.changed = write.changed;
|
|
71
|
+
result.count = rows.length;
|
|
72
|
+
result.path = write.path;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
result.error = err.message;
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
function isString(v) {
|
|
80
|
+
return typeof v === 'string' && v.length > 0;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Validate one untrusted peer-supplied row into a DB upsert (or null when it is
|
|
84
|
+
* missing a load-bearing field). Terminal-escape scrubbing is NOT done here —
|
|
85
|
+
* the render path (`sanitizeMeta` in sessions-picker) scrubs every meta string
|
|
86
|
+
* before it reaches a TTY, exactly as it does for live fan-out rows — this only
|
|
87
|
+
* enforces shape and bounds so a malformed/hostile peer can't poison the index.
|
|
88
|
+
*/
|
|
89
|
+
function toUpsert(raw) {
|
|
90
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
91
|
+
return null;
|
|
92
|
+
const r = raw;
|
|
93
|
+
if (!isString(r.id) || !isString(r.agent) || !isString(r.machine))
|
|
94
|
+
return null;
|
|
95
|
+
const timestamp = isString(r.timestamp) ? r.timestamp : (isString(r.lastActivity) ? r.lastActivity : null);
|
|
96
|
+
if (!timestamp)
|
|
97
|
+
return null;
|
|
98
|
+
const shortId = isString(r.shortId) ? r.shortId : r.id.slice(0, 8);
|
|
99
|
+
const cap = (v, max) => isString(v) ? (v.length > max ? v.slice(0, max) : v) : undefined;
|
|
100
|
+
return {
|
|
101
|
+
id: r.id,
|
|
102
|
+
shortId,
|
|
103
|
+
agent: r.agent,
|
|
104
|
+
version: cap(r.version, 64),
|
|
105
|
+
machine: r.machine,
|
|
106
|
+
cwd: cap(r.cwd, 1024),
|
|
107
|
+
topic: cap(r.topic, 400),
|
|
108
|
+
firstUser: cap(r.firstUser, SESSION_MIRROR_SNIPPET_MAX),
|
|
109
|
+
label: cap(r.label, 400),
|
|
110
|
+
lastActivity: isString(r.lastActivity) ? r.lastActivity : undefined,
|
|
111
|
+
timestamp,
|
|
112
|
+
ticketId: cap(r.ticketId, 64),
|
|
113
|
+
prUrl: cap(r.prUrl, 512),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Fold peers' published session digests into this box's local `sessions` index
|
|
118
|
+
* as mirror rows, then prune stale ones. Reads only the local user-repo checkout
|
|
119
|
+
* (the daemon's Git transport already delivered peers' files) — no network. A
|
|
120
|
+
* marked worker skips: the mirror feeds an interactive picker it never renders.
|
|
121
|
+
*/
|
|
122
|
+
export function consumeSessionMirrorFromSharedStore(options = {}) {
|
|
123
|
+
const result = { sources: [], merged: 0, pruned: 0, skipped: null, errors: [] };
|
|
124
|
+
const role = options.role ?? selfConfiguredDeviceRole();
|
|
125
|
+
const now = options.now ?? Date.now();
|
|
126
|
+
if (role === 'worker') {
|
|
127
|
+
// A worker never renders the picker, so it does not consume peers' digests.
|
|
128
|
+
// But if this box was previously a non-worker it may still hold mirror rows,
|
|
129
|
+
// which it will never refresh or render — prune them all now (cutoff = now
|
|
130
|
+
// drops every row synced before this tick) rather than leave a demoted box's
|
|
131
|
+
// index permanently bloated with stale peer rows (PHNX-3792).
|
|
132
|
+
result.pruned = pruneMirrorSessions(now);
|
|
133
|
+
result.skipped = 'this device is a worker; the session mirror feeds the interactive picker';
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
const read = readFleetSharedDeviceStates(options.userAgentsDir ?? getUserAgentsDir());
|
|
137
|
+
result.errors.push(...read.errors);
|
|
138
|
+
const self = normalizeHost(options.device ?? machineId());
|
|
139
|
+
for (const state of read.states) {
|
|
140
|
+
if (normalizeHost(state.device) === self || !state.sessions?.rows)
|
|
141
|
+
continue;
|
|
142
|
+
let mergedForDevice = 0;
|
|
143
|
+
for (const raw of state.sessions.rows) {
|
|
144
|
+
const row = toUpsert(raw);
|
|
145
|
+
if (!row)
|
|
146
|
+
continue;
|
|
147
|
+
if (upsertMirrorSession(row, state.device, now))
|
|
148
|
+
mergedForDevice++;
|
|
149
|
+
}
|
|
150
|
+
if (mergedForDevice > 0) {
|
|
151
|
+
result.sources.push(state.device);
|
|
152
|
+
result.merged += mergedForDevice;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
result.sources.sort();
|
|
156
|
+
result.pruned = pruneMirrorSessions(now - (options.maxAgeMs ?? SESSION_MIRROR_MAX_AGE_MS));
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
@@ -364,6 +364,15 @@ export interface SessionMeta {
|
|
|
364
364
|
* split as `AGENTS_ACTOR` / `AGENTS_ACTOR_KIND` on the exec env. RUSH-2018.
|
|
365
365
|
*/
|
|
366
366
|
initiatedBy?: 'human' | 'agent';
|
|
367
|
+
/**
|
|
368
|
+
* Phoenix id of the actor who initiated this session (`resolveActor().phoenixId`)
|
|
369
|
+
* — the tailnet human's stable Phoenix identity, resolved from the `actors:` map
|
|
370
|
+
* at spawn. Pairs with {@link actor}, persisted write-once at session creation
|
|
371
|
+
* and joined from the actor sidecar at scan time (kept out of the DB upsert's
|
|
372
|
+
* ON CONFLICT set), so `agents sessions --json` can surface the Phoenix id.
|
|
373
|
+
* Undefined for rows created before Phoenix-id stamping. PHNX-3798.
|
|
374
|
+
*/
|
|
375
|
+
phoenixId?: string;
|
|
367
376
|
/**
|
|
368
377
|
* True only for rows pulled from another machine over the live cross-machine
|
|
369
378
|
* fan-out (`remote-list.ts`) — their transcript is on that peer's disk, so
|
|
@@ -373,6 +382,15 @@ export interface SessionMeta {
|
|
|
373
382
|
* `parseRemoteList`; transient (never persisted, stripped from --json).
|
|
374
383
|
*/
|
|
375
384
|
_remote?: boolean;
|
|
385
|
+
/**
|
|
386
|
+
* Epoch ms this row was last written from a PEER's fleet-synced session mirror
|
|
387
|
+
* (PHNX-3792); absent for a genuine local or host-dispatch row. When set, the
|
|
388
|
+
* picker renders the peer session's topic/preview INLINE from this mirror
|
|
389
|
+
* (no per-row SSH) and stamps a "synced <ago>" / stale marker. `mirrorSource`
|
|
390
|
+
* names the publishing device. Backed by the `mirror_synced_at` column.
|
|
391
|
+
*/
|
|
392
|
+
mirrorSyncedAt?: number;
|
|
393
|
+
mirrorSource?: string;
|
|
376
394
|
/**
|
|
377
395
|
* True when the transcript file is gone from disk but the session's user turns
|
|
378
396
|
* still live in the local DB (session_text), so the row is served and rendered
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ctrl-C-safe spinner factory.
|
|
3
|
+
*
|
|
4
|
+
* `ora` defaults to `discardStdin: true`, which — on a TTY — flips stdin into
|
|
5
|
+
* raw mode for the lifetime of the spinner (via `stdin-discarder`). Raw mode
|
|
6
|
+
* disables the terminal's own ISIG translation, so a Ctrl-C keystroke no longer
|
|
7
|
+
* generates SIGINT; `stdin-discarder` tries to compensate by re-emitting SIGINT
|
|
8
|
+
* when it reads a 0x03 byte, but that read only happens when stdin is in
|
|
9
|
+
* *flowing* mode. At CLI startup a fresh `process.stdin` has `flowing === null`,
|
|
10
|
+
* so `stdin.isPaused()` returns false, the discarder skips its `stdin.resume()`,
|
|
11
|
+
* and its `prependListener('data')` never switches the stream to flowing —
|
|
12
|
+
* because Node's readable only auto-resumes for `on('data')`, not
|
|
13
|
+
* `prependListener('data')`. The net effect: while such a spinner is running,
|
|
14
|
+
* Ctrl-C is swallowed entirely — the terminal won't raise SIGINT and the
|
|
15
|
+
* discarder never sees the byte to re-raise it.
|
|
16
|
+
*
|
|
17
|
+
* That is invisible for a sub-second spinner, but any spinner wrapping a long
|
|
18
|
+
* idle wait — a cross-machine SSH fan-out, a fleet search — traps the user for
|
|
19
|
+
* the full duration (measured: `agents sessions --flat` could not be Ctrl-C'd
|
|
20
|
+
* for the entire ~12s+ sweep and had to be SIGKILLed). The global SIGINT handler
|
|
21
|
+
* in `index.ts` (`process.exit(130)`) is correct; it simply never fires because
|
|
22
|
+
* no SIGINT is ever raised.
|
|
23
|
+
*
|
|
24
|
+
* Setting `discardStdin: false` keeps the terminal in cooked mode, so Ctrl-C
|
|
25
|
+
* raises SIGINT normally (the terminal also SIGINTs the whole foreground process
|
|
26
|
+
* group, reaping the outstanding `ssh` children) and the global handler exits
|
|
27
|
+
* promptly. The only thing lost is cosmetic stray-keystroke discarding during
|
|
28
|
+
* the spin — a worthwhile trade for a spinner the user must be able to abort.
|
|
29
|
+
*
|
|
30
|
+
* Use this for any spinner that wraps a network / fleet / SSH wait. A truly
|
|
31
|
+
* instantaneous local spinner can still use `ora` directly, but preferring this
|
|
32
|
+
* everywhere is harmless.
|
|
33
|
+
*/
|
|
34
|
+
import { type Options, type Ora } from 'ora';
|
|
35
|
+
/**
|
|
36
|
+
* Build an interruptible spinner — a drop-in for `ora(text)` that keeps Ctrl-C
|
|
37
|
+
* working. Returns an unstarted {@link Ora}; call `.start()` as usual.
|
|
38
|
+
*/
|
|
39
|
+
export declare function interruptibleSpinner(text?: string, options?: Omit<Options, 'discardStdin'>): Ora;
|