@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.
@@ -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: `[host/${task.host}]`,
39
- name: task.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
+ }
@@ -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
  *
@@ -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;
@@ -241,6 +241,8 @@ function applyState(base, state, fallbackFile) {
241
241
  pr: state.pr,
242
242
  worktree: state.worktree,
243
243
  ticket: state.ticket,
244
+ createdTickets: state.createdTickets,
245
+ spawnedTeam: state.spawnedTeam,
244
246
  };
245
247
  }
246
248
  /**
@@ -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
- * 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.
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 syncNames(nameMap: Map<string, string | null>): number;
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
@@ -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 = 9;
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, name, message_count, token_count,
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, @name, @message_count, @token_count,
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
- * 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.
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 syncNames(nameMap) {
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, name FROM sessions WHERE id IN (${placeholders})`)
682
+ .prepare(`SELECT id, label FROM sessions WHERE id IN (${placeholders})`)
662
683
  .all(...chunk);
663
684
  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 });
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 upd = db.prepare(`UPDATE sessions SET name = ? WHERE id = ?`);
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, name } of items)
675
- upd.run(name, id);
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 /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.
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, 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}%`);
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 against whichever handle matches best (exact > prefix > contains).
1045
+ // Score the label by match quality (exact > prefix > contains).
1023
1046
  let score = 0;
1024
- for (const handle of [row.label, row.name]) {
1025
- if (!handle)
1026
- continue;
1047
+ const handle = row.label;
1048
+ if (handle) {
1027
1049
  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);
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, syncNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
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
- // 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());
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 (!prUrl && parsed.type === 'response_item') {
1991
+ if (parsed.type === 'response_item') {
1939
1992
  const p = parsed.payload || {};
1940
- if (!sawPrCreate && p.type === 'function_call') {
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 (sawPrCreate && p.type === 'function_call_output') {
1951
- const pr = extractPrUrl(String(p.output || ''));
1952
- if (pr) {
1953
- prUrl = pr.url;
1954
- prNumber = pr.number;
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. */