@phnx-labs/agents-cli 1.20.42 → 1.20.44
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 +17 -0
- package/README.md +4 -3
- package/dist/commands/exec.js +46 -8
- package/dist/commands/hosts.js +14 -9
- package/dist/commands/logs.d.ts +4 -0
- package/dist/commands/logs.js +19 -13
- package/dist/commands/routines.d.ts +6 -0
- package/dist/commands/routines.js +70 -12
- package/dist/commands/sessions.d.ts +6 -5
- package/dist/commands/sessions.js +50 -22
- package/dist/commands/teams.js +43 -5
- package/dist/lib/browser/chrome.d.ts +22 -0
- package/dist/lib/browser/chrome.js +53 -13
- package/dist/lib/browser/service.js +13 -0
- package/dist/lib/daemon.js +34 -9
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +83 -6
- package/dist/lib/hosts/dispatch.d.ts +5 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/logs.d.ts +14 -5
- package/dist/lib/hosts/logs.js +39 -13
- package/dist/lib/hosts/session-index.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +15 -0
- package/dist/lib/hosts/tasks.js +16 -0
- package/dist/lib/redact.js +1 -0
- package/dist/lib/rotate.d.ts +11 -6
- package/dist/lib/rotate.js +25 -11
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +17 -1
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +84 -19
- package/dist/lib/session/discover.js +5 -1
- package/dist/lib/session/remote.d.ts +4 -6
- package/dist/lib/session/remote.js +5 -12
- package/dist/lib/session/run-names.d.ts +32 -0
- package/dist/lib/session/run-names.js +63 -0
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -3
- package/dist/lib/teams/agents.js +16 -7
- package/dist/lib/tmux/session.d.ts +40 -0
- package/dist/lib/tmux/session.js +92 -0
- package/dist/lib/usage.d.ts +5 -3
- package/dist/lib/usage.js +5 -3
- package/dist/lib/versions.d.ts +54 -1
- package/dist/lib/versions.js +138 -1
- package/package.json +1 -1
package/dist/lib/hosts/logs.d.ts
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
3
|
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
4
|
*
|
|
5
|
-
* A running task with follow re-enters the offset-tail (`followHostTask`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`).
|
|
6
|
+
* Otherwise the view is **concise by default**: a bounded tail of the captured
|
|
7
|
+
* combined-stdout, so an agent glancing at a dispatched run never pulls the whole
|
|
8
|
+
* log. `full` opts into the entire raw log. (A host run's real transcript lives
|
|
9
|
+
* on the remote, not the local index — surfacing its rich summary needs remote
|
|
10
|
+
* runs to be discoverable there first; until then the bounded tail is the safe
|
|
11
|
+
* concise default.) Kept in one place so the two commands can never drift.
|
|
8
12
|
*/
|
|
9
13
|
export interface HostLogResult {
|
|
10
14
|
/** False when no host task with this id exists (caller may fall through to sessions). */
|
|
@@ -12,5 +16,10 @@ export interface HostLogResult {
|
|
|
12
16
|
/** Process exit code to adopt when the task was shown/followed. */
|
|
13
17
|
exitCode?: number;
|
|
14
18
|
}
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Show (or follow, when running) a dispatched host task. Bounded-tail summary by
|
|
21
|
+
* default; `full` dumps the entire raw combined-stdout log.
|
|
22
|
+
*/
|
|
23
|
+
export declare function showHostTaskLog(id: string, follow: boolean, full?: boolean): Promise<HostLogResult>;
|
|
24
|
+
/** Last `n` lines of `text`, prefixed with an elision note when truncated. */
|
|
25
|
+
export declare function tailLines(text: string, n: number): string;
|
package/dist/lib/hosts/logs.js
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
3
|
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
4
|
*
|
|
5
|
-
* A running task with follow re-enters the offset-tail (`followHostTask`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`).
|
|
6
|
+
* Otherwise the view is **concise by default**: a bounded tail of the captured
|
|
7
|
+
* combined-stdout, so an agent glancing at a dispatched run never pulls the whole
|
|
8
|
+
* log. `full` opts into the entire raw log. (A host run's real transcript lives
|
|
9
|
+
* on the remote, not the local index — surfacing its rich summary needs remote
|
|
10
|
+
* runs to be discoverable there first; until then the bounded tail is the safe
|
|
11
|
+
* concise default.) Kept in one place so the two commands can never drift.
|
|
8
12
|
*/
|
|
9
13
|
import * as fs from 'fs';
|
|
10
14
|
import chalk from 'chalk';
|
|
@@ -12,8 +16,13 @@ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
|
|
|
12
16
|
import { followHostTask } from './progress.js';
|
|
13
17
|
import { reconcileTask } from './reconcile.js';
|
|
14
18
|
import { sshExecRaw } from '../ssh-exec.js';
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/** Lines of raw combined-stdout to show in the concise (non-`full`) view. */
|
|
20
|
+
const HOST_LOG_TAIL_LINES = 40;
|
|
21
|
+
/**
|
|
22
|
+
* Show (or follow, when running) a dispatched host task. Bounded-tail summary by
|
|
23
|
+
* default; `full` dumps the entire raw combined-stdout log.
|
|
24
|
+
*/
|
|
25
|
+
export async function showHostTaskLog(id, follow, full = false) {
|
|
17
26
|
const task = loadTask(id);
|
|
18
27
|
if (!task)
|
|
19
28
|
return { found: false };
|
|
@@ -36,21 +45,38 @@ export async function showHostTaskLog(id, follow) {
|
|
|
36
45
|
// plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
|
|
37
46
|
// ssh) once the record is already terminal.
|
|
38
47
|
reconcileTask(task);
|
|
48
|
+
// Raw combined-stdout: the whole log with `full`, else a bounded tail.
|
|
49
|
+
const raw = readTaskLog(task);
|
|
50
|
+
if (raw === null) {
|
|
51
|
+
process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
|
|
52
|
+
return { found: true, exitCode: 0 };
|
|
53
|
+
}
|
|
54
|
+
process.stdout.write(full ? raw : tailLines(raw, HOST_LOG_TAIL_LINES));
|
|
55
|
+
return { found: true, exitCode: 0 };
|
|
56
|
+
}
|
|
57
|
+
/** Read the task's combined-stdout — local mirror first, else fetch+cache remote. */
|
|
58
|
+
function readTaskLog(task) {
|
|
39
59
|
try {
|
|
40
|
-
|
|
60
|
+
return fs.readFileSync(localLogPath(task.id), 'utf-8');
|
|
41
61
|
}
|
|
42
62
|
catch {
|
|
43
63
|
// No local log — task was dispatched with --no-follow. Fetch from the remote
|
|
44
64
|
// on demand and cache locally so subsequent calls are instant.
|
|
45
65
|
const remote = fetchAndCacheRemoteLog(task);
|
|
46
|
-
|
|
47
|
-
process.stdout.write(remote);
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
process.stdout.write(chalk.gray('(no local log captured for this task)\n'));
|
|
51
|
-
}
|
|
66
|
+
return remote !== null ? remote.toString('utf-8') : null;
|
|
52
67
|
}
|
|
53
|
-
|
|
68
|
+
}
|
|
69
|
+
/** Last `n` lines of `text`, prefixed with an elision note when truncated. */
|
|
70
|
+
export function tailLines(text, n) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
// A trailing newline yields a final empty element — drop it from the count.
|
|
73
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
74
|
+
lines.pop();
|
|
75
|
+
if (lines.length <= n)
|
|
76
|
+
return lines.join('\n') + '\n';
|
|
77
|
+
const hidden = lines.length - n;
|
|
78
|
+
const note = chalk.gray(`… ${hidden} earlier line${hidden === 1 ? '' : 's'} hidden — pass --full for the whole log\n`);
|
|
79
|
+
return note + lines.slice(-n).join('\n') + '\n';
|
|
54
80
|
}
|
|
55
81
|
/**
|
|
56
82
|
* Fetch a task's remote log over SSH, write it to the local mirror path (for
|
|
@@ -15,6 +15,14 @@ export interface HostTask {
|
|
|
15
15
|
agent: string;
|
|
16
16
|
prompt: string;
|
|
17
17
|
pid?: number;
|
|
18
|
+
/**
|
|
19
|
+
* The durable `agents run --name <slug>` handle for this dispatch, if given.
|
|
20
|
+
* Chosen at launch and agent-agnostic (unlike sessionId), so `agents hosts
|
|
21
|
+
* ps/logs <name>` and the dispatch tip can reference the run by a stable name
|
|
22
|
+
* even for agents that never expose a session id up front. Absent when the
|
|
23
|
+
* run was launched without `--name`.
|
|
24
|
+
*/
|
|
25
|
+
name?: string;
|
|
18
26
|
/**
|
|
19
27
|
* The agent session id the remote run was launched with (Claude only — the
|
|
20
28
|
* only agent that accepts `--session-id` to force a NEW session's id). Lets
|
|
@@ -52,3 +60,10 @@ export declare function listTasks(): HostTask[];
|
|
|
52
60
|
* with the same forced id resolves to the most recent dispatch.
|
|
53
61
|
*/
|
|
54
62
|
export declare function findTaskBySessionId(sessionId: string): HostTask | null;
|
|
63
|
+
/**
|
|
64
|
+
* Find the newest host task launched with `--name <name>`, so `agents hosts
|
|
65
|
+
* logs/ps <name>` and resolve-by-handle can address a run by its durable name.
|
|
66
|
+
* Case-insensitive; newest wins (listTasks is createdAt-desc) when a name was
|
|
67
|
+
* reused across dispatches.
|
|
68
|
+
*/
|
|
69
|
+
export declare function findTaskByName(name: string): HostTask | null;
|
package/dist/lib/hosts/tasks.js
CHANGED
|
@@ -85,3 +85,19 @@ export function findTaskBySessionId(sessionId) {
|
|
|
85
85
|
}
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Find the newest host task launched with `--name <name>`, so `agents hosts
|
|
90
|
+
* logs/ps <name>` and resolve-by-handle can address a run by its durable name.
|
|
91
|
+
* Case-insensitive; newest wins (listTasks is createdAt-desc) when a name was
|
|
92
|
+
* reused across dispatches.
|
|
93
|
+
*/
|
|
94
|
+
export function findTaskByName(name) {
|
|
95
|
+
if (!name)
|
|
96
|
+
return null;
|
|
97
|
+
const wanted = name.toLowerCase();
|
|
98
|
+
for (const task of listTasks()) {
|
|
99
|
+
if (task.name && task.name.toLowerCase() === wanted)
|
|
100
|
+
return task;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
package/dist/lib/redact.js
CHANGED
|
@@ -7,6 +7,7 @@ const SECRET_PATTERNS = [
|
|
|
7
7
|
[/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
|
|
8
8
|
[/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
|
|
9
9
|
[/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
|
|
10
|
+
[/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
|
|
10
11
|
[/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
|
|
11
12
|
];
|
|
12
13
|
export function redactSecrets(text) {
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -47,9 +47,11 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
|
|
|
47
47
|
* Resolve the configured strategy. Lookup order:
|
|
48
48
|
* 1. project-local agents.yaml (nearest to `startPath`)
|
|
49
49
|
* 2. ~/.agents/.system/agents.yaml
|
|
50
|
-
* 3. default: `
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* 3. default: `balanced` (weighted-random across all healthy accounts by
|
|
51
|
+
* remaining headroom, skipping any that are currently rate-limited). A
|
|
52
|
+
* bare `agents run <agent>` — e.g. every new terminal the extension spawns
|
|
53
|
+
* — should spread load and never launch into a throttled account, rather
|
|
54
|
+
* than stick to the pinned default even when it's maxed.
|
|
53
55
|
*/
|
|
54
56
|
export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
|
|
55
57
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
@@ -65,9 +67,12 @@ export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrate
|
|
|
65
67
|
* headroom, with no stampede on the lowest-usage one. Stateless — parallel
|
|
66
68
|
* callers naturally fan out via the random roll.
|
|
67
69
|
*
|
|
68
|
-
* Eligibility: signed in (email present), auth valid, and
|
|
69
|
-
*
|
|
70
|
-
* when no live
|
|
70
|
+
* Eligibility: signed in (email present), auth valid, and not currently
|
|
71
|
+
* rate-limited — no blocking window (session OR weekly) at 100%, matching the
|
|
72
|
+
* `agents view` badge; or the local cached status is usable when no live
|
|
73
|
+
* snapshot exists. Note the split: eligibility considers the session window
|
|
74
|
+
* (a session-maxed account can't run now), but the capacity *weight* above is
|
|
75
|
+
* driven by weekly headroom so a brief session spike doesn't distort routing.
|
|
71
76
|
*
|
|
72
77
|
* Dedupe: when multiple versions share an email, collapse to one candidate
|
|
73
78
|
* per email (the least-recently-active version). Prevents two parallel pods
|
package/dist/lib/rotate.js
CHANGED
|
@@ -10,7 +10,7 @@ import { getAccountInfo } from './agents.js';
|
|
|
10
10
|
import { readMeta, writeMeta, getHelpersDir } from './state.js';
|
|
11
11
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
|
|
12
12
|
import { getProjectRunConfigs } from './run-config.js';
|
|
13
|
-
import { getUsageInfoByIdentity, getUsageLookupKey, } from './usage.js';
|
|
13
|
+
import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
|
|
14
14
|
function getRotateDir() {
|
|
15
15
|
const dir = path.join(getHelpersDir(), 'rotate');
|
|
16
16
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -44,14 +44,16 @@ export function getProjectRunStrategy(agent, startPath) {
|
|
|
44
44
|
* Resolve the configured strategy. Lookup order:
|
|
45
45
|
* 1. project-local agents.yaml (nearest to `startPath`)
|
|
46
46
|
* 2. ~/.agents/.system/agents.yaml
|
|
47
|
-
* 3. default: `
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* 3. default: `balanced` (weighted-random across all healthy accounts by
|
|
48
|
+
* remaining headroom, skipping any that are currently rate-limited). A
|
|
49
|
+
* bare `agents run <agent>` — e.g. every new terminal the extension spawns
|
|
50
|
+
* — should spread load and never launch into a throttled account, rather
|
|
51
|
+
* than stick to the pinned default even when it's maxed.
|
|
50
52
|
*/
|
|
51
53
|
export function getConfiguredRunStrategy(agent, startPath = process.cwd()) {
|
|
52
54
|
return getProjectRunStrategy(agent, startPath)
|
|
53
55
|
?? normalizeRunStrategy(readMeta().run?.[agent]?.strategy)
|
|
54
|
-
?? '
|
|
56
|
+
?? 'balanced';
|
|
55
57
|
}
|
|
56
58
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
57
59
|
export function setGlobalRunStrategy(agent, strategy) {
|
|
@@ -72,10 +74,19 @@ function isAvailableEligible(candidate) {
|
|
|
72
74
|
&& hasUsageAvailable(candidate);
|
|
73
75
|
}
|
|
74
76
|
function hasUsageAvailable(candidate) {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
77
|
+
const snapshot = candidate.usageSnapshot;
|
|
78
|
+
if (snapshot && snapshot.windows.length > 0) {
|
|
79
|
+
// Eligibility mirrors the `agents view` throttle badge exactly
|
|
80
|
+
// (deriveUsageStatusFromSnapshot): an account maxed on ANY blocking window —
|
|
81
|
+
// including the 5-hour session window — cannot serve the next request, so it
|
|
82
|
+
// must not be picked. Previously this checked only non-session windows
|
|
83
|
+
// (getRoutingUsedPercent), so a session-maxed account with weekly headroom
|
|
84
|
+
// stayed "eligible" and the router kept launching into it while `ag view`
|
|
85
|
+
// showed it rate-limited. Capacity *weighting* still ranks eligible accounts
|
|
86
|
+
// by weekly headroom; this gate only decides can-it-run-right-now.
|
|
87
|
+
return deriveUsageStatusFromSnapshot(snapshot) !== 'rate_limited';
|
|
78
88
|
}
|
|
89
|
+
// No live snapshot: fall back to the coarse cached status.
|
|
79
90
|
if (candidate.usageStatus === 'out_of_credits' || candidate.usageStatus === 'rate_limited') {
|
|
80
91
|
return false;
|
|
81
92
|
}
|
|
@@ -140,9 +151,12 @@ function dedupeAndSortCandidates(candidates) {
|
|
|
140
151
|
* headroom, with no stampede on the lowest-usage one. Stateless — parallel
|
|
141
152
|
* callers naturally fan out via the random roll.
|
|
142
153
|
*
|
|
143
|
-
* Eligibility: signed in (email present), auth valid, and
|
|
144
|
-
*
|
|
145
|
-
* when no live
|
|
154
|
+
* Eligibility: signed in (email present), auth valid, and not currently
|
|
155
|
+
* rate-limited — no blocking window (session OR weekly) at 100%, matching the
|
|
156
|
+
* `agents view` badge; or the local cached status is usable when no live
|
|
157
|
+
* snapshot exists. Note the split: eligibility considers the session window
|
|
158
|
+
* (a session-maxed account can't run now), but the capacity *weight* above is
|
|
159
|
+
* driven by weekly headroom so a brief session spike doesn't distort routing.
|
|
146
160
|
*
|
|
147
161
|
* Dedupe: when multiple versions share an email, collapse to one candidate
|
|
148
162
|
* per email (the least-recently-active version). Prevents two parallel pods
|
|
@@ -20,6 +20,8 @@ export interface ActiveSession {
|
|
|
20
20
|
cwd?: string;
|
|
21
21
|
/** User-given name from /rename command. */
|
|
22
22
|
label?: string;
|
|
23
|
+
/** Durable `agents run --name` launch handle, when the run was named. */
|
|
24
|
+
name?: string;
|
|
23
25
|
/** First meaningful line of the initial prompt (extracted topic). */
|
|
24
26
|
topic?: string;
|
|
25
27
|
/** Live preview: the latest turn (agent message or tool action), from the state engine. */
|
|
@@ -100,6 +102,12 @@ export interface ActiveQueryOptions {
|
|
|
100
102
|
/** Skip the `ps` scan for ad-hoc headless agents. */
|
|
101
103
|
skipHeadless?: boolean;
|
|
102
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Resolve an agent kind from a process's reported executable. `comm` may be an
|
|
107
|
+
* absolute path (shim-launched agents), and Windows image names carry an
|
|
108
|
+
* `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
|
|
109
|
+
*/
|
|
110
|
+
export declare function agentKindFromComm(commRaw: string): string | undefined;
|
|
103
111
|
/**
|
|
104
112
|
* Pick a Claude transcript file within a project dir.
|
|
105
113
|
*
|
|
@@ -26,6 +26,7 @@ import { AgentManager } from '../teams/agents.js';
|
|
|
26
26
|
import { getTerminalsDir } from '../state.js';
|
|
27
27
|
import { readPidSessionEntry, prunePidSessionRegistry } from './pid-registry.js';
|
|
28
28
|
import { buildClaudeLabelMap } from './discover.js';
|
|
29
|
+
import { buildRunNameMap } from './run-names.js';
|
|
29
30
|
import { latestSessionFileForCwd } from './db.js';
|
|
30
31
|
import { extractSessionTopic } from './prompt.js';
|
|
31
32
|
import { readSessionTail } from './tail.js';
|
|
@@ -63,7 +64,16 @@ const AGENT_CLI_NAMES = {
|
|
|
63
64
|
* absolute path (shim-launched agents), and Windows image names carry an
|
|
64
65
|
* `.exe` suffix (`claude.exe`), so basename + suffix-strip before the lookup.
|
|
65
66
|
*/
|
|
66
|
-
function agentKindFromComm(commRaw) {
|
|
67
|
+
export function agentKindFromComm(commRaw) {
|
|
68
|
+
// A GUI desktop app can bundle a binary with the SAME name as an agent CLI: the
|
|
69
|
+
// Codex desktop app ships `/Applications/Codex.app/Contents/Resources/codex` (its
|
|
70
|
+
// `app-server`), whose basename `codex` would otherwise match the codex CLI and
|
|
71
|
+
// surface the app's background server as a phantom agent session — running at cwd
|
|
72
|
+
// '/', so it shows up unattributed in the feed. A real agent CLI is never inside a
|
|
73
|
+
// `.app` bundle, so exclude those. (The Claude desktop app is a separate case,
|
|
74
|
+
// already excluded by name below: its process is 'Claude', not the CLI's 'claude'.)
|
|
75
|
+
if (commRaw.includes('.app/Contents/'))
|
|
76
|
+
return undefined;
|
|
67
77
|
const base = path.basename(commRaw);
|
|
68
78
|
const stripped = base.replace(/\.exe$/i, '');
|
|
69
79
|
// Windows image names compare case-insensitively; POSIX comms stay exact —
|
|
@@ -342,6 +352,9 @@ export async function listTerminalsActive() {
|
|
|
342
352
|
procByPid.set(r.pid, r);
|
|
343
353
|
// Build label map from Claude's sessions/*.json for /rename support
|
|
344
354
|
const labelMap = buildClaudeLabelMap();
|
|
355
|
+
// Run-name handles (`agents run --name`) keyed by session id, for the same
|
|
356
|
+
// sessionId → handle resolution as labels.
|
|
357
|
+
const runNameMap = buildRunNameMap();
|
|
345
358
|
return entries.map((t) => {
|
|
346
359
|
// The id cached in live-terminals.json goes stale when Claude rotates its
|
|
347
360
|
// transcript uuid on resume/compact, so it often no longer matches any
|
|
@@ -356,6 +369,8 @@ export async function listTerminalsActive() {
|
|
|
356
369
|
const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
|
|
357
370
|
// Prefer label from live terminal, fall back to Claude's session label
|
|
358
371
|
const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
|
|
372
|
+
// Durable run name from `agents run --name`, resolved by the run's session id.
|
|
373
|
+
const name = resolvedId ? runNameMap.get(resolvedId) ?? undefined : undefined;
|
|
359
374
|
// Extract topic from session file (first meaningful user message)
|
|
360
375
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
361
376
|
const state = computeLiveState(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
|
|
@@ -368,6 +383,7 @@ export async function listTerminalsActive() {
|
|
|
368
383
|
sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
|
|
369
384
|
cwd: t.cwd ?? undefined,
|
|
370
385
|
label,
|
|
386
|
+
name,
|
|
371
387
|
topic,
|
|
372
388
|
sessionFile,
|
|
373
389
|
startedAtMs: t.startedAtMs,
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface SessionRow {
|
|
|
22
22
|
git_branch: string | null;
|
|
23
23
|
topic: string | null;
|
|
24
24
|
label: string | null;
|
|
25
|
+
name: string | null;
|
|
25
26
|
message_count: number | null;
|
|
26
27
|
token_count: number | null;
|
|
27
28
|
cost_usd: number | null;
|
|
@@ -128,6 +129,16 @@ export declare function upsertSessionsBatch(entries: Array<{
|
|
|
128
129
|
* Leaves FTS5 content/topic/project untouched — cheap to call every run.
|
|
129
130
|
*/
|
|
130
131
|
export declare function syncLabels(labelMap: Map<string, string | null>): number;
|
|
132
|
+
/**
|
|
133
|
+
* Sync `agents run --name` handles for a set of sessions, keyed by session id.
|
|
134
|
+
* The name's source of truth lives outside the transcript (host task sidecars,
|
|
135
|
+
* run-name sidecars written at launch), so — like {@link syncLabels} — it is
|
|
136
|
+
* re-applied by id every scan rather than parsed per-file. Updates only
|
|
137
|
+
* `sessions.name` (names resolve via a direct column tier in ftsSearch, not
|
|
138
|
+
* FTS, so there's no session_text column to touch). Only writes when the value
|
|
139
|
+
* differs; cheap to call every run. Returns the number of rows updated.
|
|
140
|
+
*/
|
|
141
|
+
export declare function syncNames(nameMap: Map<string, string | null>): number;
|
|
131
142
|
/**
|
|
132
143
|
* Sync topics (session titles) for a set of sessions, keyed by id. For agents
|
|
133
144
|
* whose human-readable title lives in a side index that updates independently
|
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 = 9;
|
|
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`
|
|
@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
52
52
|
git_branch TEXT,
|
|
53
53
|
topic TEXT,
|
|
54
54
|
label TEXT,
|
|
55
|
+
name TEXT,
|
|
55
56
|
message_count INTEGER,
|
|
56
57
|
token_count INTEGER,
|
|
57
58
|
cost_usd REAL,
|
|
@@ -188,6 +189,15 @@ function migrateSchema(db, fromVersion) {
|
|
|
188
189
|
db.exec(`UPDATE sessions SET last_activity = timestamp WHERE last_activity IS NULL`);
|
|
189
190
|
db.exec(`DELETE FROM scan_ledger;`);
|
|
190
191
|
}
|
|
192
|
+
if (fromVersion < 9) {
|
|
193
|
+
// v8 → v9: `agents run --name <slug>` gives a run a durable launch handle,
|
|
194
|
+
// resolvable via `agents sessions <name>`. Additive column; NO rescan — the
|
|
195
|
+
// name is set at run time (host sidecar / run-name sidecar), not parsed from
|
|
196
|
+
// transcripts, so existing rows stay valid with a NULL name.
|
|
197
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
198
|
+
if (!cols.some(c => c.name === 'name'))
|
|
199
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN name TEXT`);
|
|
200
|
+
}
|
|
191
201
|
}
|
|
192
202
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
193
203
|
export function getDB() {
|
|
@@ -401,13 +411,13 @@ export function recordScans(entries) {
|
|
|
401
411
|
const upsertSessionStmt = (db) => db.prepare(`
|
|
402
412
|
INSERT INTO sessions (
|
|
403
413
|
id, short_id, agent, version, account, timestamp, last_activity,
|
|
404
|
-
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
414
|
+
project, cwd, git_branch, topic, label, name, message_count, token_count,
|
|
405
415
|
cost_usd, duration_ms,
|
|
406
416
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
407
417
|
pr_url, pr_number, worktree_slug, ticket_id
|
|
408
418
|
) VALUES (
|
|
409
419
|
@id, @short_id, @agent, @version, @account, @timestamp, @last_activity,
|
|
410
|
-
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
420
|
+
@project, @cwd, @git_branch, @topic, @label, @name, @message_count, @token_count,
|
|
411
421
|
@cost_usd, @duration_ms,
|
|
412
422
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
413
423
|
@pr_url, @pr_number, @worktree_slug, @ticket_id
|
|
@@ -471,6 +481,7 @@ export function upsertSession(meta, content, scan) {
|
|
|
471
481
|
git_branch: meta.gitBranch ?? null,
|
|
472
482
|
topic: meta.topic ?? null,
|
|
473
483
|
label: meta.label ?? null,
|
|
484
|
+
name: meta.name ?? null,
|
|
474
485
|
message_count: meta.messageCount ?? null,
|
|
475
486
|
token_count: meta.tokenCount ?? null,
|
|
476
487
|
cost_usd: meta.costUsd ?? null,
|
|
@@ -559,6 +570,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
559
570
|
git_branch: meta.gitBranch ?? null,
|
|
560
571
|
topic: meta.topic ?? null,
|
|
561
572
|
label: meta.label ?? null,
|
|
573
|
+
name: meta.name ?? null,
|
|
562
574
|
message_count: meta.messageCount ?? null,
|
|
563
575
|
token_count: meta.tokenCount ?? null,
|
|
564
576
|
cost_usd: meta.costUsd ?? null,
|
|
@@ -626,6 +638,45 @@ export function syncLabels(labelMap) {
|
|
|
626
638
|
txn(updates);
|
|
627
639
|
return updates.length;
|
|
628
640
|
}
|
|
641
|
+
/**
|
|
642
|
+
* Sync `agents run --name` handles for a set of sessions, keyed by session id.
|
|
643
|
+
* The name's source of truth lives outside the transcript (host task sidecars,
|
|
644
|
+
* run-name sidecars written at launch), so — like {@link syncLabels} — it is
|
|
645
|
+
* re-applied by id every scan rather than parsed per-file. Updates only
|
|
646
|
+
* `sessions.name` (names resolve via a direct column tier in ftsSearch, not
|
|
647
|
+
* FTS, so there's no session_text column to touch). Only writes when the value
|
|
648
|
+
* differs; cheap to call every run. Returns the number of rows updated.
|
|
649
|
+
*/
|
|
650
|
+
export function syncNames(nameMap) {
|
|
651
|
+
if (nameMap.size === 0)
|
|
652
|
+
return 0;
|
|
653
|
+
const db = getDB();
|
|
654
|
+
const ids = [...nameMap.keys()];
|
|
655
|
+
const CHUNK = 500;
|
|
656
|
+
const updates = [];
|
|
657
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
658
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
659
|
+
const placeholders = chunk.map(() => '?').join(',');
|
|
660
|
+
const rows = db
|
|
661
|
+
.prepare(`SELECT id, name FROM sessions WHERE id IN (${placeholders})`)
|
|
662
|
+
.all(...chunk);
|
|
663
|
+
for (const row of rows) {
|
|
664
|
+
const live = nameMap.get(row.id) ?? null;
|
|
665
|
+
if ((live ?? '') !== (row.name ?? '')) {
|
|
666
|
+
updates.push({ id: row.id, name: live });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (updates.length === 0)
|
|
671
|
+
return 0;
|
|
672
|
+
const upd = db.prepare(`UPDATE sessions SET name = ? WHERE id = ?`);
|
|
673
|
+
const txn = db.transaction((items) => {
|
|
674
|
+
for (const { id, name } of items)
|
|
675
|
+
upd.run(name, id);
|
|
676
|
+
});
|
|
677
|
+
txn(updates);
|
|
678
|
+
return updates.length;
|
|
679
|
+
}
|
|
629
680
|
/**
|
|
630
681
|
* Sync topics (session titles) for a set of sessions, keyed by id. For agents
|
|
631
682
|
* whose human-readable title lives in a side index that updates independently
|
|
@@ -688,6 +739,7 @@ function rowToMeta(row) {
|
|
|
688
739
|
account: row.account ?? undefined,
|
|
689
740
|
topic: row.topic ?? undefined,
|
|
690
741
|
label: row.label ?? undefined,
|
|
742
|
+
name: row.name ?? undefined,
|
|
691
743
|
isTeamOrigin: row.is_team_origin === 1,
|
|
692
744
|
prUrl: row.pr_url ?? undefined,
|
|
693
745
|
prNumber: row.pr_number ?? undefined,
|
|
@@ -956,26 +1008,39 @@ export function ftsSearch(input, limit = 200) {
|
|
|
956
1008
|
const lower = trimmed.toLowerCase();
|
|
957
1009
|
const seen = new Set();
|
|
958
1010
|
const hits = [];
|
|
959
|
-
// Tier 1-3:
|
|
1011
|
+
// Tier 1-3: handle-based matches, ordered by exactness. A session's handle is
|
|
1012
|
+
// its /rename `label` OR its `agents run --name` handle — both are user-chosen
|
|
1013
|
+
// aliases and rank identically, so typing either the renamed title or the run
|
|
1014
|
+
// name resolves the session ahead of any FTS content hit.
|
|
960
1015
|
const labelRows = db.prepare(`
|
|
961
|
-
SELECT id, label FROM sessions
|
|
962
|
-
WHERE label IS NOT NULL AND LOWER(label) LIKE ?
|
|
963
|
-
|
|
1016
|
+
SELECT id, label, name FROM sessions
|
|
1017
|
+
WHERE (label IS NOT NULL AND LOWER(label) LIKE ?)
|
|
1018
|
+
OR (name IS NOT NULL AND LOWER(name) LIKE ?)
|
|
1019
|
+
`).all(`%${lower}%`, `%${lower}%`);
|
|
964
1020
|
let hasExactLabelMatch = false;
|
|
965
1021
|
for (const row of labelRows) {
|
|
966
|
-
|
|
967
|
-
let score;
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1022
|
+
// Score against whichever handle matches best (exact > prefix > contains).
|
|
1023
|
+
let score = 0;
|
|
1024
|
+
for (const handle of [row.label, row.name]) {
|
|
1025
|
+
if (!handle)
|
|
1026
|
+
continue;
|
|
1027
|
+
const h = handle.toLowerCase();
|
|
1028
|
+
if (!h.includes(lower))
|
|
1029
|
+
continue;
|
|
1030
|
+
if (h === lower) {
|
|
1031
|
+
score = Math.max(score, 1_000_000);
|
|
1032
|
+
hasExactLabelMatch = true;
|
|
1033
|
+
}
|
|
1034
|
+
else if (h.startsWith(lower)) {
|
|
1035
|
+
score = Math.max(score, 900_000);
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
score = Math.max(score, 800_000);
|
|
1039
|
+
}
|
|
977
1040
|
}
|
|
978
|
-
|
|
1041
|
+
if (score === 0)
|
|
1042
|
+
continue;
|
|
1043
|
+
// matchedTerms is empty for handle hits — the picker can render the handle
|
|
979
1044
|
// itself as the highlight, no badge needed.
|
|
980
1045
|
hits.push({ sessionId: row.id, score, matchedTerms: [] });
|
|
981
1046
|
seen.add(row.id);
|
|
@@ -25,7 +25,8 @@ import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from '.
|
|
|
25
25
|
import { costOfUsage } from '../pricing/index.js';
|
|
26
26
|
import { machineId } from './sync/config.js';
|
|
27
27
|
import { mapBounded } from '../concurrency.js';
|
|
28
|
-
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
28
|
+
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
29
|
+
import { buildRunNameMap } from './run-names.js';
|
|
29
30
|
const HOME = os.homedir();
|
|
30
31
|
// Versions can live under either repo: the user repo (current canonical
|
|
31
32
|
// location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
|
|
@@ -63,6 +64,9 @@ export async function discoverSessions(options) {
|
|
|
63
64
|
// reads to behavioral EDR (CrowdStrike Falcon) as a ransomware-style bulk
|
|
64
65
|
// file-enumeration sweep. Same dirs, same results — just not all at once.
|
|
65
66
|
await scanAgentsBounded(agents, agent => dispatchAgentScan(agent, onProgress));
|
|
67
|
+
// Apply `agents run --name` handles onto the freshly-scanned rows by id —
|
|
68
|
+
// the same idempotent, re-applied-every-scan pattern as /rename labels.
|
|
69
|
+
syncNames(buildRunNameMap());
|
|
66
70
|
}
|
|
67
71
|
finally {
|
|
68
72
|
releaseScan(process.pid);
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* POSIX single-quote a string for safe interpolation into a remote shell command.
|
|
3
|
+
* Always wraps (unlike the bare-passthrough variant in `ssh-exec.ts`) — the
|
|
4
|
+
* forwarded `agents` argv is embedded verbatim inside `bash -lc '<cmd>'`, so
|
|
5
|
+
* every token is quoted to keep the command boundary unambiguous.
|
|
5
6
|
*/
|
|
6
|
-
export declare const SSH_TARGET_RE: RegExp;
|
|
7
|
-
export declare function assertValidSshTarget(host: string): void;
|
|
8
|
-
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
9
7
|
export declare function shellQuote(s: string): string;
|
|
10
8
|
/**
|
|
11
9
|
* Strip the `--host`/`-H` flag (and its value) from a raw `agents sessions` argv,
|
|
@@ -27,24 +27,17 @@ import { join } from 'path';
|
|
|
27
27
|
import { createHash } from 'crypto';
|
|
28
28
|
import chalk from 'chalk';
|
|
29
29
|
import { getCacheDir } from '../state.js';
|
|
30
|
-
import { SSH_OPTS, controlOpts } from '../ssh-exec.js';
|
|
30
|
+
import { SSH_OPTS, controlOpts, assertValidSshTarget } from '../ssh-exec.js';
|
|
31
31
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
32
32
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
33
33
|
import { formatRelativeTime } from './relative-time.js';
|
|
34
34
|
import { terminalWidth } from './width.js';
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* POSIX single-quote a string for safe interpolation into a remote shell command.
|
|
37
|
+
* Always wraps (unlike the bare-passthrough variant in `ssh-exec.ts`) — the
|
|
38
|
+
* forwarded `agents` argv is embedded verbatim inside `bash -lc '<cmd>'`, so
|
|
39
|
+
* every token is quoted to keep the command boundary unambiguous.
|
|
39
40
|
*/
|
|
40
|
-
export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
|
|
41
|
-
export function assertValidSshTarget(host) {
|
|
42
|
-
if (!SSH_TARGET_RE.test(host)) {
|
|
43
|
-
throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host ` +
|
|
44
|
-
`(letters, digits, '.', '_', '-').`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
48
41
|
export function shellQuote(s) {
|
|
49
42
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
50
43
|
}
|