@phnx-labs/agents-cli 1.22.51 → 1.22.53

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.
Files changed (117) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/attach.js +7 -0
  5. package/dist/commands/browser.js +118 -56
  6. package/dist/commands/daemon.d.ts +2 -0
  7. package/dist/commands/daemon.js +8 -4
  8. package/dist/commands/detach.js +1 -1
  9. package/dist/commands/exec.js +16 -9
  10. package/dist/commands/fleet-capture.js +7 -0
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +16 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +65 -6
  15. package/dist/commands/monitors.js +1 -1
  16. package/dist/commands/repo.js +31 -3
  17. package/dist/commands/sessions-inject.js +8 -3
  18. package/dist/commands/sessions-picker.js +2 -1
  19. package/dist/commands/sessions-resume.d.ts +1 -0
  20. package/dist/commands/sessions-resume.js +13 -2
  21. package/dist/commands/sessions-stop.js +1 -1
  22. package/dist/commands/sessions.d.ts +23 -13
  23. package/dist/commands/sessions.js +69 -39
  24. package/dist/commands/setup-browser.d.ts +5 -2
  25. package/dist/commands/setup-browser.js +14 -29
  26. package/dist/commands/setup-preferences.d.ts +22 -3
  27. package/dist/commands/setup-preferences.js +25 -8
  28. package/dist/commands/share.js +12 -8
  29. package/dist/commands/ssh.js +35 -12
  30. package/dist/commands/status.js +5 -0
  31. package/dist/commands/sync.js +102 -2
  32. package/dist/commands/tmux.d.ts +8 -1
  33. package/dist/commands/tmux.js +167 -17
  34. package/dist/lib/account-registry.d.ts +15 -5
  35. package/dist/lib/account-registry.js +150 -50
  36. package/dist/lib/answer-router.js +2 -1
  37. package/dist/lib/browser/ipc.d.ts +44 -0
  38. package/dist/lib/browser/ipc.js +120 -8
  39. package/dist/lib/browser/profiles.d.ts +57 -17
  40. package/dist/lib/browser/profiles.js +77 -53
  41. package/dist/lib/browser/registry.d.ts +44 -14
  42. package/dist/lib/browser/registry.js +141 -45
  43. package/dist/lib/browser/runtime-state.d.ts +4 -2
  44. package/dist/lib/browser/runtime-state.js +4 -2
  45. package/dist/lib/browser/service.js +4 -3
  46. package/dist/lib/channels/owner-forward.d.ts +88 -0
  47. package/dist/lib/channels/owner-forward.js +116 -0
  48. package/dist/lib/channels/owner-sink.js +7 -0
  49. package/dist/lib/daemon/runner.js +10 -2
  50. package/dist/lib/device-config.js +3 -2
  51. package/dist/lib/devices/config-migration.js +147 -1
  52. package/dist/lib/devices/device-docs.d.ts +35 -0
  53. package/dist/lib/devices/device-docs.js +163 -0
  54. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  55. package/dist/lib/devices/discovery-policy.js +31 -21
  56. package/dist/lib/devices/registry.d.ts +11 -5
  57. package/dist/lib/devices/registry.js +46 -18
  58. package/dist/lib/exec.d.ts +66 -28
  59. package/dist/lib/exec.js +71 -26
  60. package/dist/lib/feed/feed.d.ts +10 -2
  61. package/dist/lib/feed/feed.js +12 -1
  62. package/dist/lib/feed-broadcast.js +15 -1
  63. package/dist/lib/git.d.ts +93 -0
  64. package/dist/lib/git.js +232 -0
  65. package/dist/lib/hosts/dispatch.d.ts +4 -3
  66. package/dist/lib/hosts/dispatch.js +12 -8
  67. package/dist/lib/hosts/providers/local.d.ts +9 -3
  68. package/dist/lib/hosts/providers/local.js +23 -12
  69. package/dist/lib/hosts/reconnect.d.ts +7 -4
  70. package/dist/lib/hosts/reconnect.js +29 -25
  71. package/dist/lib/hosts/registry.js +4 -1
  72. package/dist/lib/hosts/remote-os.js +3 -1
  73. package/dist/lib/monitors/remote.d.ts +18 -1
  74. package/dist/lib/monitors/remote.js +15 -2
  75. package/dist/lib/notify.d.ts +7 -0
  76. package/dist/lib/notify.js +15 -1
  77. package/dist/lib/session/active.d.ts +10 -1
  78. package/dist/lib/session/active.js +7 -1
  79. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  80. package/dist/lib/session/actor-sidecar.js +2 -0
  81. package/dist/lib/session/db.d.ts +1 -1
  82. package/dist/lib/session/db.js +39 -3
  83. package/dist/lib/session/discover.js +7 -12
  84. package/dist/lib/session/live-metadata.js +1 -0
  85. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  86. package/dist/lib/session/local-tmux-attach.js +164 -0
  87. package/dist/lib/session/pid-registry.d.ts +7 -0
  88. package/dist/lib/session/prompt.d.ts +15 -0
  89. package/dist/lib/session/prompt.js +21 -0
  90. package/dist/lib/session/remote-active.d.ts +8 -0
  91. package/dist/lib/session/remote-active.js +1 -0
  92. package/dist/lib/session/types.d.ts +17 -0
  93. package/dist/lib/session/types.js +10 -0
  94. package/dist/lib/share/publish.d.ts +8 -11
  95. package/dist/lib/share/publish.js +16 -20
  96. package/dist/lib/share/worker-template.js +104 -12
  97. package/dist/lib/state.d.ts +8 -0
  98. package/dist/lib/state.js +143 -11
  99. package/dist/lib/sync-status.d.ts +17 -0
  100. package/dist/lib/sync-status.js +21 -2
  101. package/dist/lib/terminal/resolve.d.ts +7 -0
  102. package/dist/lib/terminal/resolve.js +41 -2
  103. package/dist/lib/tmux/index.d.ts +1 -1
  104. package/dist/lib/tmux/index.js +1 -1
  105. package/dist/lib/tmux/session.d.ts +10 -0
  106. package/dist/lib/tmux/session.js +29 -0
  107. package/dist/lib/traces/insights.d.ts +67 -0
  108. package/dist/lib/traces/insights.js +178 -0
  109. package/dist/lib/traces/phenotype.d.ts +67 -0
  110. package/dist/lib/traces/phenotype.js +437 -0
  111. package/dist/lib/traces/segments.d.ts +133 -0
  112. package/dist/lib/traces/segments.js +301 -0
  113. package/dist/lib/traces/sync.d.ts +33 -0
  114. package/dist/lib/traces/sync.js +11 -2
  115. package/dist/lib/types.d.ts +47 -1
  116. package/dist/lib/watchdog/runner.js +18 -4
  117. package/package.json +1 -1
@@ -16,6 +16,7 @@
16
16
  * an agent's per-PR watcher is running state, not config, and syncing would push
17
17
  * every one of them onto every box — the accumulation this guard exists to stop.
18
18
  */
19
+ import { type GatherRemoteAgentsJsonDeps } from '../remote-agents-json.js';
19
20
  import type { MonitorConfig } from './config.js';
20
21
  /** Recursion guard: a peer answering the fan-out must not fan out again. */
21
22
  export declare const NO_MONITOR_FANOUT_ENV = "AGENTS_MONITORS_LOCAL";
@@ -39,9 +40,25 @@ export interface FleetMonitorsResult {
39
40
  * silently treating "we could not ask" as "there is no duplicate". */
40
41
  skipped: string[];
41
42
  }
43
+ export interface GatherFleetMonitorsOptions {
44
+ /** When supplied, the fan-out aborts as soon as any peer returns a monitor with
45
+ * this behavioral fingerprint. The miss path still waits for every peer so the
46
+ * guard can prove absence fleet-wide. */
47
+ againstFingerprint?: string;
48
+ /** Optional test seam for the SSH boundary; production uses the real capture. */
49
+ deps?: GatherRemoteAgentsJsonDeps;
50
+ /** Optional explicit host list; production omits it and asks the device registry. */
51
+ hosts?: string[];
52
+ }
42
53
  /**
43
54
  * Every monitor on every other registered device. Never throws: an unreachable
44
55
  * fleet degrades to an empty list plus the names we could not consult, and the
45
56
  * caller decides what to say about them.
57
+ *
58
+ * When {@link GatherFleetMonitorsOptions.againstFingerprint} is provided, a peer
59
+ * returning that fingerprint is a definitive clash: the remaining peers are
60
+ * SIGTERM'd immediately rather than burning the rest of the timeout budget.
61
+ * Absence of a clash still waits for the full fleet, because uniqueness is only
62
+ * knowable once every peer has answered.
46
63
  */
47
- export declare function gatherFleetMonitors(): Promise<FleetMonitorsResult>;
64
+ export declare function gatherFleetMonitors(options?: GatherFleetMonitorsOptions): Promise<FleetMonitorsResult>;
@@ -17,6 +17,7 @@
17
17
  * every one of them onto every box — the accumulation this guard exists to stop.
18
18
  */
19
19
  import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
20
+ import { monitorFingerprint } from './fingerprint.js';
20
21
  /** Recursion guard: a peer answering the fan-out must not fan out again. */
21
22
  export const NO_MONITOR_FANOUT_ENV = 'AGENTS_MONITORS_LOCAL';
22
23
  /**
@@ -58,15 +59,27 @@ export function parseRemoteMonitors(stdout, machine) {
58
59
  * Every monitor on every other registered device. Never throws: an unreachable
59
60
  * fleet degrades to an empty list plus the names we could not consult, and the
60
61
  * caller decides what to say about them.
62
+ *
63
+ * When {@link GatherFleetMonitorsOptions.againstFingerprint} is provided, a peer
64
+ * returning that fingerprint is a definitive clash: the remaining peers are
65
+ * SIGTERM'd immediately rather than burning the rest of the timeout budget.
66
+ * Absence of a clash still waits for the full fleet, because uniqueness is only
67
+ * knowable once every peer has answered.
61
68
  */
62
- export async function gatherFleetMonitors() {
69
+ export async function gatherFleetMonitors(options = {}) {
63
70
  try {
64
71
  const result = await gatherRemoteAgentsJson({
65
72
  args: ['monitors', 'list', '--json'],
66
73
  noFanoutEnv: NO_MONITOR_FANOUT_ENV,
74
+ hosts: options.hosts,
67
75
  parse: parseRemoteMonitors,
68
76
  quiet: true,
69
- });
77
+ earlyExit: options.againstFingerprint
78
+ ? {
79
+ isDefinitive: (item) => monitorFingerprint(item.monitor) === options.againstFingerprint,
80
+ }
81
+ : undefined,
82
+ }, options.deps);
70
83
  return {
71
84
  monitors: result.items,
72
85
  skipped: [...result.skipped, ...result.parseFailed],
@@ -48,6 +48,13 @@ export declare function buildOpenClawNotifyArgs(text: string, opts: {
48
48
  * selects the provider per host. A missing owner config or a delivery failure
49
49
  * (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
50
50
  * ENOENT — so callers surface a consistent, best-effort failure.
51
+ *
52
+ * When local delivery fails because THIS box structurally cannot reach the owner
53
+ * — the rush-backed owner channel is macOS-only, so a headless Linux worker can
54
+ * never ring the phone (PHNX-3303) — the notify is forwarded over SSH to a
55
+ * capable fleet peer that DOES have the provider, mirroring the reroute
56
+ * `agents message` already uses. A successful forward is returned as the result;
57
+ * if no capable peer is reachable, the original clean local error stands.
51
58
  */
52
59
  export declare function sendToOwner(text: string, options?: OwnerNotifyOptions): Promise<SendResult>;
53
60
  export declare function notifyUrgentBlock(block: OpenBlock, options?: OwnerNotifyOptions): Promise<NotifyResult>;
@@ -2,6 +2,7 @@ import { readMeta } from './state.js';
2
2
  import { getOwnerNotifyFromHumans } from './humans.js';
3
3
  import { registerBuiltinProviders } from './channels/providers/index.js';
4
4
  import { lookupTransport } from './channels/resolve.js';
5
+ import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
5
6
  export function formatUrgentBlockMessage(block) {
6
7
  const q = block.questions[0];
7
8
  const header = q?.header ? `[${q.header}] ` : '';
@@ -38,6 +39,13 @@ export function buildOpenClawNotifyArgs(text, opts) {
38
39
  * selects the provider per host. A missing owner config or a delivery failure
39
40
  * (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
40
41
  * ENOENT — so callers surface a consistent, best-effort failure.
42
+ *
43
+ * When local delivery fails because THIS box structurally cannot reach the owner
44
+ * — the rush-backed owner channel is macOS-only, so a headless Linux worker can
45
+ * never ring the phone (PHNX-3303) — the notify is forwarded over SSH to a
46
+ * capable fleet peer that DOES have the provider, mirroring the reroute
47
+ * `agents message` already uses. A successful forward is returned as the result;
48
+ * if no capable peer is reachable, the original clean local error stands.
41
49
  */
42
50
  export async function sendToOwner(text, options = {}) {
43
51
  const meta = options.meta ?? readMeta();
@@ -57,11 +65,17 @@ export async function sendToOwner(text, options = {}) {
57
65
  if (!provider) {
58
66
  return { ok: false, channel, id: target, error };
59
67
  }
60
- return provider.send(text, {
68
+ const local = await provider.send(text, {
61
69
  target,
62
70
  ownerScoped: options.target === undefined,
63
71
  dryRun: options.dryRun,
64
72
  });
73
+ // A dry-run never delivers, and an override target is an explicit recipient
74
+ // (not the fleet-wide owner) — neither should hop to a peer.
75
+ if (local.ok || options.dryRun || options.target !== undefined)
76
+ return local;
77
+ const forwarded = await forwardOwnerNotifyToPeer(text, channel, meta);
78
+ return forwarded ?? local;
65
79
  }
66
80
  export async function notifyUrgentBlock(block, options = {}) {
67
81
  if (block.notifiedAt) {
@@ -62,7 +62,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
62
62
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
63
63
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
64
64
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
65
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName'>;
65
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
66
66
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
67
67
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
68
68
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -112,6 +112,13 @@ export type RecapSource = 'label' | 'last' | 'prompt';
112
112
  export interface ActiveSession {
113
113
  context: ActiveContext;
114
114
  kind: string;
115
+ /**
116
+ * Custom harness / profile name when this live process was launched via
117
+ * `agents run <profile>` (e.g. `deepseek`). `kind` stays the HOST process
118
+ * (claude) so transcript lookup and live-signal parsers keep working.
119
+ * `sessions --active` displays this when set (PHNX-2935).
120
+ */
121
+ harness?: string;
115
122
  /** Specific host app — 'code', 'cursor', 'codium', 'iterm', 'terminal', 'warp', 'tmux', etc. */
116
123
  host?: string;
117
124
  pid?: number;
@@ -652,6 +659,8 @@ export declare function listUnattributedActive(attributed: Set<number>): Promise
652
659
  /** One tmux pane's resolved agent identity for the authoritative source. */
653
660
  interface PaneIdentity {
654
661
  agent: string;
662
+ /** Custom harness/profile name from the launch registry, when set. */
663
+ harness?: string;
655
664
  /** Exact session id when resolvable (launch registry, or the hook join). */
656
665
  sessionId?: string;
657
666
  /** The agent's OS pid from the launch registry (may differ from `pane_pid`). */
@@ -166,6 +166,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
166
166
  s.origin = m.origin;
167
167
  if (!s.routineName && m.routineName)
168
168
  s.routineName = m.routineName;
169
+ if (!s.harness && m.harness)
170
+ s.harness = m.harness;
169
171
  }
170
172
  }
171
173
  function loadBackfillMetaFor(sessions) {
@@ -1010,6 +1012,7 @@ export async function listTeamsActive(opts = {}) {
1010
1012
  return applyState({
1011
1013
  context: 'teams',
1012
1014
  kind: a.agentType,
1015
+ harness: a.profileName ?? undefined,
1013
1016
  pid: a.pid ?? undefined,
1014
1017
  sessionId: resolvedId,
1015
1018
  machine: offloaded ? execHost : undefined,
@@ -1071,6 +1074,7 @@ export async function listTerminalsActive() {
1071
1074
  return applyState({
1072
1075
  context: 'terminal',
1073
1076
  kind: t.kind,
1077
+ harness: pidEntry?.harness,
1074
1078
  host: detectHost(t.pid, procByPid),
1075
1079
  tty: procByPid.get(t.pid)?.tty,
1076
1080
  pid: t.pid,
@@ -1557,6 +1561,7 @@ async function listUnattributedActiveLive(attributed) {
1557
1561
  out.push(applyState({
1558
1562
  context,
1559
1563
  kind,
1564
+ harness: entry?.harness,
1560
1565
  host,
1561
1566
  tty: procByPid.get(pid)?.tty,
1562
1567
  pid,
@@ -1621,7 +1626,7 @@ export function resolvePaneIdentity(pane, sessName, meta, liveEntry, getHookInde
1621
1626
  terminalId: liveEntry.terminalId,
1622
1627
  })?.session_id
1623
1628
  ?? nameSessionId;
1624
- return { agent: liveEntry.agent, sessionId, pid: liveEntry.pid };
1629
+ return { agent: liveEntry.agent, harness: liveEntry.harness, sessionId, pid: liveEntry.pid };
1625
1630
  }
1626
1631
  // No live-registry entry. Session-meta labels are the wrapped-origin fallback;
1627
1632
  // prefer them, then fall back to the name so a pane with neither a registry
@@ -1771,6 +1776,7 @@ export async function listTmuxAgentSessions() {
1771
1776
  out.push(applyState({
1772
1777
  context: 'terminal',
1773
1778
  kind: id.agent,
1779
+ harness: id.harness,
1774
1780
  host: 'tmux',
1775
1781
  pid,
1776
1782
  sessionId: id.sessionId ?? sessionIdFromFile(sessionFile),
@@ -7,6 +7,13 @@ export interface SessionActorRecord {
7
7
  initiatedBy?: 'human' | 'agent';
8
8
  /** Effective permissions mode used by the launcher. */
9
9
  mode?: SessionRunMode;
10
+ /**
11
+ * Custom harness / profile name when launched via `agents run <profile>`
12
+ * (e.g. `deepseek`). Joined onto the session index at scan time so a
13
+ * durable listing can distinguish the profile from its host agent
14
+ * (PHNX-2935).
15
+ */
16
+ harness?: string;
10
17
  /** Stable wrapper names that resolve to this native session id. */
11
18
  aliases?: string[];
12
19
  startedAtMs: number;
@@ -40,6 +40,7 @@ function isSafeAlias(alias) {
40
40
  function hasRecordData(record) {
41
41
  return typeof record.actor === 'string'
42
42
  || typeof record.mode === 'string'
43
+ || typeof record.harness === 'string'
43
44
  || (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
44
45
  }
45
46
  function normalizedAliases(aliases) {
@@ -83,6 +84,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
83
84
  actor: previous?.actor,
84
85
  initiatedBy: previous?.initiatedBy,
85
86
  mode: previous?.mode,
87
+ harness: previous?.harness,
86
88
  aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
87
89
  startedAtMs: previous?.startedAtMs ?? Date.now(),
88
90
  });
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 40;
15
+ export declare const SCHEMA_VERSION = 41;
16
16
  /**
17
17
  * Bumping this invalidates every cached facet row without touching the schema
18
18
  * version, so a change to the extraction logic (a new metric, a corrected bucket)
@@ -27,7 +27,7 @@ const DB_PATH = getSessionsDbPath();
27
27
  /** Current schema version; bumped when migrations are added. Exported so tests
28
28
  * assert against the constant instead of hardcoding a number that every bump
29
29
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
30
- export const SCHEMA_VERSION = 40;
30
+ export const SCHEMA_VERSION = 41;
31
31
  /**
32
32
  * Bump to force `agents sessions backfill resources` to re-derive every
33
33
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS sessions (
64
64
  id TEXT PRIMARY KEY,
65
65
  short_id TEXT NOT NULL,
66
66
  agent TEXT NOT NULL,
67
+ harness TEXT,
67
68
  origin TEXT DEFAULT 'cli',
68
69
  routine_name TEXT,
69
70
  routine_run_id TEXT,
@@ -1072,6 +1073,18 @@ function migrateSchema(db, fromVersion) {
1072
1073
  db.exec(`ALTER TABLE sessions ADD COLUMN background_shell_count INTEGER`);
1073
1074
  }
1074
1075
  }
1076
+ if (fromVersion < 41) {
1077
+ // v40 -> v41: persist the custom-harness / profile name a run was launched
1078
+ // as (PHNX-2935). Transcript discovery still keys `agent` on the HOST
1079
+ // (the file lives under the host's session dir), so without this column
1080
+ // `agents sessions` cannot tell `agents run deepseek` from a native claude
1081
+ // run. Additive, no ledger flush — the name is launch metadata joined from
1082
+ // the actor sidecar, not parsed from the transcript. Pre-upgrade rows stay
1083
+ // NULL (native / unknown) until a sidecar-backed rescan fills them.
1084
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1085
+ if (!cols.has('harness'))
1086
+ db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
1087
+ }
1075
1088
  }
1076
1089
  /**
1077
1090
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -1194,6 +1207,21 @@ export function getDB() {
1194
1207
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
1195
1208
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
1196
1209
  }
1210
+ // harness column: only after the column is guaranteed present.
1211
+ // Fresh SCHEMA (v41) includes the column; older DBs get it from migrate v41.
1212
+ // schema_version can be stamped at SCHEMA_VERSION without the column existing
1213
+ // — getDB writes the marker for any DB whose meta has no row (a hand-built
1214
+ // or partially-created index), and migrateSchema never runs in that path
1215
+ // (`currentVersion === undefined`). If a partial upgrade left schema_version
1216
+ // ahead of the column, repair here so the next upsertSession INSERT naming
1217
+ // `harness` does not throw (PHNX-2935). Same shape as the `machine` repair
1218
+ // above, for the same reason.
1219
+ {
1220
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
1221
+ if (!cols.some((c) => c.name === 'harness')) {
1222
+ db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
1223
+ }
1224
+ }
1197
1225
  // One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
1198
1226
  // them anymore. Guarded by a meta flag so we only try once.
1199
1227
  const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
@@ -1583,7 +1611,7 @@ export function recordDirScans(entries) {
1583
1611
  }
1584
1612
  const upsertSessionStmt = (db) => db.prepare(`
1585
1613
  INSERT INTO sessions (
1586
- id, short_id, agent, origin, routine_name, routine_run_id,
1614
+ id, short_id, agent, harness, origin, routine_name, routine_run_id,
1587
1615
  version, account, account_key, account_org, mode, timestamp, last_activity,
1588
1616
  project, cwd, git_branch, topic, label, message_count, token_count,
1589
1617
  output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
@@ -1594,7 +1622,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1594
1622
  recent_directories_touched, linear_project, linear_project_url, machine,
1595
1623
  actor, initiated_by, used_browser, used_computer
1596
1624
  ) VALUES (
1597
- @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
1625
+ @id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
1598
1626
  @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1599
1627
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
1600
1628
  @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
@@ -1608,6 +1636,11 @@ const upsertSessionStmt = (db) => db.prepare(`
1608
1636
  ON CONFLICT(id) DO UPDATE SET
1609
1637
  short_id = excluded.short_id,
1610
1638
  agent = excluded.agent,
1639
+ -- Custom harness/profile name is launch metadata, not transcript-derived.
1640
+ -- COALESCE(existing, incoming) keeps a stored stamp on rescan (the scanner
1641
+ -- carries none) and backfills a NULL-first row once the sidecar lands —
1642
+ -- the same write-once pattern as actor/initiated_by (PHNX-2935).
1643
+ harness = COALESCE(sessions.harness, excluded.harness),
1611
1644
  origin = excluded.origin,
1612
1645
  routine_name = excluded.routine_name,
1613
1646
  routine_run_id = excluded.routine_run_id,
@@ -1886,6 +1919,7 @@ export function upsertSession(meta, content, scan) {
1886
1919
  id: meta.id,
1887
1920
  short_id: meta.shortId,
1888
1921
  agent: meta.agent,
1922
+ harness: meta.harness ?? actorRec?.harness ?? null,
1889
1923
  origin: meta.origin ?? 'cli',
1890
1924
  routine_name: meta.routineName ?? null,
1891
1925
  routine_run_id: meta.routineRunId ?? null,
@@ -2081,6 +2115,7 @@ export function upsertSessionsBatch(entries) {
2081
2115
  id: meta.id,
2082
2116
  short_id: meta.shortId,
2083
2117
  agent: meta.agent,
2118
+ harness: meta.harness ?? actorIndex.get(meta.id)?.harness ?? null,
2084
2119
  origin: meta.origin ?? 'cli',
2085
2120
  routine_name: meta.routineName ?? null,
2086
2121
  routine_run_id: meta.routineRunId ?? null,
@@ -2309,6 +2344,7 @@ function rowToMeta(row) {
2309
2344
  id: row.id,
2310
2345
  shortId: row.short_id,
2311
2346
  agent: row.agent,
2347
+ harness: row.harness ?? undefined,
2312
2348
  origin: (row.origin === 'routine' ? 'routine' : 'cli'),
2313
2349
  routineName: row.routine_name ?? undefined,
2314
2350
  routineRunId: row.routine_run_id ?? undefined,
@@ -26,7 +26,7 @@ import { getConfigSymlinkVersion } from '../installations/shims.js';
26
26
  import { SESSION_AGENTS } from './types.js';
27
27
  import { deriveShortId } from './short-id.js';
28
28
  import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
29
- import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput, classifyUserPrompt } from './prompt.js';
29
+ import { extractSessionTopic, extractSlashCommandName, extractSlashCommandFromToolInput, cleanGeneratedSessionLabel } from './prompt.js';
30
30
  import { isBackgroundShellStart, isSkillInvocation, extractSkills, extractSlashCommands, isSubAgentTool } from './highlights.js';
31
31
  import { parseAntigravity, parseCursor, splitSessionFilePath } from './parse.js';
32
32
  import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
@@ -3268,15 +3268,10 @@ export function finalizeClaudeScan(state) {
3268
3268
  // A topic is the first meaningful prompt. Harness-owned names travel in the
3269
3269
  // separate label field so consumers can replace an early topic once Claude's
3270
3270
  // generated title (or a later `/rename`) arrives.
3271
- // Claude's generated `ai-title` is derived from the first turn, so a session
3272
- // opened with a skill gets named after the injected "Base directory for this
3273
- // skill: …" line instead of its task — and that name then wins on every
3274
- // surface for the session's whole life. `classifyUserPrompt` reports `skill`
3275
- // only for that injected line, so collapse it to `/<skill>`. A `/rename`
3276
- // (`custom-title`) is the user's own words and is never rewritten.
3277
- const generated = classifyUserPrompt(state.aiTitle ?? '');
3278
- const label = state.customTitle
3279
- || (generated.kind === 'skill' ? generated.clean : state.aiTitle);
3271
+ // Generated titles (Claude `ai-title`, Cursor `chatMeta.title`) go through
3272
+ // one shared cleaner so a skill-preamble echo collapses to `/<skill>`. A
3273
+ // `/rename` (`custom-title`) is the user's own words and is never rewritten.
3274
+ const label = state.customTitle || cleanGeneratedSessionLabel(state.aiTitle);
3280
3275
  const worktree = detectWorktree(state.cwd, state.gitBranch);
3281
3276
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3282
3277
  return {
@@ -4391,8 +4386,8 @@ export function readCursorMeta(filePath, currentVersion) {
4391
4386
  .filter((event) => event.type === 'message' && event.role === 'user' && event.content)
4392
4387
  .map((event) => event.content);
4393
4388
  const firstUserText = userTexts[0];
4394
- const title = typeof chatMeta?.title === 'string' && chatMeta.title.trim()
4395
- ? chatMeta.title.trim()
4389
+ const title = typeof chatMeta?.title === 'string'
4390
+ ? cleanGeneratedSessionLabel(chatMeta.title)
4396
4391
  : undefined;
4397
4392
  const meta = {
4398
4393
  id: sessionId,
@@ -37,6 +37,7 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
37
37
  id,
38
38
  shortId: deriveShortId(id),
39
39
  agent: active.kind,
40
+ harness: active.harness,
40
41
  timestamp: new Date(startedMs).toISOString(),
41
42
  lastActivity: new Date(lastMs).toISOString(),
42
43
  // An empty file path is honest — a just-created session may have no
@@ -0,0 +1,69 @@
1
+ export type TmuxAliasState = 'not-an-alias' | 'no-server' | 'absent' | 'dead' | 'live';
2
+ /** Shape of the tmux alias the CLI mints for an agent session: `ag-<agent>-<shortid>`.
3
+ * Delegates to the one canonical matcher beside the name parsers in active.ts. */
4
+ export declare function looksLikeTmuxAlias(selector: string): boolean;
5
+ /**
6
+ * Classify a selector against the live tmux server. Split from the attach so the
7
+ * decision is testable against a real tmux server without replacing the caller's
8
+ * shell (attaching is not something a test can undo).
9
+ */
10
+ export declare function resolveTmuxAliasState(selector: string, socket?: string): Promise<TmuxAliasState>;
11
+ /**
12
+ * A local `ag-<agent>-<8hex>` alias names a pane on THIS box. Attach it
13
+ * without any fleet SSH. `--device` keeps the sweep: the caller scoped
14
+ * identity to another machine.
15
+ */
16
+ export declare function shouldAttachLocalTmuxAliasBeforeFleet(selector: string | undefined, hosts: string[]): selector is string;
17
+ /**
18
+ * Attach a live tmux session named exactly as the selector, without needing the
19
+ * session index to know anything about it.
20
+ *
21
+ * SES-41 requires a `ag-<agent>-<8hex>` tmux alias to resolve, but the alias's
22
+ * hex is the LAUNCH id, not the harness session id, and for a harness that
23
+ * writes no `state/sessions/<pid>.json` record there is no mapping back to a
24
+ * SessionMeta at all. The pane is the thing the user asked for, and its NAME is
25
+ * sufficient to attach it — so an unattributable session is still reachable
26
+ * instead of being a dead end that forces raw `tmux -S … attach`.
27
+ *
28
+ * Returns false when this is not an alias, the server/session is absent, or
29
+ * every pane is dead — the caller then continues to normal id resolution.
30
+ * SES-39's "re-read `pane_dead` immediately before attach" is honoured here:
31
+ * liveness is queried at attach time, not read from a roster.
32
+ */
33
+ export declare function attachLiveTmuxAlias(selector: string): Promise<boolean>;
34
+ export type LocalAliasBySuffix = {
35
+ kind: 'alias';
36
+ alias: string;
37
+ } | {
38
+ kind: 'collision';
39
+ aliases: string[];
40
+ } | {
41
+ kind: 'none';
42
+ };
43
+ /**
44
+ * Resolve a bare 8-hex selector (`agents tmux ls`'s hex column, typed without
45
+ * the `ag-<agent>-` prefix) against LIVE local panes only. Dead panes matching
46
+ * the suffix are excluded before the uniqueness check — a retained, exited pane
47
+ * must never compete with (or block) a live one for the same short id.
48
+ *
49
+ * Exported for direct testing: `attachLocalLiveSelector` composes this with a
50
+ * real `attachTmux()` call, which a unit test cannot safely invoke (it takes
51
+ * over the terminal).
52
+ */
53
+ export declare function resolveUniqueLocalLiveAliasBySuffix(shortId: string, socket?: string): Promise<LocalAliasBySuffix>;
54
+ /**
55
+ * The full local gate (PHNX-3292 rule 1): a selector that is either a live
56
+ * local tmux alias, or a bare 8-hex short id that names exactly one live local
57
+ * pane, attaches immediately — zero SSH. `--device`/`hosts` scopes identity to
58
+ * another machine and disables this gate entirely (rule 4).
59
+ *
60
+ * Two LIVE local panes matching the same 8-hex suffix fail closed (rule 5): a
61
+ * collision is reported with both names rather than guessing, and the caller
62
+ * must not fall through to fleet resolution for a selector that is genuinely
63
+ * ambiguous ON THIS BOX.
64
+ *
65
+ * Returns `false` (never attached, never reported) when the selector does not
66
+ * name a live local pane at all, so the caller can continue to session-id
67
+ * resolution / the fleet race.
68
+ */
69
+ export declare function attachLocalLiveSelector(selector: string | undefined, hosts: string[]): Promise<boolean>;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Attach a LIVE tmux pane on THIS box, with zero SSH — the local half of the
3
+ * PHNX-3292 product rule: "first unique match wins, local before fleet."
4
+ *
5
+ * `agents tmux ls` prints `ag-<agent>-<8hex>` names. A selector that already
6
+ * names one of those panes (the full alias, or its bare 8-hex suffix when it
7
+ * is unique among LIVE local panes) is attached directly — no session index
8
+ * lookup, no fleet SSH fan-out. `sessions resume`, the deprecated `sessions
9
+ * attach`, and `sessions focus` all take this gate before anything else.
10
+ *
11
+ * Lives outside `commands/` (not in focus.ts) so `sessions-resume.ts` and
12
+ * `attach.ts` can both import it without a focus.ts <-> sessions-resume.ts
13
+ * import cycle (focus.ts already imports from sessions-resume.ts).
14
+ */
15
+ import fs from 'node:fs';
16
+ import chalk from 'chalk';
17
+ import { attachTmux, ensureSessionHookRepaired, getDefaultSocketPath, hasSession, listSessions, runTmux, teardownIfAgentExited } from '../tmux/index.js';
18
+ import { isAgentTmuxAlias } from './types.js';
19
+ /** Width of `SessionMeta.shortId` / the hex an `ag-<agent>-<8hex>` alias embeds. */
20
+ const SHORT_SESSION_ID_RE = /^[0-9a-f]{8}$/i;
21
+ /** Shape of the tmux alias the CLI mints for an agent session: `ag-<agent>-<shortid>`.
22
+ * Delegates to the one canonical matcher beside the name parsers in active.ts. */
23
+ export function looksLikeTmuxAlias(selector) {
24
+ return isAgentTmuxAlias(selector);
25
+ }
26
+ /**
27
+ * Classify a selector against the live tmux server. Split from the attach so the
28
+ * decision is testable against a real tmux server without replacing the caller's
29
+ * shell (attaching is not something a test can undo).
30
+ */
31
+ export async function resolveTmuxAliasState(selector, socket) {
32
+ if (!looksLikeTmuxAlias(selector))
33
+ return 'not-an-alias';
34
+ const sock = socket ?? getDefaultSocketPath();
35
+ if (!fs.existsSync(sock))
36
+ return 'no-server';
37
+ if (!(await hasSession(selector, sock).catch(() => false)))
38
+ return 'absent';
39
+ const panes = await runTmux({
40
+ socket: sock,
41
+ args: ['list-panes', '-t', `=${selector}`, '-F', '#{pane_dead}'],
42
+ }).catch(() => undefined);
43
+ const states = (panes?.stdout ?? '').split('\n').map(s => s.trim()).filter(Boolean);
44
+ return states.some(d => d === '0') ? 'live' : 'dead';
45
+ }
46
+ /**
47
+ * A local `ag-<agent>-<8hex>` alias names a pane on THIS box. Attach it
48
+ * without any fleet SSH. `--device` keeps the sweep: the caller scoped
49
+ * identity to another machine.
50
+ */
51
+ export function shouldAttachLocalTmuxAliasBeforeFleet(selector, hosts) {
52
+ return !!selector && hosts.length === 0 && isAgentTmuxAlias(selector);
53
+ }
54
+ /**
55
+ * Attach a live tmux session named exactly as the selector, without needing the
56
+ * session index to know anything about it.
57
+ *
58
+ * SES-41 requires a `ag-<agent>-<8hex>` tmux alias to resolve, but the alias's
59
+ * hex is the LAUNCH id, not the harness session id, and for a harness that
60
+ * writes no `state/sessions/<pid>.json` record there is no mapping back to a
61
+ * SessionMeta at all. The pane is the thing the user asked for, and its NAME is
62
+ * sufficient to attach it — so an unattributable session is still reachable
63
+ * instead of being a dead end that forces raw `tmux -S … attach`.
64
+ *
65
+ * Returns false when this is not an alias, the server/session is absent, or
66
+ * every pane is dead — the caller then continues to normal id resolution.
67
+ * SES-39's "re-read `pane_dead` immediately before attach" is honoured here:
68
+ * liveness is queried at attach time, not read from a roster.
69
+ */
70
+ export async function attachLiveTmuxAlias(selector) {
71
+ const socket = getDefaultSocketPath();
72
+ if (await resolveTmuxAliasState(selector, socket) !== 'live')
73
+ return false;
74
+ if (!process.stdout.isTTY) {
75
+ console.error(chalk.red(`"${selector}" is a live tmux session, but attaching needs a TTY.`));
76
+ console.error(chalk.gray(` Run it from a terminal, or: agents tmux attach ${selector}`));
77
+ process.exitCode = 1;
78
+ return true;
79
+ }
80
+ // Already inside a tmux client on this socket — which is the normal state for
81
+ // an interactive agent session here — MOVE that client instead of nesting a
82
+ // second one. jumpTo (go.ts) does the same; attaching from inside tmux
83
+ // otherwise stacks clients rather than taking you to the session.
84
+ if (process.env.TMUX) {
85
+ await runTmux({ socket, args: ['switch-client', '-t', `=${selector}`], throwOnError: false }).catch(() => { });
86
+ console.log(chalk.gray(`Switched this tmux client to ${selector}.`));
87
+ return true;
88
+ }
89
+ console.log(chalk.gray(`Attaching ${selector} — Ctrl-b d to detach.`));
90
+ // Repair a legacy/stale pane-died hook before handing the session to an
91
+ // attach client — the 5-min daemon reconcile that used to cover this was
92
+ // deleted; attach-time repair is what closes the gap now (RUSH-2435).
93
+ await ensureSessionHookRepaired(selector, socket);
94
+ const code = await attachTmux({ socket, args: ['attach-session', '-t', `=${selector}`] });
95
+ await teardownIfAgentExited(selector, socket);
96
+ process.exitCode = code;
97
+ return true;
98
+ }
99
+ /**
100
+ * Resolve a bare 8-hex selector (`agents tmux ls`'s hex column, typed without
101
+ * the `ag-<agent>-` prefix) against LIVE local panes only. Dead panes matching
102
+ * the suffix are excluded before the uniqueness check — a retained, exited pane
103
+ * must never compete with (or block) a live one for the same short id.
104
+ *
105
+ * Exported for direct testing: `attachLocalLiveSelector` composes this with a
106
+ * real `attachTmux()` call, which a unit test cannot safely invoke (it takes
107
+ * over the terminal).
108
+ */
109
+ export async function resolveUniqueLocalLiveAliasBySuffix(shortId, socket) {
110
+ const sock = socket ?? getDefaultSocketPath();
111
+ if (!fs.existsSync(sock))
112
+ return { kind: 'none' };
113
+ let sessions;
114
+ try {
115
+ sessions = await listSessions({ socket: sock });
116
+ }
117
+ catch {
118
+ return { kind: 'none' };
119
+ }
120
+ const named = sessions
121
+ .map((session) => session.name)
122
+ .filter((name) => isAgentTmuxAlias(name) && name.toLowerCase().endsWith(shortId.toLowerCase()));
123
+ if (named.length === 0)
124
+ return { kind: 'none' };
125
+ const states = await Promise.all(named.map(async (name) => ({ name, state: await resolveTmuxAliasState(name, sock) })));
126
+ const live = states.filter((entry) => entry.state === 'live').map((entry) => entry.name);
127
+ if (live.length === 0)
128
+ return { kind: 'none' };
129
+ if (live.length > 1)
130
+ return { kind: 'collision', aliases: live };
131
+ return { kind: 'alias', alias: live[0] };
132
+ }
133
+ /**
134
+ * The full local gate (PHNX-3292 rule 1): a selector that is either a live
135
+ * local tmux alias, or a bare 8-hex short id that names exactly one live local
136
+ * pane, attaches immediately — zero SSH. `--device`/`hosts` scopes identity to
137
+ * another machine and disables this gate entirely (rule 4).
138
+ *
139
+ * Two LIVE local panes matching the same 8-hex suffix fail closed (rule 5): a
140
+ * collision is reported with both names rather than guessing, and the caller
141
+ * must not fall through to fleet resolution for a selector that is genuinely
142
+ * ambiguous ON THIS BOX.
143
+ *
144
+ * Returns `false` (never attached, never reported) when the selector does not
145
+ * name a live local pane at all, so the caller can continue to session-id
146
+ * resolution / the fleet race.
147
+ */
148
+ export async function attachLocalLiveSelector(selector, hosts) {
149
+ if (shouldAttachLocalTmuxAliasBeforeFleet(selector, hosts)) {
150
+ return attachLiveTmuxAlias(selector);
151
+ }
152
+ if (!selector || hosts.length > 0 || !SHORT_SESSION_ID_RE.test(selector))
153
+ return false;
154
+ const found = await resolveUniqueLocalLiveAliasBySuffix(selector);
155
+ if (found.kind === 'none')
156
+ return false;
157
+ if (found.kind === 'collision') {
158
+ console.error(chalk.red(`"${selector}" matches ${found.aliases.length} live local panes: ${found.aliases.join(', ')}`));
159
+ console.error(chalk.gray(' Pass the full alias to disambiguate — see: agents tmux ls'));
160
+ process.exitCode = 1;
161
+ return true;
162
+ }
163
+ return attachLiveTmuxAlias(found.alias);
164
+ }