@phnx-labs/agents-cli 1.22.52 → 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 (69) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/exec.js +16 -9
  5. package/dist/commands/fleet-capture.js +7 -0
  6. package/dist/commands/focus.js +2 -0
  7. package/dist/commands/go.js +2 -1
  8. package/dist/commands/sessions-inject.js +8 -3
  9. package/dist/commands/sessions-picker.js +2 -1
  10. package/dist/commands/sessions.js +30 -20
  11. package/dist/commands/ssh.js +35 -12
  12. package/dist/commands/sync.js +44 -0
  13. package/dist/lib/account-registry.d.ts +15 -5
  14. package/dist/lib/account-registry.js +150 -50
  15. package/dist/lib/answer-router.js +2 -1
  16. package/dist/lib/browser/profiles.d.ts +18 -0
  17. package/dist/lib/browser/profiles.js +26 -1
  18. package/dist/lib/browser/registry.d.ts +44 -14
  19. package/dist/lib/browser/registry.js +141 -45
  20. package/dist/lib/daemon/runner.js +10 -2
  21. package/dist/lib/device-config.js +3 -2
  22. package/dist/lib/devices/config-migration.js +147 -1
  23. package/dist/lib/devices/device-docs.d.ts +35 -0
  24. package/dist/lib/devices/device-docs.js +163 -0
  25. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  26. package/dist/lib/devices/discovery-policy.js +31 -21
  27. package/dist/lib/devices/registry.d.ts +11 -5
  28. package/dist/lib/devices/registry.js +46 -18
  29. package/dist/lib/exec.d.ts +60 -30
  30. package/dist/lib/exec.js +65 -27
  31. package/dist/lib/feed/feed.d.ts +10 -2
  32. package/dist/lib/feed/feed.js +12 -1
  33. package/dist/lib/hosts/dispatch.d.ts +4 -3
  34. package/dist/lib/hosts/dispatch.js +12 -8
  35. package/dist/lib/hosts/providers/local.d.ts +9 -3
  36. package/dist/lib/hosts/providers/local.js +23 -12
  37. package/dist/lib/hosts/reconnect.d.ts +7 -4
  38. package/dist/lib/hosts/reconnect.js +29 -25
  39. package/dist/lib/hosts/registry.js +4 -1
  40. package/dist/lib/hosts/remote-os.js +3 -1
  41. package/dist/lib/session/active.d.ts +10 -1
  42. package/dist/lib/session/active.js +7 -1
  43. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  44. package/dist/lib/session/actor-sidecar.js +2 -0
  45. package/dist/lib/session/db.d.ts +1 -1
  46. package/dist/lib/session/db.js +39 -3
  47. package/dist/lib/session/discover.js +7 -12
  48. package/dist/lib/session/live-metadata.js +1 -0
  49. package/dist/lib/session/pid-registry.d.ts +7 -0
  50. package/dist/lib/session/prompt.d.ts +15 -0
  51. package/dist/lib/session/prompt.js +21 -0
  52. package/dist/lib/session/types.d.ts +17 -0
  53. package/dist/lib/session/types.js +10 -0
  54. package/dist/lib/share/worker-template.js +12 -7
  55. package/dist/lib/state.d.ts +8 -0
  56. package/dist/lib/state.js +143 -11
  57. package/dist/lib/terminal/resolve.d.ts +7 -0
  58. package/dist/lib/terminal/resolve.js +41 -2
  59. package/dist/lib/traces/insights.d.ts +67 -0
  60. package/dist/lib/traces/insights.js +178 -0
  61. package/dist/lib/traces/phenotype.d.ts +67 -0
  62. package/dist/lib/traces/phenotype.js +437 -0
  63. package/dist/lib/traces/segments.d.ts +133 -0
  64. package/dist/lib/traces/segments.js +301 -0
  65. package/dist/lib/traces/sync.d.ts +33 -0
  66. package/dist/lib/traces/sync.js +11 -2
  67. package/dist/lib/types.d.ts +47 -1
  68. package/dist/lib/watchdog/runner.js +18 -4
  69. package/package.json +1 -1
@@ -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
@@ -1,6 +1,13 @@
1
1
  export interface PidSessionEntry {
2
2
  pid: number;
3
3
  agent: string;
4
+ /**
5
+ * Custom harness / profile name when this pid was launched via
6
+ * `agents run <profile>` (e.g. `deepseek`). `agent` stays the HOST
7
+ * CLI (`claude`) so process matching and transcript discovery keep
8
+ * working. `sessions --active` displays this when set (PHNX-2935).
9
+ */
10
+ harness?: string;
4
11
  /** The launch session id. Present for agents launched with a known id (Claude). */
5
12
  sessionId?: string;
6
13
  cwd?: string;
@@ -69,5 +69,20 @@ export interface ClassifiedPrompt {
69
69
  export declare function classifyUserPrompt(raw: string | undefined, opts?: {
70
70
  hasImageAttachment?: boolean;
71
71
  }): ClassifiedPrompt;
72
+ /**
73
+ * Collapse a harness-generated session title when it is injected skill
74
+ * scaffolding; otherwise leave the title as the harness wrote it.
75
+ *
76
+ * Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
77
+ * first turn, so a session opened with a skill gets named after the injected
78
+ * "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
79
+ * `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
80
+ * whitespace-only input yields `undefined` so the caller falls through to the
81
+ * first-prompt topic.
82
+ *
83
+ * A user-authored title (Claude `/rename` / `custom-title`) is never passed
84
+ * here — the caller keeps it verbatim.
85
+ */
86
+ export declare function cleanGeneratedSessionLabel(title: string | undefined): string | undefined;
72
87
  /** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
73
88
  export declare function extractSessionTopic(raw: string): string | undefined;
@@ -201,6 +201,27 @@ export function classifyUserPrompt(raw, opts = {}) {
201
201
  const display = (cleanedFirst || firstLine).replace(IMAGE_PATH_RE, '[image]').trim();
202
202
  return { clean: display, kind: 'text' };
203
203
  }
204
+ /**
205
+ * Collapse a harness-generated session title when it is injected skill
206
+ * scaffolding; otherwise leave the title as the harness wrote it.
207
+ *
208
+ * Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
209
+ * first turn, so a session opened with a skill gets named after the injected
210
+ * "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
211
+ * `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
212
+ * whitespace-only input yields `undefined` so the caller falls through to the
213
+ * first-prompt topic.
214
+ *
215
+ * A user-authored title (Claude `/rename` / `custom-title`) is never passed
216
+ * here — the caller keeps it verbatim.
217
+ */
218
+ export function cleanGeneratedSessionLabel(title) {
219
+ const trimmed = title?.trim();
220
+ if (!trimmed)
221
+ return undefined;
222
+ const classified = classifyUserPrompt(trimmed);
223
+ return classified.kind === 'skill' ? classified.clean : trimmed;
224
+ }
204
225
  /** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
205
226
  export function extractSessionTopic(raw) {
206
227
  if (!raw.trim())
@@ -31,6 +31,16 @@ export declare const AG_TMUX_NAME_RE: RegExp;
31
31
  /** Whether `name` is an `ag-<agent>-<shortid>` tmux session name. */
32
32
  export declare function isAgentTmuxAlias(name: string): boolean;
33
33
  export declare function isSessionTrackedAgent(agent: string): agent is SessionAgentId;
34
+ /**
35
+ * The name `agents sessions` shows for a run. A custom harness launched via a
36
+ * profile (`agents run deepseek`) keeps its host agent for transcript discovery
37
+ * and parsing (`claude`) and stamps the profile name on `harness`. Display
38
+ * prefers that stamp so a deepseek run is not listed as claude (PHNX-2935).
39
+ */
40
+ export declare function sessionDisplayAgent(session: {
41
+ agent: string;
42
+ harness?: string | null;
43
+ }): string;
34
44
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
35
45
  export interface SessionEvent {
36
46
  type: 'message' | 'tool_use' | 'tool_result' | 'thinking' | 'error' | 'init' | 'result' | 'usage' | 'attachment' | 'hook' | 'interrupt';
@@ -154,6 +164,13 @@ export interface SessionMeta {
154
164
  id: string;
155
165
  shortId: string;
156
166
  agent: SessionAgentId;
167
+ /**
168
+ * Custom harness / profile name when this session was launched via
169
+ * `agents run <profile>` (e.g. `deepseek`). `agent` stays the HOST
170
+ * harness that produced the transcript (`claude`, …) so discovery and
171
+ * parsing keep working. Display surfaces this when set (PHNX-2935).
172
+ */
173
+ harness?: string;
157
174
  /** Where the indexed transcript came from. Routine rows are archived from a run directory. */
158
175
  origin?: 'cli' | 'routine';
159
176
  /** Routine name for transcripts archived from ~/.agents/.history/runs/<name>/<runId>/. */
@@ -31,3 +31,13 @@ export function isAgentTmuxAlias(name) {
31
31
  export function isSessionTrackedAgent(agent) {
32
32
  return SESSION_AGENTS.includes(agent);
33
33
  }
34
+ /**
35
+ * The name `agents sessions` shows for a run. A custom harness launched via a
36
+ * profile (`agents run deepseek`) keeps its host agent for transcript discovery
37
+ * and parsing (`claude`) and stamps the profile name on `harness`. Display
38
+ * prefers that stamp so a deepseek run is not listed as claude (PHNX-2935).
39
+ */
40
+ export function sessionDisplayAgent(session) {
41
+ const harness = session.harness?.trim();
42
+ return harness || session.agent;
43
+ }
@@ -518,20 +518,25 @@ function renderAttributionBar(meta, handle) {
518
518
  // page's own CSS can never wash the bar out (inline beats a page stylesheet);
519
519
  // the rest is a namespaced style block with !important on every colour so a
520
520
  // broad page rule (span selector to black, etc.) cannot make the text vanish.
521
+ // FIXED, full-viewport-width chrome (not sticky-inside-body — that inherited the
522
+ // page's max-width/margins and rendered as a floating box). html padding-top
523
+ // pushes the whole page down by the bar's height; flex-wrap:nowrap + ellipsis on
524
+ // the left keeps the bar exactly one line tall so the push height stays correct.
521
525
  return '<div class="agents-share-bar" role="contentinfo" aria-label="Sharing details" ' +
522
526
  'style="background:#0b0b0c !important;color:#e8e8e8 !important">' +
523
527
  '<style>' +
524
- '.agents-share-bar{all:initial;position:sticky;top:0;left:0;right:0;z-index:2147483647;box-sizing:border-box;display:flex !important;align-items:center;gap:12px;flex-wrap:wrap;' +
525
- 'padding:8px 16px !important;background:#0b0b0c !important;color:#e8e8e8 !important;border-bottom:1px solid #24242b !important;' +
526
- 'font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
528
+ 'html{padding-top:38px !important}' +
529
+ '.agents-share-bar{all:initial;position:fixed !important;top:0 !important;left:0 !important;right:0 !important;width:100% !important;z-index:2147483647 !important;box-sizing:border-box;display:flex !important;align-items:center;gap:12px;flex-wrap:nowrap;' +
530
+ 'padding:8px 16px !important;background:#0b0b0c !important;color:#e8e8e8 !important;border-bottom:1px solid #23232a !important;box-shadow:0 1px 3px rgba(0,0,0,.35) !important;' +
531
+ 'font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif !important}' +
527
532
  '.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
528
- '.agents-share-bar .ash-left{display:flex;align-items:center;gap:8px;min-width:0;color:#cfd3d9 !important}' +
533
+ '.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
529
534
  '.agents-share-bar strong{color:#fff !important;font-weight:600}' +
530
- '.agents-share-bar .ash-dot{opacity:.4;margin:0 3px}' +
531
- '.agents-share-bar .ash-right{margin-left:auto;display:flex;align-items:center;gap:12px}' +
535
+ '.agents-share-bar .ash-dot{opacity:.4;margin:0 4px}' +
536
+ '.agents-share-bar .ash-right{flex:none;display:flex;align-items:center;gap:12px}' +
532
537
  '.agents-share-bar .ash-date{color:#9096a0 !important}' +
533
538
  '.agents-share-bar .ash-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;border:1px solid;font-weight:600;font-size:12px;white-space:nowrap}' +
534
- '.agents-share-bar .ash-chip svg{flex:none}' +
539
+ '.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
535
540
  '</style>' +
536
541
  '<span class="ash-left">' + left + '</span>' +
537
542
  '<span class="ash-right">' + right + '</span>' +
@@ -391,6 +391,14 @@ export declare function getDeviceMetaPath(): string;
391
391
  */
392
392
  export declare function getVersionResourcesPath(): string;
393
393
  export declare function withMetaLock<T>(fn: () => T): T;
394
+ /**
395
+ * Write `meta` to disk (central + device docs + pins) WITHOUT taking the meta
396
+ * lock — the caller must already hold it via {@link withMetaLock}. Exported so a
397
+ * writer that needs to read fresh state, decide, and commit within a SINGLE lock
398
+ * acquisition (e.g. browser tombstone eviction) can do so without the
399
+ * read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
400
+ */
401
+ export declare function writeMetaUnlocked(meta: Meta): void;
394
402
  /**
395
403
  * Read and cache ~/.agents/agents.yaml, migrating from legacy locations if needed.
396
404
  *
package/dist/lib/state.js CHANGED
@@ -876,6 +876,9 @@ const META_KEY_SCOPE = {
876
876
  deviceRoutines: 'device',
877
877
  deviceConfig: 'device',
878
878
  deviceBrowser: 'device',
879
+ deviceFleet: 'device',
880
+ deviceHosts: 'device',
881
+ deviceAccounts: 'device',
879
882
  // Central — synced via agents.yaml.
880
883
  accounts: 'central',
881
884
  run: 'central',
@@ -915,6 +918,35 @@ const KNOWN_META_KEYS = new Set([
915
918
  // first registry read can migrate it into this device's file and delete it.
916
919
  'browser',
917
920
  ]);
921
+ /**
922
+ * Rewrite a frozen `agents.yaml` header to the current {@link META_HEADER}.
923
+ *
924
+ * `serializeCentral` parses the existing file to preserve its hand-written body
925
+ * comments, but that also preserves the leading metadata header verbatim, so a
926
+ * top-level file written before the agi-cli rename (or before the `$schema` line
927
+ * existed) keeps its stale header forever — every freshly-written device doc gets
928
+ * the current header while the shared file is left behind (PHNX-3315).
929
+ *
930
+ * The header is healed TEXTUALLY, on the already-serialized string, rather than
931
+ * via `doc.commentBefore`: the `yaml` library folds the whole leading comment
932
+ * block onto the FIRST key's `commentBefore` when that key already carries a
933
+ * hand-written comment, so the header is not reliably the document comment — but
934
+ * it is always the top block of the output. Strip a leading `agents-cli metadata`
935
+ * header (any pre-rename variant, with or without the `$schema` line — the URL
936
+ * and schema lines are matched specifically so a hand-written body comment is
937
+ * never mistaken for a header line) and prepend the canonical header. A file with
938
+ * no recognizable header simply gains one. Body comments, which sit below the
939
+ * blank line that terminates the header, are untouched.
940
+ */
941
+ function healMetaHeader(serialized) {
942
+ const headerBlock = /^# agents-cli metadata\n# Auto-generated - do not edit manually\n(?:# (?:https:\/\/github\.com\/phnx-labs\/[^\n]*|yaml-language-server: \$schema=[^\n]*)\n)*\n?/;
943
+ const stripped = serialized.replace(headerBlock, '');
944
+ // No metadata header present (replace was a no-op) → leave the file exactly as
945
+ // it is. We heal a STALE header; we never prepend one to a file that never had
946
+ // it (that would rewrite a hand-authored, headerless central file on the first
947
+ // real central change). A current header round-trips to the identical bytes.
948
+ return stripped === serialized ? serialized : META_HEADER + stripped;
949
+ }
918
950
  /**
919
951
  * Serialize the central (synced) meta to `agents.yaml` WITHOUT destroying the
920
952
  * hand-written comments in the committed file.
@@ -973,20 +1005,37 @@ function serializeCentral(central) {
973
1005
  }
974
1006
  }
975
1007
  // No central field changed → keep the file byte-identical (comments intact), so
976
- // writeIfChanged skips it and the churn loop never starts.
1008
+ // writeIfChanged skips it and the churn loop never starts. A device-only write
1009
+ // (pins/routines/etc. routed elsewhere) reaches here with changed=false and
1010
+ // MUST NOT rewrite the shared file — header healing waits for a genuine central
1011
+ // change below rather than dirtying agents.yaml on an unrelated write, which is
1012
+ // the very churn that wedges `agents sync` and blocks fleet pulls.
977
1013
  if (!changed)
978
1014
  return existing;
979
- // Everything cleared → header only (never leave a flow `{}` behind).
980
- // Otherwise stringifyDoc: it still normalizes a legacy flow root (`{}`) to
981
- // block, so edited nodes do not render flow (`disabledCommands: [ teams ]`
982
- // instead of a `- teams` block list), but it no longer forces block on a
983
- // normal document — that flattened committed flow sequences and made this
984
- // writer disagree with feed.ts/activity.ts/migrate.ts on the same file
985
- // (RUSH-2505). parseDocument still preserves comments + key ordering.
986
- return isEmpty ? META_HEADER : stringifyDoc(doc);
1015
+ // Everything cleared → header only (never leave a flow `{}` behind). Byte-stable
1016
+ // when the file is already exactly the current header.
1017
+ if (isEmpty)
1018
+ return existing === META_HEADER ? existing : META_HEADER;
1019
+ // A central key changed: serialize the edited doc and heal a frozen header on
1020
+ // the result. stringifyDoc still normalizes a legacy flow root (`{}`) to block,
1021
+ // so edited nodes do not render flow (`disabledCommands: [ teams ]` instead of a
1022
+ // `- teams` block list), but it no longer forces block on a normal document —
1023
+ // that flattened committed flow sequences and made this writer disagree with
1024
+ // feed.ts/activity.ts/migrate.ts on the same file (RUSH-2505). parseDocument
1025
+ // still preserves body comments + key ordering; healMetaHeader is a no-op unless
1026
+ // a stale metadata header is actually present, so a headerless central file is
1027
+ // updated in place without gaining one.
1028
+ return healMetaHeader(stringifyDoc(doc));
987
1029
  }
988
- function writeMetaUnlocked(meta) {
989
- const { agents, isolatedAgents, versions, deviceRoutines, deviceConfig, deviceBrowser, projectRoot, ...central } = meta;
1030
+ /**
1031
+ * Write `meta` to disk (central + device docs + pins) WITHOUT taking the meta
1032
+ * lock — the caller must already hold it via {@link withMetaLock}. Exported so a
1033
+ * writer that needs to read fresh state, decide, and commit within a SINGLE lock
1034
+ * acquisition (e.g. browser tombstone eviction) can do so without the
1035
+ * read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
1036
+ */
1037
+ export function writeMetaUnlocked(meta) {
1038
+ const { agents, isolatedAgents, versions, deviceRoutines, deviceConfig, deviceBrowser, deviceFleet, deviceHosts, deviceAccounts, projectRoot, ...central } = meta;
990
1039
  // Write the machine-local files FIRST, then strip central — so a crash mid-write
991
1040
  // never removes pins/versions from central before they're persisted elsewhere.
992
1041
  const hasAgents = !!agents && Object.keys(agents).length > 0;
@@ -1049,6 +1098,43 @@ function writeMetaUnlocked(meta) {
1049
1098
  doc.browser = deviceBrowser;
1050
1099
  else
1051
1100
  delete doc.browser;
1101
+ // PHNX-3315 device-scoped fleet/hosts/accounts blocks. Each is this box's OWN
1102
+ // slice; the effective fleet view is unioned across every device doc at read
1103
+ // time (lib/devices/device-docs.ts). Empty slices are dropped so a box that
1104
+ // has made no decision leaves no key behind (no committed empty maps).
1105
+ const fleetDiscovery = deviceFleet?.discovery && Object.keys(deviceFleet.discovery).length > 0
1106
+ ? deviceFleet.discovery : undefined;
1107
+ const fleetIgnored = deviceFleet?.ignored && deviceFleet.ignored.length > 0
1108
+ ? deviceFleet.ignored : undefined;
1109
+ if (fleetDiscovery || fleetIgnored) {
1110
+ const df = {};
1111
+ if (fleetDiscovery)
1112
+ df.discovery = fleetDiscovery;
1113
+ if (fleetIgnored)
1114
+ df.ignored = fleetIgnored;
1115
+ doc.fleet = df;
1116
+ }
1117
+ else
1118
+ delete doc.fleet;
1119
+ const hasDeviceHosts = !!deviceHosts && Object.keys(deviceHosts).length > 0;
1120
+ if (hasDeviceHosts)
1121
+ doc.hosts = deviceHosts;
1122
+ else
1123
+ delete doc.hosts;
1124
+ const accountsNative = deviceAccounts?.native && Object.keys(deviceAccounts.native).length > 0
1125
+ ? deviceAccounts.native : undefined;
1126
+ const accountsBindings = deviceAccounts?.bindings && Object.keys(deviceAccounts.bindings).length > 0
1127
+ ? deviceAccounts.bindings : undefined;
1128
+ if (accountsNative || accountsBindings) {
1129
+ const da = {};
1130
+ if (accountsNative)
1131
+ da.native = accountsNative;
1132
+ if (accountsBindings)
1133
+ da.bindings = accountsBindings;
1134
+ doc.accounts = da;
1135
+ }
1136
+ else
1137
+ delete doc.accounts;
1052
1138
  const hasProjectRoot = typeof projectRoot === 'string' && projectRoot.length > 0;
1053
1139
  if (hasProjectRoot)
1054
1140
  doc.projectRoot = projectRoot;
@@ -1121,6 +1207,52 @@ function overlayMachineLocal(meta) {
1121
1207
  if (dm?.config && typeof dm.config === 'object' && !Array.isArray(dm.config)) {
1122
1208
  meta.deviceConfig = { ...meta.deviceConfig, ...dm.config };
1123
1209
  }
1210
+ // PHNX-3315: this box's own device-scoped fleet/hosts/accounts slices.
1211
+ // These populate the `device*` keys the writers read-modify-write; the
1212
+ // effective UNION across all boxes is computed separately by
1213
+ // lib/devices/device-docs.ts, not here (this overlay is this box only).
1214
+ // A malformed block on THIS box's own doc is a HARD error, exactly like
1215
+ // `routines` below and the cross-box union readers (device-docs.ts): a
1216
+ // silent drop would let the next writeMetaUnlocked round-trip overwrite the
1217
+ // whole block with just the new entry, discarding the rest on this box's
1218
+ // tracked file (PHNX-3315).
1219
+ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
1220
+ const dmRaw = dm;
1221
+ if (dmRaw.fleet !== undefined) {
1222
+ if (!isMap(dmRaw.fleet))
1223
+ throw new Error(`Device config corrupted at ${devicePath}: fleet must be a map.`);
1224
+ const df = dmRaw.fleet;
1225
+ if (df.discovery !== undefined && !isMap(df.discovery)) {
1226
+ throw new Error(`Device config corrupted at ${devicePath}: fleet.discovery must be a map.`);
1227
+ }
1228
+ if (df.ignored !== undefined && !Array.isArray(df.ignored)) {
1229
+ throw new Error(`Device config corrupted at ${devicePath}: fleet.ignored must be a list.`);
1230
+ }
1231
+ const discovery = df.discovery;
1232
+ const ignored = df.ignored;
1233
+ if (discovery || ignored)
1234
+ meta.deviceFleet = { ...(discovery ? { discovery } : {}), ...(ignored ? { ignored } : {}) };
1235
+ }
1236
+ if (dmRaw.hosts !== undefined) {
1237
+ if (!isMap(dmRaw.hosts))
1238
+ throw new Error(`Device config corrupted at ${devicePath}: hosts must be a map.`);
1239
+ meta.deviceHosts = { ...meta.deviceHosts, ...dmRaw.hosts };
1240
+ }
1241
+ if (dmRaw.accounts !== undefined) {
1242
+ if (!isMap(dmRaw.accounts))
1243
+ throw new Error(`Device config corrupted at ${devicePath}: accounts must be a map.`);
1244
+ const acc = dmRaw.accounts;
1245
+ if (acc.native !== undefined && !isMap(acc.native)) {
1246
+ throw new Error(`Device config corrupted at ${devicePath}: accounts.native must be a map.`);
1247
+ }
1248
+ if (acc.bindings !== undefined && !isMap(acc.bindings)) {
1249
+ throw new Error(`Device config corrupted at ${devicePath}: accounts.bindings must be a map.`);
1250
+ }
1251
+ const native = acc.native;
1252
+ const bindings = acc.bindings;
1253
+ if (native || bindings)
1254
+ meta.deviceAccounts = { ...(native ? { native } : {}), ...(bindings ? { bindings } : {}) };
1255
+ }
1124
1256
  if (Object.prototype.hasOwnProperty.call(dm, 'routines')) {
1125
1257
  if (!Array.isArray(dm.routines) || dm.routines.some((name) => typeof name !== 'string')) {
1126
1258
  throw new Error(`Device config corrupted at ${devicePath}: routines must be a string list.`);
@@ -34,6 +34,7 @@ export type InjectResolution = {
34
34
  } | {
35
35
  addressable: false;
36
36
  reason: string;
37
+ hint?: string;
37
38
  };
38
39
  export interface ResolveOptions {
39
40
  /**
@@ -49,6 +50,12 @@ export interface ResolveOptions {
49
50
  */
50
51
  ptyId?: string;
51
52
  }
53
+ /**
54
+ * Human-facing recovery hint for a session the resolver judged un-addressable.
55
+ * Tells the user both how to continue THIS session and how to make FUTURE runs
56
+ * addressable, so the failure is not silent and the fix is actionable.
57
+ */
58
+ export declare function addressabilityRecoveryHint(session: ActiveSession, fallbackId?: string): string;
52
59
  /**
53
60
  * Resolve a target from an already-fetched ActiveSession. Pure — no I/O — so the
54
61
  * precedence logic is unit-testable without the process table. This is where the
@@ -1,10 +1,40 @@
1
1
  import { getActiveSessions } from '../session/active.js';
2
+ import { machineId } from '../machine-id.js';
2
3
  /** The editor CLIs that speak the swarm-ext URI protocol, keyed by the host detectHost() reports. */
3
4
  const IDE_INJECT_VARIANTS = {
4
5
  codium: { cli: 'codium', scheme: 'vscodium' },
5
6
  cursor: { cli: 'cursor', scheme: 'cursor' },
6
7
  code: { cli: 'code', scheme: 'vscode' },
7
8
  };
9
+ /**
10
+ * Human-facing recovery hint for a session the resolver judged un-addressable.
11
+ * Tells the user both how to continue THIS session and how to make FUTURE runs
12
+ * addressable, so the failure is not silent and the fix is actionable.
13
+ */
14
+ export function addressabilityRecoveryHint(session, fallbackId) {
15
+ const sid = session.sessionId;
16
+ // Branch on the live sessionId (an IDE terminal that has not registered one
17
+ // yet is a distinct message), but render the resume command with the real id
18
+ // when the caller can supply it — e.g. `focus` has meta.id even though the
19
+ // live row's sessionId is falsy, so without this the hint printed a useless
20
+ // `agents sessions resume <id>` placeholder in exactly that case (PHNX-3070).
21
+ const resumeId = sid ?? fallbackId;
22
+ const shortId = resumeId ? resumeId.slice(0, 8) : '<id>';
23
+ const device = session.machine ?? machineId();
24
+ const resumeCmd = resumeId ? `agents sessions resume ${shortId}` : 'agents sessions resume <id>';
25
+ const tmuxCmd = `agents config set devices.${device}.tmux on`;
26
+ const interactive = session.context === 'terminal' || !!session.tty;
27
+ if (session.host === 'ghostty') {
28
+ return `Ghostty has no per-split addressing. ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
29
+ }
30
+ if (session.host && session.host in IDE_INJECT_VARIANTS && !sid) {
31
+ return `This IDE terminal has not registered a session id yet. Wait a moment and retry, or use \`${resumeCmd}\` to continue.`;
32
+ }
33
+ if (session.host) {
34
+ return `Host '${session.host}' has no addressable rail here. ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
35
+ }
36
+ return `This session has no addressable terminal rail (not tmux, iTerm, an IDE terminal, or a pty sidecar). ${interactive ? `Enable tmux wrapping with \`${tmuxCmd}\` and re-launch, or ` : ''}use \`${resumeCmd}\` to continue this session.`;
37
+ }
8
38
  /**
9
39
  * Resolve a target from an already-fetched ActiveSession. Pure — no I/O — so the
10
40
  * precedence logic is unit-testable without the process table. This is where the
@@ -35,7 +65,11 @@ export function resolveInjectTargetForSession(session, opts = {}) {
35
65
  const variant = session.host ? IDE_INJECT_VARIANTS[session.host] : undefined;
36
66
  if (variant) {
37
67
  if (!session.sessionId) {
38
- return { addressable: false, reason: `IDE terminal (${session.host}) has no session id to address` };
68
+ return {
69
+ addressable: false,
70
+ reason: `IDE terminal (${session.host}) has no session id to address`,
71
+ hint: addressabilityRecoveryHint(session),
72
+ };
39
73
  }
40
74
  return {
41
75
  addressable: true,
@@ -58,13 +92,18 @@ export function resolveInjectTargetForSession(session, opts = {}) {
58
92
  note: 'coarse Ghostty window path (opt-in): raises a window and types into the FOCUSED split — not split-precise',
59
93
  };
60
94
  }
61
- return { addressable: false, reason: 'un-addressable (ghostty, no tmux): no per-split addressing; watchdog skips' };
95
+ return {
96
+ addressable: false,
97
+ reason: 'un-addressable (ghostty, no tmux): no per-split addressing; watchdog skips',
98
+ hint: addressabilityRecoveryHint(session),
99
+ };
62
100
  }
63
101
  return {
64
102
  addressable: false,
65
103
  reason: session.host
66
104
  ? `no precise inject rail for host '${session.host}' (no tmux/iterm/IDE terminal detected)`
67
105
  : 'no inject rail: session is not inside tmux, iTerm, or an IDE terminal',
106
+ hint: addressabilityRecoveryHint(session),
68
107
  };
69
108
  }
70
109
  /**