@phnx-labs/agents-cli 1.20.44 → 1.20.45
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 +9 -1
- package/dist/commands/exec.js +54 -11
- package/dist/commands/secrets.d.ts +18 -0
- package/dist/commands/secrets.js +105 -30
- package/dist/commands/teams.js +61 -3
- package/dist/lib/hosts/dispatch.d.ts +29 -0
- package/dist/lib/hosts/dispatch.js +46 -1
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +27 -0
- package/dist/lib/hosts/session-index.d.ts +15 -0
- package/dist/lib/hosts/session-index.js +28 -2
- package/dist/lib/rotate.d.ts +33 -0
- package/dist/lib/rotate.js +37 -0
- package/dist/lib/secrets/remote.d.ts +14 -0
- package/dist/lib/secrets/remote.js +18 -1
- package/dist/lib/session/active.d.ts +4 -0
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/db.d.ts +16 -9
- package/dist/lib/session/db.js +66 -44
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +84 -13
- package/dist/lib/session/run-names.d.ts +9 -7
- package/dist/lib/session/run-names.js +9 -7
- package/dist/lib/session/state.d.ts +29 -3
- package/dist/lib/session/state.js +84 -5
- package/dist/lib/session/types.d.ts +19 -8
- package/dist/lib/teams/agents.js +9 -0
- package/package.json +1 -1
|
@@ -35,8 +35,10 @@ export function hostSessionMeta(task, ctx) {
|
|
|
35
35
|
// stale-filter treats as "always live" (see module doc).
|
|
36
36
|
filePath: '',
|
|
37
37
|
topic: ctx.prompt.split('\n')[0]?.slice(0, 120) || undefined,
|
|
38
|
-
label
|
|
39
|
-
name
|
|
38
|
+
// The run's `--name` seeds the label (resolves `agents sessions <name>` and
|
|
39
|
+
// `agents hosts logs <name>`); an unnamed host run falls back to the
|
|
40
|
+
// `[host/<name>]` indicator, mirroring the cloud path's `[cloud/<status>]`.
|
|
41
|
+
label: task.name || `[host/${task.host}]`,
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
@@ -55,3 +57,27 @@ export function registerHostSession(task, ctx) {
|
|
|
55
57
|
/* index write is best-effort; the run is already live on the host */
|
|
56
58
|
}
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Register an interactive host run (no prompt, TTY forwarded over SSH) in the
|
|
62
|
+
* local session index. Unlike detached host runs, there is no remote log/exit
|
|
63
|
+
* file and no HostTask; we only need the session id so `agents sessions` can
|
|
64
|
+
* surface and resume it by id.
|
|
65
|
+
*/
|
|
66
|
+
export function registerInteractiveHostSession(ctx) {
|
|
67
|
+
if (!SESSION_AGENTS.includes(ctx.agent))
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
upsertSession({
|
|
71
|
+
id: ctx.sessionId,
|
|
72
|
+
shortId: ctx.sessionId.slice(0, 8),
|
|
73
|
+
agent: ctx.agent,
|
|
74
|
+
timestamp: ctx.createdAt ?? new Date().toISOString(),
|
|
75
|
+
cwd: ctx.cwd,
|
|
76
|
+
filePath: '',
|
|
77
|
+
label: ctx.name || `[host/${ctx.host}]`,
|
|
78
|
+
}, '');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* index write is best-effort; the run is already live on the host */
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -56,6 +56,39 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
|
|
|
56
56
|
export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
|
|
57
57
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
58
58
|
export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
|
|
59
|
+
/**
|
|
60
|
+
* Whether a specific account can serve a run right now, and — when it can't —
|
|
61
|
+
* why. `signed_out` covers no-email / invalid-auth; `rate_limited` and
|
|
62
|
+
* `out_of_credits` name the throttle. Used to pre-warn on a version-pinned
|
|
63
|
+
* teammate whose account rotation won't route around (a pin IS the target).
|
|
64
|
+
*/
|
|
65
|
+
export type AccountReadiness = {
|
|
66
|
+
ready: true;
|
|
67
|
+
} | {
|
|
68
|
+
ready: false;
|
|
69
|
+
reason: 'rate_limited' | 'out_of_credits' | 'signed_out';
|
|
70
|
+
email: string | null;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
|
|
74
|
+
* + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
|
|
75
|
+
* disagree with what rotation would actually do. The `reason` combines the two
|
|
76
|
+
* signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
|
|
77
|
+
* rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
|
|
78
|
+
* snapshot never carries). When a live snapshot exists it wins over the cached
|
|
79
|
+
* status — matching the gate — so a stale `out_of_credits` cache is not
|
|
80
|
+
* reported while the account is actually serving requests.
|
|
81
|
+
*/
|
|
82
|
+
export declare function readinessFromCandidate(candidate: RotateCandidate): AccountReadiness;
|
|
83
|
+
/**
|
|
84
|
+
* Readiness for a specific installed (agent, version). Returns `{ ready: true }`
|
|
85
|
+
* when the version isn't among the collected candidates — absence is the
|
|
86
|
+
* caller's `isVersionInstalled` concern, not ours; don't cry wolf. Only
|
|
87
|
+
* meaningful for a version-pinned target: a bare target rotates to a healthy
|
|
88
|
+
* account on its own, and a profile injects its own auth (a different account
|
|
89
|
+
* than the version home carries), so neither is checkable here.
|
|
90
|
+
*/
|
|
91
|
+
export declare function checkRunAccountReadiness(agent: AgentId, version: string): Promise<AccountReadiness>;
|
|
59
92
|
/**
|
|
60
93
|
* Pick a healthy candidate using weighted random by remaining capacity.
|
|
61
94
|
*
|
package/dist/lib/rotate.js
CHANGED
|
@@ -92,6 +92,43 @@ function hasUsageAvailable(candidate) {
|
|
|
92
92
|
}
|
|
93
93
|
return true;
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
|
|
97
|
+
* + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
|
|
98
|
+
* disagree with what rotation would actually do. The `reason` combines the two
|
|
99
|
+
* signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
|
|
100
|
+
* rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
|
|
101
|
+
* snapshot never carries). When a live snapshot exists it wins over the cached
|
|
102
|
+
* status — matching the gate — so a stale `out_of_credits` cache is not
|
|
103
|
+
* reported while the account is actually serving requests.
|
|
104
|
+
*/
|
|
105
|
+
export function readinessFromCandidate(candidate) {
|
|
106
|
+
if (!candidate.email || !candidate.authValid) {
|
|
107
|
+
return { ready: false, reason: 'signed_out', email: candidate.email };
|
|
108
|
+
}
|
|
109
|
+
if (hasUsageAvailable(candidate)) {
|
|
110
|
+
return { ready: true };
|
|
111
|
+
}
|
|
112
|
+
const snap = candidate.usageSnapshot;
|
|
113
|
+
const snapRateLimited = !!snap && snap.windows.length > 0 && deriveUsageStatusFromSnapshot(snap) === 'rate_limited';
|
|
114
|
+
const reason = !snapRateLimited && candidate.usageStatus === 'out_of_credits' ? 'out_of_credits' : 'rate_limited';
|
|
115
|
+
return { ready: false, reason, email: candidate.email };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Readiness for a specific installed (agent, version). Returns `{ ready: true }`
|
|
119
|
+
* when the version isn't among the collected candidates — absence is the
|
|
120
|
+
* caller's `isVersionInstalled` concern, not ours; don't cry wolf. Only
|
|
121
|
+
* meaningful for a version-pinned target: a bare target rotates to a healthy
|
|
122
|
+
* account on its own, and a profile injects its own auth (a different account
|
|
123
|
+
* than the version home carries), so neither is checkable here.
|
|
124
|
+
*/
|
|
125
|
+
export async function checkRunAccountReadiness(agent, version) {
|
|
126
|
+
const candidates = await collectRunCandidates(agent);
|
|
127
|
+
const candidate = candidates.find((c) => c.version === version);
|
|
128
|
+
if (!candidate)
|
|
129
|
+
return { ready: true };
|
|
130
|
+
return readinessFromCandidate(candidate);
|
|
131
|
+
}
|
|
95
132
|
function getRoutingUsedPercent(snapshot) {
|
|
96
133
|
if (!snapshot || snapshot.windows.length === 0)
|
|
97
134
|
return null;
|
|
@@ -51,6 +51,20 @@ export declare function remoteSecretsRaw(target: string, args: string[], opts?:
|
|
|
51
51
|
tty?: boolean;
|
|
52
52
|
input?: string;
|
|
53
53
|
}): SshExecResult;
|
|
54
|
+
/**
|
|
55
|
+
* Run a remote `agents secrets <args>` FOREGROUND, with the local stdio wired
|
|
56
|
+
* straight through (`stdio: 'inherit'` + `-tt`), and return its exit code.
|
|
57
|
+
*
|
|
58
|
+
* Unlike `remoteSecretsRaw` — which pipes stdin, so even with `-tt` the remote
|
|
59
|
+
* process's `process.stdin.isTTY` is false and a passphrase prompt refuses to
|
|
60
|
+
* appear (the macOS file-store guard then hard-errors "needs
|
|
61
|
+
* AGENTS_SECRETS_PASSPHRASE") — this inherits the caller's real terminal, so the
|
|
62
|
+
* remote sees a genuine TTY and its hidden passphrase prompt surfaces and reads
|
|
63
|
+
* the keystrokes. This is the transport for `unlock --host`: you type the remote
|
|
64
|
+
* bundle's passphrase at your own terminal. Output is NOT captured (it streams
|
|
65
|
+
* to the terminal); only the exit code is returned.
|
|
66
|
+
*/
|
|
67
|
+
export declare function remoteSecretsStream(target: string, args: string[]): number;
|
|
54
68
|
/**
|
|
55
69
|
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
56
70
|
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
16
|
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
17
|
*/
|
|
18
|
-
import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
|
|
18
|
+
import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
20
20
|
import { emit } from '../events.js';
|
|
21
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
@@ -93,6 +93,23 @@ export function remoteSecretsRaw(target, args, opts = {}) {
|
|
|
93
93
|
extraSshArgs: opts.tty ? ['-tt'] : undefined,
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Run a remote `agents secrets <args>` FOREGROUND, with the local stdio wired
|
|
98
|
+
* straight through (`stdio: 'inherit'` + `-tt`), and return its exit code.
|
|
99
|
+
*
|
|
100
|
+
* Unlike `remoteSecretsRaw` — which pipes stdin, so even with `-tt` the remote
|
|
101
|
+
* process's `process.stdin.isTTY` is false and a passphrase prompt refuses to
|
|
102
|
+
* appear (the macOS file-store guard then hard-errors "needs
|
|
103
|
+
* AGENTS_SECRETS_PASSPHRASE") — this inherits the caller's real terminal, so the
|
|
104
|
+
* remote sees a genuine TTY and its hidden passphrase prompt surfaces and reads
|
|
105
|
+
* the keystrokes. This is the transport for `unlock --host`: you type the remote
|
|
106
|
+
* bundle's passphrase at your own terminal. Output is NOT captured (it streams
|
|
107
|
+
* to the terminal); only the exit code is returned.
|
|
108
|
+
*/
|
|
109
|
+
export function remoteSecretsStream(target, args) {
|
|
110
|
+
const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target));
|
|
111
|
+
return sshStream(target, remoteCmd, { tty: true });
|
|
112
|
+
}
|
|
96
113
|
/**
|
|
97
114
|
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
98
115
|
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
@@ -36,6 +36,10 @@ export interface ActiveSession {
|
|
|
36
36
|
worktree?: DetectedWorktree;
|
|
37
37
|
/** Tracker ticket the session is tied to. */
|
|
38
38
|
ticket?: DetectedTicket;
|
|
39
|
+
/** Tracker refs the session CREATED (Linear create_issue / gh issue create). */
|
|
40
|
+
createdTickets?: string[];
|
|
41
|
+
/** Team name the session SPAWNED via `agents teams create/add`. */
|
|
42
|
+
spawnedTeam?: string;
|
|
39
43
|
sessionFile?: string;
|
|
40
44
|
startedAtMs?: number;
|
|
41
45
|
status: ActiveStatus;
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -22,7 +22,6 @@ export interface SessionRow {
|
|
|
22
22
|
git_branch: string | null;
|
|
23
23
|
topic: string | null;
|
|
24
24
|
label: string | null;
|
|
25
|
-
name: string | null;
|
|
26
25
|
message_count: number | null;
|
|
27
26
|
token_count: number | null;
|
|
28
27
|
cost_usd: number | null;
|
|
@@ -130,15 +129,23 @@ export declare function upsertSessionsBatch(entries: Array<{
|
|
|
130
129
|
*/
|
|
131
130
|
export declare function syncLabels(labelMap: Map<string, string | null>): number;
|
|
132
131
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
132
|
+
* Seed session labels from `agents run --name` handles, keyed by session id.
|
|
133
|
+
*
|
|
134
|
+
* `--name` is the universal launch-time way to set a session's label — the same
|
|
135
|
+
* field an agent later refines with a generated title (`syncLabels`) or the user
|
|
136
|
+
* with `/rename`. The seed's source of truth lives outside the transcript (host
|
|
137
|
+
* task sidecars, run-name sidecars written at launch), so it is re-applied by id
|
|
138
|
+
* every scan rather than parsed per-file. It only fills a label that is still
|
|
139
|
+
* EMPTY — an agent-generated title always wins over the seed, so a Claude run's
|
|
140
|
+
* `--name` shows until Claude titles it, and a non-Claude run keeps its `--name`
|
|
141
|
+
* as the label. Writes both `sessions.label` and the FTS5 label column so a
|
|
142
|
+
* seeded name is fuzzy-searchable. Cheap to call every run; returns rows updated.
|
|
143
|
+
*
|
|
144
|
+
* Ordering matters: this runs AFTER the per-agent scans (which apply
|
|
145
|
+
* agent-generated titles via {@link syncLabels}), so it never overwrites a real
|
|
146
|
+
* title — it only backfills the gap the seed was meant to cover.
|
|
140
147
|
*/
|
|
141
|
-
export declare function
|
|
148
|
+
export declare function seedLabelsFromNames(nameMap: Map<string, string | null>): number;
|
|
142
149
|
/**
|
|
143
150
|
* Sync topics (session titles) for a set of sessions, keyed by id. For agents
|
|
144
151
|
* 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 = 10;
|
|
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,7 +52,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
52
52
|
git_branch TEXT,
|
|
53
53
|
topic TEXT,
|
|
54
54
|
label TEXT,
|
|
55
|
-
name TEXT,
|
|
56
55
|
message_count INTEGER,
|
|
57
56
|
token_count INTEGER,
|
|
58
57
|
cost_usd REAL,
|
|
@@ -198,6 +197,22 @@ function migrateSchema(db, fromVersion) {
|
|
|
198
197
|
if (!cols.some(c => c.name === 'name'))
|
|
199
198
|
db.exec(`ALTER TABLE sessions ADD COLUMN name TEXT`);
|
|
200
199
|
}
|
|
200
|
+
if (fromVersion < 10) {
|
|
201
|
+
// v9 → v10: `name` and `label` unify into a single `label`. `--name` now
|
|
202
|
+
// SEEDS the label at launch (refined later by an agent-generated title)
|
|
203
|
+
// instead of living in a separate immutable `name` column. Fold any existing
|
|
204
|
+
// name into label where the label is empty, mirror it into the FTS row, then
|
|
205
|
+
// drop the redundant column. Seeds re-apply from the run-name sidecars every
|
|
206
|
+
// scan (seedLabelsFromNames), so no rescan is required.
|
|
207
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
208
|
+
if (cols.some(c => c.name === 'name')) {
|
|
209
|
+
db.exec(`UPDATE sessions SET label = name
|
|
210
|
+
WHERE (label IS NULL OR label = '') AND name IS NOT NULL AND name != ''`);
|
|
211
|
+
db.exec(`UPDATE session_text SET label = COALESCE(
|
|
212
|
+
(SELECT label FROM sessions WHERE sessions.id = session_text.session_id), '')`);
|
|
213
|
+
db.exec(`ALTER TABLE sessions DROP COLUMN name`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
201
216
|
}
|
|
202
217
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
203
218
|
export function getDB() {
|
|
@@ -411,13 +426,13 @@ export function recordScans(entries) {
|
|
|
411
426
|
const upsertSessionStmt = (db) => db.prepare(`
|
|
412
427
|
INSERT INTO sessions (
|
|
413
428
|
id, short_id, agent, version, account, timestamp, last_activity,
|
|
414
|
-
project, cwd, git_branch, topic, label,
|
|
429
|
+
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
415
430
|
cost_usd, duration_ms,
|
|
416
431
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
417
432
|
pr_url, pr_number, worktree_slug, ticket_id
|
|
418
433
|
) VALUES (
|
|
419
434
|
@id, @short_id, @agent, @version, @account, @timestamp, @last_activity,
|
|
420
|
-
@project, @cwd, @git_branch, @topic, @label, @
|
|
435
|
+
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
421
436
|
@cost_usd, @duration_ms,
|
|
422
437
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
423
438
|
@pr_url, @pr_number, @worktree_slug, @ticket_id
|
|
@@ -481,7 +496,6 @@ export function upsertSession(meta, content, scan) {
|
|
|
481
496
|
git_branch: meta.gitBranch ?? null,
|
|
482
497
|
topic: meta.topic ?? null,
|
|
483
498
|
label: meta.label ?? null,
|
|
484
|
-
name: meta.name ?? null,
|
|
485
499
|
message_count: meta.messageCount ?? null,
|
|
486
500
|
token_count: meta.tokenCount ?? null,
|
|
487
501
|
cost_usd: meta.costUsd ?? null,
|
|
@@ -570,7 +584,6 @@ export function upsertSessionsBatch(entries) {
|
|
|
570
584
|
git_branch: meta.gitBranch ?? null,
|
|
571
585
|
topic: meta.topic ?? null,
|
|
572
586
|
label: meta.label ?? null,
|
|
573
|
-
name: meta.name ?? null,
|
|
574
587
|
message_count: meta.messageCount ?? null,
|
|
575
588
|
token_count: meta.tokenCount ?? null,
|
|
576
589
|
cost_usd: meta.costUsd ?? null,
|
|
@@ -639,15 +652,23 @@ export function syncLabels(labelMap) {
|
|
|
639
652
|
return updates.length;
|
|
640
653
|
}
|
|
641
654
|
/**
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
655
|
+
* Seed session labels from `agents run --name` handles, keyed by session id.
|
|
656
|
+
*
|
|
657
|
+
* `--name` is the universal launch-time way to set a session's label — the same
|
|
658
|
+
* field an agent later refines with a generated title (`syncLabels`) or the user
|
|
659
|
+
* with `/rename`. The seed's source of truth lives outside the transcript (host
|
|
660
|
+
* task sidecars, run-name sidecars written at launch), so it is re-applied by id
|
|
661
|
+
* every scan rather than parsed per-file. It only fills a label that is still
|
|
662
|
+
* EMPTY — an agent-generated title always wins over the seed, so a Claude run's
|
|
663
|
+
* `--name` shows until Claude titles it, and a non-Claude run keeps its `--name`
|
|
664
|
+
* as the label. Writes both `sessions.label` and the FTS5 label column so a
|
|
665
|
+
* seeded name is fuzzy-searchable. Cheap to call every run; returns rows updated.
|
|
666
|
+
*
|
|
667
|
+
* Ordering matters: this runs AFTER the per-agent scans (which apply
|
|
668
|
+
* agent-generated titles via {@link syncLabels}), so it never overwrites a real
|
|
669
|
+
* title — it only backfills the gap the seed was meant to cover.
|
|
649
670
|
*/
|
|
650
|
-
export function
|
|
671
|
+
export function seedLabelsFromNames(nameMap) {
|
|
651
672
|
if (nameMap.size === 0)
|
|
652
673
|
return 0;
|
|
653
674
|
const db = getDB();
|
|
@@ -658,21 +679,25 @@ export function syncNames(nameMap) {
|
|
|
658
679
|
const chunk = ids.slice(i, i + CHUNK);
|
|
659
680
|
const placeholders = chunk.map(() => '?').join(',');
|
|
660
681
|
const rows = db
|
|
661
|
-
.prepare(`SELECT id,
|
|
682
|
+
.prepare(`SELECT id, label FROM sessions WHERE id IN (${placeholders})`)
|
|
662
683
|
.all(...chunk);
|
|
663
684
|
for (const row of rows) {
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
685
|
+
const seed = nameMap.get(row.id);
|
|
686
|
+
// Only fill an empty label; a real agent title (non-empty) always wins.
|
|
687
|
+
if (seed && !(row.label ?? '').trim()) {
|
|
688
|
+
updates.push({ id: row.id, label: seed });
|
|
667
689
|
}
|
|
668
690
|
}
|
|
669
691
|
}
|
|
670
692
|
if (updates.length === 0)
|
|
671
693
|
return 0;
|
|
672
|
-
const
|
|
694
|
+
const updSessions = db.prepare(`UPDATE sessions SET label = ? WHERE id = ?`);
|
|
695
|
+
const updFts = db.prepare(`UPDATE session_text SET label = ? WHERE session_id = ?`);
|
|
673
696
|
const txn = db.transaction((items) => {
|
|
674
|
-
for (const { id,
|
|
675
|
-
|
|
697
|
+
for (const { id, label } of items) {
|
|
698
|
+
updSessions.run(label, id);
|
|
699
|
+
updFts.run(label, id);
|
|
700
|
+
}
|
|
676
701
|
});
|
|
677
702
|
txn(updates);
|
|
678
703
|
return updates.length;
|
|
@@ -739,7 +764,6 @@ function rowToMeta(row) {
|
|
|
739
764
|
account: row.account ?? undefined,
|
|
740
765
|
topic: row.topic ?? undefined,
|
|
741
766
|
label: row.label ?? undefined,
|
|
742
|
-
name: row.name ?? undefined,
|
|
743
767
|
isTeamOrigin: row.is_team_origin === 1,
|
|
744
768
|
prUrl: row.pr_url ?? undefined,
|
|
745
769
|
prNumber: row.pr_number ?? undefined,
|
|
@@ -1009,33 +1033,31 @@ export function ftsSearch(input, limit = 200) {
|
|
|
1009
1033
|
const seen = new Set();
|
|
1010
1034
|
const hits = [];
|
|
1011
1035
|
// Tier 1-3: handle-based matches, ordered by exactness. A session's handle is
|
|
1012
|
-
// its
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1036
|
+
// its `label` — set by an agent title / `/rename`, or seeded at launch from
|
|
1037
|
+
// `agents run --name`. Typing it resolves the session ahead of any FTS content
|
|
1038
|
+
// hit.
|
|
1015
1039
|
const labelRows = db.prepare(`
|
|
1016
|
-
SELECT id, label
|
|
1017
|
-
WHERE
|
|
1018
|
-
|
|
1019
|
-
`).all(`%${lower}%`, `%${lower}%`);
|
|
1040
|
+
SELECT id, label FROM sessions
|
|
1041
|
+
WHERE label IS NOT NULL AND LOWER(label) LIKE ?
|
|
1042
|
+
`).all(`%${lower}%`);
|
|
1020
1043
|
let hasExactLabelMatch = false;
|
|
1021
1044
|
for (const row of labelRows) {
|
|
1022
|
-
// Score
|
|
1045
|
+
// Score the label by match quality (exact > prefix > contains).
|
|
1023
1046
|
let score = 0;
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
continue;
|
|
1047
|
+
const handle = row.label;
|
|
1048
|
+
if (handle) {
|
|
1027
1049
|
const h = handle.toLowerCase();
|
|
1028
|
-
if (
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1050
|
+
if (h.includes(lower)) {
|
|
1051
|
+
if (h === lower) {
|
|
1052
|
+
score = 1_000_000;
|
|
1053
|
+
hasExactLabelMatch = true;
|
|
1054
|
+
}
|
|
1055
|
+
else if (h.startsWith(lower)) {
|
|
1056
|
+
score = 900_000;
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
score = 800_000;
|
|
1060
|
+
}
|
|
1039
1061
|
}
|
|
1040
1062
|
}
|
|
1041
1063
|
if (score === 0)
|
|
@@ -64,6 +64,10 @@ interface ClaudeSessionScan {
|
|
|
64
64
|
prNumber?: number;
|
|
65
65
|
worktreeSlug?: string;
|
|
66
66
|
ticketId?: string;
|
|
67
|
+
/** Tracker refs the session CREATED (Linear create_issue / gh issue create). */
|
|
68
|
+
createdTickets?: string[];
|
|
69
|
+
/** Team name this session SPAWNED via `agents teams create/add` (not team-of-origin). */
|
|
70
|
+
spawnedTeam?: string;
|
|
67
71
|
}
|
|
68
72
|
/**
|
|
69
73
|
* Discover sessions. Scans only files whose (mtime, size) have changed since
|
|
@@ -21,11 +21,11 @@ import { getConfigSymlinkVersion } from '../shims.js';
|
|
|
21
21
|
import { SESSION_AGENTS } from './types.js';
|
|
22
22
|
import { extractSessionTopic } from './prompt.js';
|
|
23
23
|
import { parseAntigravity } from './parse.js';
|
|
24
|
-
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from './state.js';
|
|
24
|
+
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket } from './state.js';
|
|
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,
|
|
28
|
+
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
29
29
|
import { buildRunNameMap } from './run-names.js';
|
|
30
30
|
const HOME = os.homedir();
|
|
31
31
|
// Versions can live under either repo: the user repo (current canonical
|
|
@@ -64,9 +64,11 @@ export async function discoverSessions(options) {
|
|
|
64
64
|
// reads to behavioral EDR (CrowdStrike Falcon) as a ransomware-style bulk
|
|
65
65
|
// file-enumeration sweep. Same dirs, same results — just not all at once.
|
|
66
66
|
await scanAgentsBounded(agents, agent => dispatchAgentScan(agent, onProgress));
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
67
|
+
// Seed labels from `agents run --name` handles onto the freshly-scanned
|
|
68
|
+
// rows by id. Runs AFTER the per-agent scans (which applied agent-generated
|
|
69
|
+
// titles via syncLabels), so a real title always wins and the seed only
|
|
70
|
+
// backfills sessions that would otherwise be unnamed.
|
|
71
|
+
seedLabelsFromNames(buildRunNameMap());
|
|
70
72
|
}
|
|
71
73
|
finally {
|
|
72
74
|
releaseScan(process.pid);
|
|
@@ -481,6 +483,8 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
481
483
|
prNumber: scan.prNumber,
|
|
482
484
|
worktreeSlug: scan.worktreeSlug,
|
|
483
485
|
ticketId: scan.ticketId,
|
|
486
|
+
createdTickets: scan.createdTickets,
|
|
487
|
+
spawnedTeam: scan.spawnedTeam,
|
|
484
488
|
};
|
|
485
489
|
}
|
|
486
490
|
else {
|
|
@@ -504,6 +508,8 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
504
508
|
prNumber: scan.prNumber,
|
|
505
509
|
worktreeSlug: scan.worktreeSlug,
|
|
506
510
|
ticketId: scan.ticketId,
|
|
511
|
+
createdTickets: scan.createdTickets,
|
|
512
|
+
spawnedTeam: scan.spawnedTeam,
|
|
507
513
|
};
|
|
508
514
|
}
|
|
509
515
|
return { meta, content: scan.contentText || '' };
|
|
@@ -715,6 +721,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
|
|
|
715
721
|
prNumber: scan.prNumber,
|
|
716
722
|
worktreeSlug: scan.worktreeSlug,
|
|
717
723
|
ticketId: scan.ticketId,
|
|
724
|
+
createdTickets: scan.createdTickets,
|
|
725
|
+
spawnedTeam: scan.spawnedTeam,
|
|
718
726
|
};
|
|
719
727
|
return { meta, content: scan.contentText || '' };
|
|
720
728
|
}
|
|
@@ -1756,6 +1764,12 @@ export async function scanClaudeSession(filePath) {
|
|
|
1756
1764
|
let sawPrCreate = false;
|
|
1757
1765
|
let prUrl;
|
|
1758
1766
|
let prNumber;
|
|
1767
|
+
// Artifacts the session PRODUCED: tracker refs it created and any team it spawned.
|
|
1768
|
+
// Ticket creation spans two events — a create_issue tool_use, then the tool_result
|
|
1769
|
+
// carrying the new id — so we hold the pending tool_use ids until their result lands.
|
|
1770
|
+
const createdTickets = new Set();
|
|
1771
|
+
const pendingTicketTools = new Set();
|
|
1772
|
+
let spawnedTeam;
|
|
1759
1773
|
try {
|
|
1760
1774
|
for await (const line of rl) {
|
|
1761
1775
|
if (!line.trim())
|
|
@@ -1772,6 +1786,39 @@ export async function scanClaudeSession(filePath) {
|
|
|
1772
1786
|
if (!entrypoint && typeof parsed.entrypoint === 'string') {
|
|
1773
1787
|
entrypoint = parsed.entrypoint;
|
|
1774
1788
|
}
|
|
1789
|
+
// Produced-artifact signals, structurally (independent of the PR gate below):
|
|
1790
|
+
// - a Bash `agents teams create/add` command → the team it spawned
|
|
1791
|
+
// - a Linear create_issue / `gh issue create` tool_use → its result carries
|
|
1792
|
+
// the new ticket ref, read from the matching tool_result.
|
|
1793
|
+
if (parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
|
|
1794
|
+
for (const b of parsed.message.content) {
|
|
1795
|
+
if (b?.type !== 'tool_use')
|
|
1796
|
+
continue;
|
|
1797
|
+
if (!spawnedTeam && typeof b?.input?.command === 'string') {
|
|
1798
|
+
const team = detectSpawnedTeam(b.input.command);
|
|
1799
|
+
if (team)
|
|
1800
|
+
spawnedTeam = team;
|
|
1801
|
+
}
|
|
1802
|
+
if (typeof b?.id === 'string' && isTicketCreateTool(b?.name, b?.input?.command)) {
|
|
1803
|
+
pendingTicketTools.add(b.id);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
if (pendingTicketTools.size > 0 && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
|
|
1808
|
+
for (const b of parsed.message.content) {
|
|
1809
|
+
if (b?.type !== 'tool_result' || typeof b?.tool_use_id !== 'string')
|
|
1810
|
+
continue;
|
|
1811
|
+
if (!pendingTicketTools.has(b.tool_use_id))
|
|
1812
|
+
continue;
|
|
1813
|
+
pendingTicketTools.delete(b.tool_use_id);
|
|
1814
|
+
const text = typeof b.content === 'string'
|
|
1815
|
+
? b.content
|
|
1816
|
+
: Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
|
|
1817
|
+
const t = extractCreatedTicket(text);
|
|
1818
|
+
if (t)
|
|
1819
|
+
createdTickets.add(t);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1775
1822
|
// PR signal, structurally: a Bash tool_use whose command is `gh pr create`
|
|
1776
1823
|
// marks intent; the pull URL is then read from a tool_result's output.
|
|
1777
1824
|
if (!prUrl) {
|
|
@@ -1900,6 +1947,8 @@ export async function scanClaudeSession(filePath) {
|
|
|
1900
1947
|
prNumber,
|
|
1901
1948
|
worktreeSlug: worktree?.slug,
|
|
1902
1949
|
ticketId: ticket?.id,
|
|
1950
|
+
createdTickets: createdTickets.size > 0 ? [...createdTickets] : undefined,
|
|
1951
|
+
spawnedTeam,
|
|
1903
1952
|
};
|
|
1904
1953
|
}
|
|
1905
1954
|
/** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
|
|
@@ -1922,6 +1971,10 @@ async function scanCodexSession(filePath) {
|
|
|
1922
1971
|
let sawPrCreate = false;
|
|
1923
1972
|
let prUrl;
|
|
1924
1973
|
let prNumber;
|
|
1974
|
+
// Produced artifacts (mirror of the Claude scan): created tracker refs + spawned team.
|
|
1975
|
+
const createdTickets = new Set();
|
|
1976
|
+
const pendingTicketTools = new Set();
|
|
1977
|
+
let spawnedTeam;
|
|
1925
1978
|
try {
|
|
1926
1979
|
for await (const line of rl) {
|
|
1927
1980
|
if (!line.trim())
|
|
@@ -1935,23 +1988,39 @@ async function scanCodexSession(filePath) {
|
|
|
1935
1988
|
}
|
|
1936
1989
|
// PR signal, structurally: a Codex `function_call` whose command is
|
|
1937
1990
|
// `gh pr create`, then the pull URL from a `function_call_output`.
|
|
1938
|
-
if (
|
|
1991
|
+
if (parsed.type === 'response_item') {
|
|
1939
1992
|
const p = parsed.payload || {};
|
|
1940
|
-
if (
|
|
1993
|
+
if (p.type === 'function_call') {
|
|
1941
1994
|
let cmd = '';
|
|
1942
1995
|
try {
|
|
1943
1996
|
const args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
|
|
1944
1997
|
cmd = String(args.command || args.cmd || '');
|
|
1945
1998
|
}
|
|
1946
1999
|
catch { /* non-JSON args */ }
|
|
1947
|
-
if (isPrCreateCommand(cmd))
|
|
2000
|
+
if (!prUrl && !sawPrCreate && isPrCreateCommand(cmd))
|
|
1948
2001
|
sawPrCreate = true;
|
|
2002
|
+
if (!spawnedTeam) {
|
|
2003
|
+
const team = detectSpawnedTeam(cmd);
|
|
2004
|
+
if (team)
|
|
2005
|
+
spawnedTeam = team;
|
|
2006
|
+
}
|
|
2007
|
+
if (typeof p.call_id === 'string' && isTicketCreateTool(p.name, cmd)) {
|
|
2008
|
+
pendingTicketTools.add(p.call_id);
|
|
2009
|
+
}
|
|
1949
2010
|
}
|
|
1950
|
-
if (
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2011
|
+
if (p.type === 'function_call_output') {
|
|
2012
|
+
if (!prUrl && sawPrCreate) {
|
|
2013
|
+
const pr = extractPrUrl(String(p.output || ''));
|
|
2014
|
+
if (pr) {
|
|
2015
|
+
prUrl = pr.url;
|
|
2016
|
+
prNumber = pr.number;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
if (typeof p.call_id === 'string' && pendingTicketTools.has(p.call_id)) {
|
|
2020
|
+
pendingTicketTools.delete(p.call_id);
|
|
2021
|
+
const t = extractCreatedTicket(String(p.output || ''));
|
|
2022
|
+
if (t)
|
|
2023
|
+
createdTickets.add(t);
|
|
1955
2024
|
}
|
|
1956
2025
|
}
|
|
1957
2026
|
}
|
|
@@ -2044,6 +2113,8 @@ async function scanCodexSession(filePath) {
|
|
|
2044
2113
|
prNumber,
|
|
2045
2114
|
worktreeSlug: worktree?.slug,
|
|
2046
2115
|
ticketId: ticket?.id,
|
|
2116
|
+
createdTickets: createdTickets.size > 0 ? [...createdTickets] : undefined,
|
|
2117
|
+
spawnedTeam,
|
|
2047
2118
|
};
|
|
2048
2119
|
}
|
|
2049
2120
|
/** Resolve the working directory for an OpenClaw agent from its workspace config. */
|