@phnx-labs/agents-cli 1.20.36 → 1.20.37
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/dist/commands/sessions-sync.d.ts +3 -0
- package/dist/commands/sessions-sync.js +44 -4
- package/dist/commands/sessions.d.ts +7 -0
- package/dist/commands/sessions.js +147 -35
- package/dist/lib/daemon.js +4 -2
- package/dist/lib/devices/resolve-target.d.ts +24 -0
- package/dist/lib/devices/resolve-target.js +80 -0
- package/dist/lib/session/active.d.ts +19 -0
- package/dist/lib/session/active.js +10 -4
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +41 -5
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +16 -1
- package/dist/lib/session/ghostty-tabs.d.ts +33 -0
- package/dist/lib/session/ghostty-tabs.js +126 -0
- package/dist/lib/session/relative-time.js +6 -2
- package/dist/lib/session/remote-active.js +4 -14
- package/dist/lib/session/remote-list.js +4 -12
- package/dist/lib/session/remote.js +4 -2
- package/dist/lib/session/sync/config.d.ts +13 -0
- package/dist/lib/session/sync/config.js +56 -0
- package/dist/lib/session/types.d.ts +6 -0
- package/dist/lib/sync-umbrella.js +4 -4
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +31 -0
- package/package.json +1 -1
package/dist/lib/session/db.js
CHANGED
|
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
|
13
13
|
const SESSIONS_DIR = getSessionsDir();
|
|
14
14
|
const DB_PATH = getSessionsDbPath();
|
|
15
15
|
/** Current schema version; bumped when migrations are added. */
|
|
16
|
-
const SCHEMA_VERSION =
|
|
16
|
+
const SCHEMA_VERSION = 8;
|
|
17
17
|
/**
|
|
18
18
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
19
19
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
46
46
|
version TEXT,
|
|
47
47
|
account TEXT,
|
|
48
48
|
timestamp TEXT NOT NULL,
|
|
49
|
+
last_activity TEXT,
|
|
49
50
|
project TEXT,
|
|
50
51
|
cwd TEXT,
|
|
51
52
|
git_branch TEXT,
|
|
@@ -176,6 +177,17 @@ function migrateSchema(db, fromVersion) {
|
|
|
176
177
|
db.exec(`ALTER TABLE sessions ADD COLUMN ticket_id TEXT`);
|
|
177
178
|
db.exec(`DELETE FROM scan_ledger;`);
|
|
178
179
|
}
|
|
180
|
+
if (fromVersion < 8) {
|
|
181
|
+
// v7 → v8: the listing now sorts and labels by last-activity (last message
|
|
182
|
+
// time) instead of creation time. Add the column, seed it to `timestamp` so
|
|
183
|
+
// no row sorts as NULL before the rescan lands, then force a full rescan so
|
|
184
|
+
// every session gets its true last_activity (from lastTsMs) repopulated.
|
|
185
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
186
|
+
if (!cols.some(c => c.name === 'last_activity'))
|
|
187
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN last_activity TEXT`);
|
|
188
|
+
db.exec(`UPDATE sessions SET last_activity = timestamp WHERE last_activity IS NULL`);
|
|
189
|
+
db.exec(`DELETE FROM scan_ledger;`);
|
|
190
|
+
}
|
|
179
191
|
}
|
|
180
192
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
181
193
|
export function getDB() {
|
|
@@ -202,6 +214,11 @@ export function getDB() {
|
|
|
202
214
|
migrateSchema(db, currentVersion);
|
|
203
215
|
db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
|
|
204
216
|
}
|
|
217
|
+
// Index last_activity only after the column is guaranteed to exist — fresh DBs
|
|
218
|
+
// get it from CREATE TABLE above, existing pre-v8 DBs from the migration just
|
|
219
|
+
// run. It must NOT live in SCHEMA (executed before migration) or an existing
|
|
220
|
+
// DB would fail the index build on a column it doesn't have yet.
|
|
221
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
|
|
205
222
|
// One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
|
|
206
223
|
// them anymore. Guarded by a meta flag so we only try once.
|
|
207
224
|
const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
|
|
@@ -383,13 +400,13 @@ export function recordScans(entries) {
|
|
|
383
400
|
}
|
|
384
401
|
const upsertSessionStmt = (db) => db.prepare(`
|
|
385
402
|
INSERT INTO sessions (
|
|
386
|
-
id, short_id, agent, version, account, timestamp,
|
|
403
|
+
id, short_id, agent, version, account, timestamp, last_activity,
|
|
387
404
|
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
388
405
|
cost_usd, duration_ms,
|
|
389
406
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
390
407
|
pr_url, pr_number, worktree_slug, ticket_id
|
|
391
408
|
) VALUES (
|
|
392
|
-
@id, @short_id, @agent, @version, @account, @timestamp,
|
|
409
|
+
@id, @short_id, @agent, @version, @account, @timestamp, @last_activity,
|
|
393
410
|
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
394
411
|
@cost_usd, @duration_ms,
|
|
395
412
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
@@ -401,6 +418,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
401
418
|
version = excluded.version,
|
|
402
419
|
account = excluded.account,
|
|
403
420
|
timestamp = excluded.timestamp,
|
|
421
|
+
last_activity = excluded.last_activity,
|
|
404
422
|
project = excluded.project,
|
|
405
423
|
cwd = excluded.cwd,
|
|
406
424
|
git_branch = excluded.git_branch,
|
|
@@ -447,6 +465,7 @@ export function upsertSession(meta, content, scan) {
|
|
|
447
465
|
version: meta.version ?? null,
|
|
448
466
|
account: meta.account ?? null,
|
|
449
467
|
timestamp: meta.timestamp,
|
|
468
|
+
last_activity: resolveLastActivity(meta, scan),
|
|
450
469
|
project: meta.project ?? null,
|
|
451
470
|
cwd: meta.cwd ?? null,
|
|
452
471
|
git_branch: meta.gitBranch ?? null,
|
|
@@ -526,6 +545,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
526
545
|
version: meta.version ?? null,
|
|
527
546
|
account: meta.account ?? null,
|
|
528
547
|
timestamp: meta.timestamp,
|
|
548
|
+
last_activity: resolveLastActivity(meta, scan),
|
|
529
549
|
project: meta.project ?? null,
|
|
530
550
|
cwd: meta.cwd ?? null,
|
|
531
551
|
git_branch: meta.gitBranch ?? null,
|
|
@@ -641,6 +661,7 @@ function rowToMeta(row) {
|
|
|
641
661
|
shortId: row.short_id,
|
|
642
662
|
agent: row.agent,
|
|
643
663
|
timestamp: row.timestamp,
|
|
664
|
+
lastActivity: row.last_activity ?? undefined,
|
|
644
665
|
project: row.project ?? undefined,
|
|
645
666
|
cwd: row.cwd ?? undefined,
|
|
646
667
|
filePath: row.file_path,
|
|
@@ -660,6 +681,21 @@ function rowToMeta(row) {
|
|
|
660
681
|
ticketId: row.ticket_id ?? undefined,
|
|
661
682
|
};
|
|
662
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* The recency signal used to sort and label the listing: last-message time when
|
|
686
|
+
* a parser computed it (`meta.lastActivity` from `lastTsMs`), else the file's
|
|
687
|
+
* mtime (its last write), else creation time. Guarded on `filePath` so synthetic
|
|
688
|
+
* / cloud rows (no local file) fall to their creation timestamp rather than a
|
|
689
|
+
* bogus scan-time mtime. Always an ISO string, so it sorts lexicographically
|
|
690
|
+
* against `timestamp` and feeds `formatRelativeTime` unchanged.
|
|
691
|
+
*/
|
|
692
|
+
function resolveLastActivity(meta, scan) {
|
|
693
|
+
if (meta.lastActivity)
|
|
694
|
+
return meta.lastActivity;
|
|
695
|
+
if (scan?.fileMtimeMs && meta.filePath)
|
|
696
|
+
return new Date(scan.fileMtimeMs).toISOString();
|
|
697
|
+
return meta.timestamp;
|
|
698
|
+
}
|
|
663
699
|
/**
|
|
664
700
|
* Newest indexed session file for an agent working in `cwd`. Lets the live
|
|
665
701
|
* `--active` scanner locate a Codex transcript (whose files are date-partitioned,
|
|
@@ -741,7 +777,7 @@ function buildSessionWhere(options) {
|
|
|
741
777
|
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
742
778
|
return { clause, params };
|
|
743
779
|
}
|
|
744
|
-
/** Query sessions from the database, applying filters and ordering by
|
|
780
|
+
/** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
|
|
745
781
|
export function querySessions(options = {}) {
|
|
746
782
|
const db = getDB();
|
|
747
783
|
const { clause, params } = buildSessionWhere(options);
|
|
@@ -757,7 +793,7 @@ export function querySessions(options = {}) {
|
|
|
757
793
|
? 'ORDER BY cost_usd IS NULL, cost_usd DESC, timestamp DESC'
|
|
758
794
|
: options.sortBy === 'duration'
|
|
759
795
|
? 'ORDER BY duration_ms IS NULL, duration_ms DESC, timestamp DESC'
|
|
760
|
-
: 'ORDER BY timestamp DESC';
|
|
796
|
+
: 'ORDER BY IFNULL(last_activity, timestamp) DESC, timestamp DESC';
|
|
761
797
|
const sql = `SELECT * FROM sessions ${clause} ${orderClause} ${limitClause}`;
|
|
762
798
|
const rows = db.prepare(sql).all(...params);
|
|
763
799
|
// Belt-and-suspenders: drop rows whose JSONL no longer exists on disk. The
|
|
@@ -50,6 +50,8 @@ interface ClaudeSessionScan {
|
|
|
50
50
|
costUsd?: number;
|
|
51
51
|
/** Wall-clock duration in ms between the first and last timestamped event. */
|
|
52
52
|
durationMs?: number;
|
|
53
|
+
/** ISO time of the last timestamped event — the session's last activity. */
|
|
54
|
+
lastActivity?: string;
|
|
53
55
|
/**
|
|
54
56
|
* Value of the JSONL `entrypoint` field on the first event that carries it.
|
|
55
57
|
* 'cli' for real interactive sessions, 'sdk-cli' for team-spawned ones.
|
|
@@ -459,6 +459,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
459
459
|
shortId: sessionId.slice(0, 8),
|
|
460
460
|
agent: 'claude',
|
|
461
461
|
timestamp: scan.timestamp,
|
|
462
|
+
lastActivity: scan.lastActivity,
|
|
462
463
|
project: cwd ? path.basename(cwd) : undefined,
|
|
463
464
|
cwd,
|
|
464
465
|
filePath,
|
|
@@ -485,6 +486,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
485
486
|
shortId: sessionId.slice(0, 8),
|
|
486
487
|
agent: 'claude',
|
|
487
488
|
timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
|
|
489
|
+
lastActivity: scan.lastActivity,
|
|
488
490
|
filePath,
|
|
489
491
|
account,
|
|
490
492
|
label,
|
|
@@ -693,6 +695,7 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
|
|
|
693
695
|
// Codex `session_meta` only carries the start time; use file mtime when
|
|
694
696
|
// it's newer so long-running sessions register as recently active.
|
|
695
697
|
timestamp: pickLatestCodexTimestamp(scan.timestamp, filePath),
|
|
698
|
+
lastActivity: scan.lastActivity,
|
|
696
699
|
project: cwd ? path.basename(cwd) : undefined,
|
|
697
700
|
cwd,
|
|
698
701
|
filePath,
|
|
@@ -887,6 +890,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
|
|
|
887
890
|
shortId: sessionId.slice(0, 8),
|
|
888
891
|
agent: 'gemini',
|
|
889
892
|
timestamp: startTime || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
|
|
893
|
+
lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
|
|
890
894
|
project,
|
|
891
895
|
cwd,
|
|
892
896
|
filePath,
|
|
@@ -1093,6 +1097,7 @@ async function scanOpenCodeIncremental() {
|
|
|
1093
1097
|
s.directory,
|
|
1094
1098
|
s.version,
|
|
1095
1099
|
s.time_created,
|
|
1100
|
+
s.time_updated,
|
|
1096
1101
|
COALESCE(stats.message_count, 0),
|
|
1097
1102
|
stats.token_count,
|
|
1098
1103
|
COALESCE(stats.has_token_data, 0)
|
|
@@ -1125,20 +1130,26 @@ async function scanOpenCodeIncremental() {
|
|
|
1125
1130
|
for (const line of out.split('\n')) {
|
|
1126
1131
|
if (!line.trim())
|
|
1127
1132
|
continue;
|
|
1128
|
-
const [id, title, directory, version, timeCreatedStr, messageCountStr, tokenCountStr, hasTokenDataStr] = line.split('|||');
|
|
1133
|
+
const [id, title, directory, version, timeCreatedStr, timeUpdatedStr, messageCountStr, tokenCountStr, hasTokenDataStr] = line.split('|||');
|
|
1129
1134
|
if (!id)
|
|
1130
1135
|
continue;
|
|
1131
1136
|
const timeCreated = parseInt(timeCreatedStr, 10);
|
|
1137
|
+
const timeUpdated = parseInt(timeUpdatedStr, 10);
|
|
1132
1138
|
const messageCount = parseInt(messageCountStr, 10);
|
|
1133
1139
|
const tokenCount = parseInt(tokenCountStr, 10);
|
|
1134
1140
|
const hasTokenData = hasTokenDataStr === '1';
|
|
1135
1141
|
const timestamp = isNaN(timeCreated) ? new Date().toISOString() : new Date(timeCreated).toISOString();
|
|
1142
|
+
// OpenCode is one shared DB, not one file per session — its row carries a
|
|
1143
|
+
// per-session updated time. Set lastActivity explicitly (falling back to
|
|
1144
|
+
// creation, never the whole-DB mtime the ScanStamp would otherwise supply).
|
|
1145
|
+
const lastActivity = Number.isNaN(timeUpdated) ? timestamp : new Date(timeUpdated).toISOString();
|
|
1136
1146
|
const topic = title || undefined;
|
|
1137
1147
|
const meta = {
|
|
1138
1148
|
id,
|
|
1139
1149
|
shortId: id.replace(/^ses_/, '').slice(0, 8),
|
|
1140
1150
|
agent: 'opencode',
|
|
1141
1151
|
timestamp,
|
|
1152
|
+
lastActivity,
|
|
1142
1153
|
project: directory ? path.basename(directory) : undefined,
|
|
1143
1154
|
cwd: directory ? normalizeCwd(directory) : undefined,
|
|
1144
1155
|
filePath: `${OPENCODE_DB}#${id}`,
|
|
@@ -1571,6 +1582,7 @@ async function readDroidMeta(filePath, currentVersion) {
|
|
|
1571
1582
|
shortId: sessionId.slice(0, 8),
|
|
1572
1583
|
agent: 'droid',
|
|
1573
1584
|
timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
|
|
1585
|
+
lastActivity: scan.lastActivity,
|
|
1574
1586
|
project: cwd ? path.basename(cwd) : undefined,
|
|
1575
1587
|
cwd,
|
|
1576
1588
|
filePath,
|
|
@@ -1692,6 +1704,7 @@ async function scanDroidSession(filePath) {
|
|
|
1692
1704
|
model,
|
|
1693
1705
|
messageCount,
|
|
1694
1706
|
durationMs,
|
|
1707
|
+
lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
|
|
1695
1708
|
contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
|
|
1696
1709
|
};
|
|
1697
1710
|
}
|
|
@@ -1877,6 +1890,7 @@ export async function scanClaudeSession(filePath) {
|
|
|
1877
1890
|
tokenCount: sawTokenCount ? tokenCount : undefined,
|
|
1878
1891
|
costUsd: sawCost ? costUsd : undefined,
|
|
1879
1892
|
durationMs,
|
|
1893
|
+
lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
|
|
1880
1894
|
contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
|
|
1881
1895
|
prUrl,
|
|
1882
1896
|
prNumber,
|
|
@@ -2020,6 +2034,7 @@ async function scanCodexSession(filePath) {
|
|
|
2020
2034
|
tokenCount,
|
|
2021
2035
|
costUsd,
|
|
2022
2036
|
durationMs,
|
|
2037
|
+
lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
|
|
2023
2038
|
contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
|
|
2024
2039
|
prUrl,
|
|
2025
2040
|
prNumber,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort Ghostty tab-number detection for `agents sessions --active`.
|
|
3
|
+
*
|
|
4
|
+
* Ghostty (macOS) exposes read-only AppleScript: every tab has an `index` and a
|
|
5
|
+
* `name` (title); every surface a `working directory`. It exposes NO per-tab env
|
|
6
|
+
* var and NO tty/pid on a surface — so a session is matched to its tab by
|
|
7
|
+
* `working directory` (with title as a tiebreak). This is display sugar only:
|
|
8
|
+
* one bounded, non-fatal osascript call, run by the renderer, never on the
|
|
9
|
+
* discovery / --json / --waiting path. Any failure (Ghostty not running,
|
|
10
|
+
* Automation permission denied, timeout) degrades silently to no tab number.
|
|
11
|
+
*/
|
|
12
|
+
import type { ActiveSession } from './active.js';
|
|
13
|
+
/** One Ghostty surface (terminal), flattened from window -> tab -> surface. */
|
|
14
|
+
export interface GhosttySurface {
|
|
15
|
+
windowIndex: number;
|
|
16
|
+
tabIndex: number;
|
|
17
|
+
cwd: string;
|
|
18
|
+
title: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Enumerate every Ghostty surface (window/tab/cwd/title) via one read-only
|
|
22
|
+
* osascript call. Returns [] on ANY failure — Ghostty not running, Automation
|
|
23
|
+
* permission not granted, timeout, or a parse miss. Never throws, never prompts.
|
|
24
|
+
*/
|
|
25
|
+
export declare function enumerateGhosttyTabs(timeoutMs?: number): Promise<GhosttySurface[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Assign a Ghostty tab number to each `host === 'ghostty'` session by matching
|
|
28
|
+
* its cwd to a surface's working directory; ties (same cwd) are broken by title
|
|
29
|
+
* containment against the session's label/topic/kind. Deliberately conservative:
|
|
30
|
+
* a session that can't be uniquely resolved gets NO number — a wrong jump target
|
|
31
|
+
* is worse than none. Pure and unit-tested.
|
|
32
|
+
*/
|
|
33
|
+
export declare function assignGhosttyTabs(sessions: ActiveSession[], surfaces: GhosttySurface[]): Map<ActiveSession, number>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort Ghostty tab-number detection for `agents sessions --active`.
|
|
3
|
+
*
|
|
4
|
+
* Ghostty (macOS) exposes read-only AppleScript: every tab has an `index` and a
|
|
5
|
+
* `name` (title); every surface a `working directory`. It exposes NO per-tab env
|
|
6
|
+
* var and NO tty/pid on a surface — so a session is matched to its tab by
|
|
7
|
+
* `working directory` (with title as a tiebreak). This is display sugar only:
|
|
8
|
+
* one bounded, non-fatal osascript call, run by the renderer, never on the
|
|
9
|
+
* discovery / --json / --waiting path. Any failure (Ghostty not running,
|
|
10
|
+
* Automation permission denied, timeout) degrades silently to no tab number.
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from 'child_process';
|
|
13
|
+
import { promisify } from 'util';
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
// Field delimiter: ASCII Unit Separator (0x1F). AppleScript's `tab` keyword does
|
|
16
|
+
// not resolve to a tab byte via `osascript -e`, but `character id 31` does — and
|
|
17
|
+
// 0x1F never appears in a cwd or a tab title, so parsing is unambiguous.
|
|
18
|
+
const ENUM_SCRIPT = `tell application "Ghostty"
|
|
19
|
+
set fd to (character id 31)
|
|
20
|
+
set out to ""
|
|
21
|
+
set wi to 0
|
|
22
|
+
repeat with w in windows
|
|
23
|
+
set wi to wi + 1
|
|
24
|
+
repeat with t in tabs of w
|
|
25
|
+
set ti to index of t
|
|
26
|
+
repeat with s in terminals of t
|
|
27
|
+
set out to out & wi & fd & ti & fd & (working directory of s) & fd & (name of s) & linefeed
|
|
28
|
+
end repeat
|
|
29
|
+
end repeat
|
|
30
|
+
end repeat
|
|
31
|
+
return out
|
|
32
|
+
end tell`;
|
|
33
|
+
/**
|
|
34
|
+
* Enumerate every Ghostty surface (window/tab/cwd/title) via one read-only
|
|
35
|
+
* osascript call. Returns [] on ANY failure — Ghostty not running, Automation
|
|
36
|
+
* permission not granted, timeout, or a parse miss. Never throws, never prompts.
|
|
37
|
+
*/
|
|
38
|
+
export async function enumerateGhosttyTabs(timeoutMs = 1500) {
|
|
39
|
+
if (process.platform !== 'darwin')
|
|
40
|
+
return [];
|
|
41
|
+
let stdout;
|
|
42
|
+
try {
|
|
43
|
+
({ stdout } = await execFileAsync('osascript', ['-e', ENUM_SCRIPT], {
|
|
44
|
+
timeout: timeoutMs,
|
|
45
|
+
killSignal: 'SIGKILL',
|
|
46
|
+
maxBuffer: 1024 * 1024,
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const line of stdout.split('\n')) {
|
|
55
|
+
if (!line)
|
|
56
|
+
continue;
|
|
57
|
+
const f = line.split('\u001f');
|
|
58
|
+
if (f.length < 4)
|
|
59
|
+
continue;
|
|
60
|
+
const windowIndex = parseInt(f[0], 10);
|
|
61
|
+
const tabIndex = parseInt(f[1], 10);
|
|
62
|
+
if (!Number.isFinite(windowIndex) || !Number.isFinite(tabIndex))
|
|
63
|
+
continue;
|
|
64
|
+
out.push({ windowIndex, tabIndex, cwd: f[2], title: f[3] });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Trailing-slash-insensitive cwd key. */
|
|
69
|
+
function cwdKey(p) {
|
|
70
|
+
return (p ?? '').replace(/\/+$/, '');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Normalize a tab title / hint for containment matching: drop a leading run of
|
|
74
|
+
* non-alphanumerics (Ghostty prefixes the title with a spinner glyph like `⠐ `
|
|
75
|
+
* or `✳ ` while the agent runs) and lowercase. Without this, a title that starts
|
|
76
|
+
* with the session's exact topic still fails a substring test.
|
|
77
|
+
*/
|
|
78
|
+
function normText(s) {
|
|
79
|
+
return s.replace(/^[^\p{L}\p{N}]+/u, '').toLowerCase().trim();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Assign a Ghostty tab number to each `host === 'ghostty'` session by matching
|
|
83
|
+
* its cwd to a surface's working directory; ties (same cwd) are broken by title
|
|
84
|
+
* containment against the session's label/topic/kind. Deliberately conservative:
|
|
85
|
+
* a session that can't be uniquely resolved gets NO number — a wrong jump target
|
|
86
|
+
* is worse than none. Pure and unit-tested.
|
|
87
|
+
*/
|
|
88
|
+
export function assignGhosttyTabs(sessions, surfaces) {
|
|
89
|
+
const result = new Map();
|
|
90
|
+
if (surfaces.length === 0)
|
|
91
|
+
return result;
|
|
92
|
+
const byCwd = new Map();
|
|
93
|
+
for (const s of surfaces) {
|
|
94
|
+
const k = cwdKey(s.cwd);
|
|
95
|
+
const bucket = byCwd.get(k);
|
|
96
|
+
if (bucket)
|
|
97
|
+
bucket.push(s);
|
|
98
|
+
else
|
|
99
|
+
byCwd.set(k, [s]);
|
|
100
|
+
}
|
|
101
|
+
for (const sess of sessions) {
|
|
102
|
+
if (sess.host !== 'ghostty')
|
|
103
|
+
continue;
|
|
104
|
+
const candidates = byCwd.get(cwdKey(sess.cwd));
|
|
105
|
+
if (!candidates || candidates.length === 0)
|
|
106
|
+
continue;
|
|
107
|
+
if (candidates.length === 1) {
|
|
108
|
+
result.set(sess, candidates[0].tabIndex);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// Tie: break by title containment (glyph-stripped, lowercased). Hints are
|
|
112
|
+
// what the session knows about itself; a good hint is >=8 chars so short
|
|
113
|
+
// fragments don't cause spurious matches.
|
|
114
|
+
const hints = [sess.label, sess.topic, sess.preview]
|
|
115
|
+
.filter((h) => !!h && normText(h).length >= 8)
|
|
116
|
+
.map(normText);
|
|
117
|
+
const matches = candidates.filter(c => {
|
|
118
|
+
const title = normText(c.title);
|
|
119
|
+
return title.length >= 8 && hints.some(h => title.includes(h) || h.includes(title));
|
|
120
|
+
});
|
|
121
|
+
// Only assign when the tiebreak is unambiguous (exactly one title match).
|
|
122
|
+
if (matches.length === 1)
|
|
123
|
+
result.set(sess, matches[0].tabIndex);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
@@ -21,8 +21,12 @@ export function formatRelativeTime(isoTimestamp) {
|
|
|
21
21
|
return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
|
|
22
22
|
if (diffDays < 7)
|
|
23
23
|
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
24
|
-
// Older: show date
|
|
24
|
+
// Older: show the date. Append a 2-digit year for anything outside the current
|
|
25
|
+
// year so "Jun 28" is never ambiguous across years (e.g. "Jun 28 '25").
|
|
25
26
|
const d = new Date(then);
|
|
26
27
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
27
|
-
|
|
28
|
+
const label = `${months[d.getMonth()]} ${d.getDate()}`;
|
|
29
|
+
return d.getFullYear() === new Date(now).getFullYear()
|
|
30
|
+
? label
|
|
31
|
+
: `${label} '${String(d.getFullYear()).slice(-2)}`;
|
|
28
32
|
}
|
|
@@ -18,9 +18,9 @@ import { spawn } from 'child_process';
|
|
|
18
18
|
import chalk from 'chalk';
|
|
19
19
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
20
20
|
import { sshTargetFor } from '../devices/connect.js';
|
|
21
|
+
import { resolveExplicitTargets } from '../devices/resolve-target.js';
|
|
21
22
|
import { loadDevices } from '../devices/registry.js';
|
|
22
23
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
23
|
-
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
24
24
|
import { machineId, normalizeHost } from './sync/config.js';
|
|
25
25
|
/** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
|
|
26
26
|
* reachable-but-slow remote still answers before we give up. */
|
|
@@ -108,19 +108,9 @@ export async function gatherRemoteActive(hosts) {
|
|
|
108
108
|
const self = machineId();
|
|
109
109
|
const targets = [];
|
|
110
110
|
if (hosts && hosts.length > 0) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
const bareHost = h.split('@').pop() || h;
|
|
120
|
-
// Resolve the OS by the name the user passed (a bare alias like `win-mini`
|
|
121
|
-
// matches a device-registry entry; a raw `user@host` falls back to POSIX).
|
|
122
|
-
targets.push({ target: h, machine: normalizeHost(bareHost), name: h, os: resolveRemoteOsSync(h) });
|
|
123
|
-
}
|
|
111
|
+
// Resolve each token through the device registry so an explicit --host/--device
|
|
112
|
+
// dials the exact same address (and machine id) as the auto-discovery sweep.
|
|
113
|
+
targets.push(...await resolveExplicitTargets(hosts));
|
|
124
114
|
}
|
|
125
115
|
else {
|
|
126
116
|
let reg;
|
|
@@ -17,9 +17,9 @@ import { spawn } from 'child_process';
|
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { sshTargetFor } from '../devices/connect.js';
|
|
20
|
+
import { resolveExplicitTargets } from '../devices/resolve-target.js';
|
|
20
21
|
import { loadDevices } from '../devices/registry.js';
|
|
21
22
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
22
|
-
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
23
23
|
import { machineId, normalizeHost } from './sync/config.js';
|
|
24
24
|
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
25
25
|
import { terminalWidth } from './width.js';
|
|
@@ -112,17 +112,9 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
|
112
112
|
const self = machineId();
|
|
113
113
|
const targets = [];
|
|
114
114
|
if (hosts && hosts.length > 0) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
const bareHost = h.split('@').pop() || h;
|
|
124
|
-
targets.push({ target: h, machine: normalizeHost(bareHost), name: h, os: resolveRemoteOsSync(h) });
|
|
125
|
-
}
|
|
115
|
+
// Resolve each token through the device registry so an explicit --host/--device
|
|
116
|
+
// dials the exact same address (and machine id) as the auto-discovery sweep.
|
|
117
|
+
targets.push(...await resolveExplicitTargets(hosts));
|
|
126
118
|
}
|
|
127
119
|
else {
|
|
128
120
|
let reg;
|
|
@@ -63,10 +63,12 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
|
63
63
|
const out = [];
|
|
64
64
|
for (let i = 0; i < args.length; i++) {
|
|
65
65
|
const a = args[i];
|
|
66
|
-
if (a === '--host' || a === '-H') {
|
|
66
|
+
if (a === '--host' || a === '-H' || a === '--device') {
|
|
67
67
|
// Commander's `<target...>` variadic accepts both `--host a --host b` and
|
|
68
68
|
// `--host a b` — consume every consecutive token that is a known host so
|
|
69
69
|
// the variadic form doesn't leak the extra hosts into the remote argv.
|
|
70
|
+
// `--device` is an alias for `--host` (its values are merged into the same
|
|
71
|
+
// host set), so strip it the same way — else the peer would re-fan-out.
|
|
70
72
|
// Fall back to consuming the single next token when we have no host set
|
|
71
73
|
// (e.g. malformed input) so the flag value never leaks either way.
|
|
72
74
|
if (hosts.size > 0) {
|
|
@@ -78,7 +80,7 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
|
78
80
|
}
|
|
79
81
|
continue;
|
|
80
82
|
}
|
|
81
|
-
if (a.startsWith('--host=') || a.startsWith('-H='))
|
|
83
|
+
if (a.startsWith('--host=') || a.startsWith('-H=') || a.startsWith('--device='))
|
|
82
84
|
continue;
|
|
83
85
|
if (/^-H.+/.test(a))
|
|
84
86
|
continue; // glued short form: -Hyosemite-s1
|
|
@@ -5,6 +5,19 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Secrets bundle holding the R2 credentials. */
|
|
7
7
|
export declare const SYNC_BUNDLE = "r2.backups";
|
|
8
|
+
/** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
|
|
9
|
+
export declare const SYNC_ENABLED_ENV = "AGENTS_SESSIONS_SYNC";
|
|
10
|
+
/** Durable, machine-local path holding the sync enable flag. */
|
|
11
|
+
export declare function syncStateFilePath(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether automatic session sync is enabled on this machine. Defaults to true;
|
|
14
|
+
* an unrecognized env value falls through to the file; an absent/unreadable file
|
|
15
|
+
* falls through to the default. Read fresh every call (no memoization) so a
|
|
16
|
+
* `--disable` takes effect on the daemon's next ~90s cycle without a restart.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isSyncEnabled(): boolean;
|
|
19
|
+
/** Persist the machine-local sync enable flag (durable across cache wipes). */
|
|
20
|
+
export declare function setSyncEnabled(enabled: boolean): void;
|
|
8
21
|
export interface R2Config {
|
|
9
22
|
accountId: string;
|
|
10
23
|
bucket: string;
|
|
@@ -3,9 +3,65 @@
|
|
|
3
3
|
* machine's stable identity. Credentials come from the `r2.backups` secrets
|
|
4
4
|
* bundle (OS keychain on macOS, libsecret on Linux) — never from env or disk.
|
|
5
5
|
*/
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
6
8
|
import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
|
|
9
|
+
import { getHistoryDir } from '../../state.js';
|
|
7
10
|
/** Secrets bundle holding the R2 credentials. */
|
|
8
11
|
export const SYNC_BUNDLE = 'r2.backups';
|
|
12
|
+
// ── Enable / disable switch ─────────────────────────────────────────────────
|
|
13
|
+
// Whether the daemon's automatic cross-machine sync (and `agents sync
|
|
14
|
+
// --sessions`) may run on THIS machine. Independent of credential presence
|
|
15
|
+
// (isSyncConfigured): a machine can hold valid R2 creds yet still opt out of the
|
|
16
|
+
// background push/pull — e.g. when on-demand `agents sessions --host` is
|
|
17
|
+
// preferred over the ad-hoc R2 mirror. Manual `agents sessions sync` is an
|
|
18
|
+
// explicit user action and is deliberately NOT gated by this switch.
|
|
19
|
+
//
|
|
20
|
+
// Resolution order: the AGENTS_SESSIONS_SYNC env var (a recognized on/off value
|
|
21
|
+
// wins outright, for ad-hoc overrides and tests), then a durable machine-local
|
|
22
|
+
// flag file, then the default (enabled). The flag lives in the durable
|
|
23
|
+
// ~/.agents/.history tree — NOT .cache — so a cache wipe can never silently
|
|
24
|
+
// re-enable a sync the operator turned off.
|
|
25
|
+
/** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
|
|
26
|
+
export const SYNC_ENABLED_ENV = 'AGENTS_SESSIONS_SYNC';
|
|
27
|
+
const SYNC_ENABLED_FILE = 'sessions-sync.json';
|
|
28
|
+
const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disabled']);
|
|
29
|
+
const ON_VALUES = new Set(['1', 'on', 'true', 'yes', 'enabled']);
|
|
30
|
+
/** Durable, machine-local path holding the sync enable flag. */
|
|
31
|
+
export function syncStateFilePath() {
|
|
32
|
+
return path.join(getHistoryDir(), SYNC_ENABLED_FILE);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether automatic session sync is enabled on this machine. Defaults to true;
|
|
36
|
+
* an unrecognized env value falls through to the file; an absent/unreadable file
|
|
37
|
+
* falls through to the default. Read fresh every call (no memoization) so a
|
|
38
|
+
* `--disable` takes effect on the daemon's next ~90s cycle without a restart.
|
|
39
|
+
*/
|
|
40
|
+
export function isSyncEnabled() {
|
|
41
|
+
const envRaw = process.env[SYNC_ENABLED_ENV]?.trim().toLowerCase();
|
|
42
|
+
if (envRaw) {
|
|
43
|
+
if (OFF_VALUES.has(envRaw))
|
|
44
|
+
return false;
|
|
45
|
+
if (ON_VALUES.has(envRaw))
|
|
46
|
+
return true;
|
|
47
|
+
// Unrecognized value: ignore and consult the persisted flag.
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(fs.readFileSync(syncStateFilePath(), 'utf-8'));
|
|
51
|
+
if (parsed && typeof parsed.enabled === 'boolean')
|
|
52
|
+
return parsed.enabled;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Absent or unreadable → default enabled.
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/** Persist the machine-local sync enable flag (durable across cache wipes). */
|
|
60
|
+
export function setSyncEnabled(enabled) {
|
|
61
|
+
const p = syncStateFilePath();
|
|
62
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
63
|
+
fs.writeFileSync(p, JSON.stringify({ enabled }, null, 2) + '\n', 'utf-8');
|
|
64
|
+
}
|
|
9
65
|
/**
|
|
10
66
|
* Resolve R2 credentials from the `r2.backups` bundle. Throws a clear,
|
|
11
67
|
* actionable error if the bundle or any key is missing — sync cannot proceed
|
|
@@ -46,6 +46,12 @@ export interface SessionMeta {
|
|
|
46
46
|
shortId: string;
|
|
47
47
|
agent: SessionAgentId;
|
|
48
48
|
timestamp: string;
|
|
49
|
+
/**
|
|
50
|
+
* Last-activity time (ISO): the last message timestamp when a parser computed
|
|
51
|
+
* it, else file mtime, else `timestamp`. This is the recency signal the
|
|
52
|
+
* listing sorts and labels by; `timestamp` stays the creation time.
|
|
53
|
+
*/
|
|
54
|
+
lastActivity?: string;
|
|
49
55
|
project?: string;
|
|
50
56
|
cwd?: string;
|
|
51
57
|
filePath: string;
|
|
@@ -111,10 +111,10 @@ export async function runUmbrellaSync(args) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
if (plan.fetchSessions) {
|
|
114
|
-
// Gate exactly like the daemon: a missing r2.backups bundle
|
|
115
|
-
// not an error that fails the whole sync.
|
|
116
|
-
const { isSyncConfigured } = await import('./session/sync/config.js');
|
|
117
|
-
if (isSyncConfigured()) {
|
|
114
|
+
// Gate exactly like the daemon: an off switch or a missing r2.backups bundle
|
|
115
|
+
// is a clean no-op, not an error that fails the whole sync.
|
|
116
|
+
const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
|
|
117
|
+
if (isSyncEnabled() && isSyncConfigured()) {
|
|
118
118
|
const { syncSessions } = await import('./session/sync/sync.js');
|
|
119
119
|
const r = await syncSessions();
|
|
120
120
|
result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
|
|
@@ -70,6 +70,16 @@ export declare function killSession(name: string, socket?: string): Promise<bool
|
|
|
70
70
|
* meta files. Wipes the socket so the next `new` starts from a clean slate.
|
|
71
71
|
*/
|
|
72
72
|
export declare function killAll(socket?: string): Promise<number>;
|
|
73
|
+
/**
|
|
74
|
+
* Map every pane id (`%N`) on a socket to its `session:window.pane` attach
|
|
75
|
+
* target, in one batched `tmux list-panes -a` call. `%116 -> main:2.0` is a
|
|
76
|
+
* valid `tmux attach -t main:2` / `tmux select-window -t main:2` target — a
|
|
77
|
+
* human jump target, unlike the bare `%pane` send-keys id. Because it walks
|
|
78
|
+
* every pane (not just one-per-session), it also surfaces multiple agents that
|
|
79
|
+
* share a session across windows. Best-effort: returns an empty map on any
|
|
80
|
+
* failure (tmux gone, foreign socket) so callers fall back to the raw pane id.
|
|
81
|
+
*/
|
|
82
|
+
export declare function mapPanesToTargets(socket?: string): Promise<Map<string, string>>;
|
|
73
83
|
/**
|
|
74
84
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
75
85
|
* - tmux session with no meta → returned without `meta` (external session)
|