@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
@@ -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())
@@ -39,4 +39,12 @@ export interface RemoteActiveResult {
39
39
  */
40
40
  export declare function gatherRemoteActive(hosts?: string[], opts?: {
41
41
  quiet?: boolean;
42
+ /**
43
+ * Opt-in first-hit abort. Omitted by default so `--active`, projects, and
44
+ * `focus` with no id keep all-settle. Detach/stop pass this for a unique
45
+ * live id so a reachable hit does not wait out sleeping peers.
46
+ */
47
+ earlyExit?: {
48
+ isDefinitive: (item: ActiveSession, machine: string) => boolean;
49
+ };
42
50
  }): Promise<RemoteActiveResult>;
@@ -80,6 +80,7 @@ export async function gatherRemoteActive(hosts, opts) {
80
80
  hosts,
81
81
  parse: parseRemoteActive,
82
82
  quiet: opts?.quiet,
83
+ earlyExit: opts?.earlyExit,
83
84
  });
84
85
  return {
85
86
  sessions: result.items,
@@ -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
+ }
@@ -85,6 +85,8 @@ export interface PublishOptions {
85
85
  export type ShareVisibility = 'public' | 'unlisted' | 'me' | 'org';
86
86
  export interface PublishResult {
87
87
  url: string;
88
+ /** URL-safe object name, explicit (`--slug`) or deterministically derived. */
89
+ slug?: string;
88
90
  expiresAt?: string;
89
91
  coverUrl?: string;
90
92
  /** Server-enforced visibility stamped on the object. */
@@ -224,22 +226,17 @@ export declare function redactEmails(text: string): string;
224
226
  export declare function scanShareContent(body: string | Buffer): SensitiveHit[];
225
227
  /** Build the refuse message when the pre-publish scan finds sensitive content. */
226
228
  export declare function formatSensitiveContentError(hits: SensitiveHit[]): string;
227
- /** Derive a URL-safe slug from a filename (or pass one through). */
229
+ /** Derive a URL-safe slug from a filename, stripping its path and extension. */
228
230
  export declare function slugify(name: string): string;
229
231
  /** The project the file belongs to — git repo name, else the cwd's basename. */
230
232
  export declare function detectProject(dir?: string): string;
231
233
  /**
232
- * Readable slug + view id: `<feature>-<16hex>`. A leading `plan-` on the
233
- * filename is dropped. The project no longer prefixes the slug — the URL
234
- * namespace is already the publisher's handle (`/<you>/<slug>`).
235
- *
236
- * The tail is 8 random bytes (64-bit, 16 hex chars). Reads are public — the URL is
237
- * the only capability — so the nonce must be genuinely infeasible to brute-force,
238
- * not merely unlisted; 64 bits puts a blind guess out of reach. (See docs/distribution.md
239
- * §Security for the threat model and `--expire` for sensitive content.)
240
- * `--slug` still publishes that exact slug, no view-id appended.
234
+ * Stable default slug for an artifact: prefer its HTML `<title>` or Markdown
235
+ * frontmatter `title:`, then fall back to its filename. The same artifact title
236
+ * always yields the same slug, so republishing without `--slug` updates the same
237
+ * URL. `--slug` remains an exact override.
241
238
  */
242
- export declare function defaultSlug(filePath: string, _dir?: string): string;
239
+ export declare function defaultSlug(filePath: string, body?: Buffer): string;
243
240
  /**
244
241
  * Best-effort OG cover: capture a screenshot, upload it as `<slug>.png`, and return
245
242
  * the page body with og:image meta injected (+ the cover URL). All IO is injected
@@ -8,7 +8,6 @@
8
8
  import { readFileSync } from 'node:fs';
9
9
  import { basename } from 'node:path';
10
10
  import { execFileSync } from 'node:child_process';
11
- import { randomBytes } from 'node:crypto';
12
11
  import { hostname as osHostname } from 'node:os';
13
12
  import { readShareConfig } from './config.js';
14
13
  import { resolveGitHubUsername } from '../git.js';
@@ -309,15 +308,17 @@ export function formatSensitiveContentError(hits) {
309
308
  `Shares are world-readable by URL — pass --force to publish anyway, ` +
310
309
  `or --unlisted --expire 12h to bound the blast radius.`);
311
310
  }
312
- /** Derive a URL-safe slug from a filename (or pass one through). */
313
- export function slugify(name) {
314
- return (basename(name)
315
- .replace(/\.[^.]+$/, '')
316
- .toLowerCase()
311
+ /** Normalize artifact title text into a URL-safe slug. */
312
+ function normalizeSlug(name) {
313
+ return (name.toLowerCase()
317
314
  .replace(/[^a-z0-9]+/g, '-')
318
315
  .replace(/^-+|-+$/g, '')
319
316
  .slice(0, 60) || 'page');
320
317
  }
318
+ /** Derive a URL-safe slug from a filename, stripping its path and extension. */
319
+ export function slugify(name) {
320
+ return normalizeSlug(basename(name).replace(/\.[^.]+$/, ''));
321
+ }
321
322
  function sanitizeSlugPart(s) {
322
323
  return sanitizeShareNamespace(s);
323
324
  }
@@ -343,19 +344,13 @@ export function detectProject(dir = process.cwd()) {
343
344
  return gitRepoName(dir) ?? (sanitizeSlugPart(basename(dir)) || 'share');
344
345
  }
345
346
  /**
346
- * Readable slug + view id: `<feature>-<16hex>`. A leading `plan-` on the
347
- * filename is dropped. The project no longer prefixes the slug — the URL
348
- * namespace is already the publisher's handle (`/<you>/<slug>`).
349
- *
350
- * The tail is 8 random bytes (64-bit, 16 hex chars). Reads are public — the URL is
351
- * the only capability — so the nonce must be genuinely infeasible to brute-force,
352
- * not merely unlisted; 64 bits puts a blind guess out of reach. (See docs/distribution.md
353
- * §Security for the threat model and `--expire` for sensitive content.)
354
- * `--slug` still publishes that exact slug, no view-id appended.
347
+ * Stable default slug for an artifact: prefer its HTML `<title>` or Markdown
348
+ * frontmatter `title:`, then fall back to its filename. The same artifact title
349
+ * always yields the same slug, so republishing without `--slug` updates the same
350
+ * URL. `--slug` remains an exact override.
355
351
  */
356
- export function defaultSlug(filePath, _dir) {
357
- const feature = slugify(filePath).replace(/^plan-/, '') || 'page';
358
- return `${feature}-${randomBytes(8).toString('hex')}`;
352
+ export function defaultSlug(filePath, body) {
353
+ return body ? normalizeSlug(deriveLabel(filePath, body)) : slugify(filePath);
359
354
  }
360
355
  function guessContentType(filePath) {
361
356
  if (/\.html?$/i.test(filePath))
@@ -459,7 +454,8 @@ export async function publishFile(filePath, opts = {}) {
459
454
  }
460
455
  export async function publishToEndpoint(filePath, endpoint, opts = {}) {
461
456
  const username = await resolveShareUsername(opts);
462
- const slugPart = (opts.slug ?? defaultSlug(filePath)).replace(/^\/+/, '');
457
+ let body = readFileSync(filePath);
458
+ const slugPart = (opts.slug ?? defaultSlug(filePath, body)).replace(/^\/+/, '');
463
459
  const key = buildShareKey(username, slugPart);
464
460
  const expiresAt = resolveExpire(opts.expire);
465
461
  const visibility = resolveShareVisibility(opts);
@@ -472,7 +468,6 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
472
468
  const res = await fetch(u, { method: 'PUT', headers: h, body: new Uint8Array(b) });
473
469
  return { ok: res.ok, status: res.status, url: u };
474
470
  });
475
- let body = readFileSync(filePath);
476
471
  let coverUrl;
477
472
  const isHtml = /\.html?$/i.test(filePath);
478
473
  if (isHtml) {
@@ -576,6 +571,7 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
576
571
  }
577
572
  return {
578
573
  url: r.url ?? pageUrl,
574
+ slug: key.slice(key.indexOf('/') + 1),
579
575
  expiresAt,
580
576
  coverUrl,
581
577
  label,
@@ -253,7 +253,20 @@ export default {
253
253
  headers.set('cache-control', 'public, max-age=60');
254
254
  if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
255
255
  }
256
- return new Response(request.method === 'HEAD' ? null : obj.body, { status: 200, headers });
256
+ if (request.method === 'HEAD') return new Response(null, { status: 200, headers });
257
+ // HTML pages get an attribution bar injected at serve time (who shared it,
258
+ // what made it, when, and — the point — a visual VISIBILITY cue). All read
259
+ // from the object's stamped metadata; non-HTML assets (images, JSON, the OG
260
+ // cover) are served byte-for-byte. The body changes, so the R2 etag no
261
+ // longer matches — drop it rather than serve a lying validator.
262
+ const isHtmlPage = (headers.get('content-type') || '').indexOf('text/html') !== -1;
263
+ if (isHtmlPage) {
264
+ const rawHtml = await obj.text();
265
+ const withBar = injectAttributionBar(rawHtml, obj.customMetadata || {}, firstSeg);
266
+ headers.delete('etag');
267
+ return new Response(withBar, { status: 200, headers });
268
+ }
269
+ return new Response(obj.body, { status: 200, headers });
257
270
  }
258
271
 
259
272
  if (request.method === 'DELETE') {
@@ -473,6 +486,73 @@ function escapeHtml(s) {
473
486
  .replace(/"/g, '&quot;');
474
487
  }
475
488
 
489
+ // The attribution bar: a slim strip prepended to every served HTML page that
490
+ // makes the object's stamped metadata visible — who shared it, what made it,
491
+ // when, and (the whole point) a color-coded VISIBILITY cue Google-Drive style.
492
+ // All values come from customMetadata; nothing new is stamped at publish time,
493
+ // so this is a pure Worker change deployable with 'agents artifacts share update'.
494
+ var VIS_ICON = {
495
+ me: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="11" width="16" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>',
496
+ org: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M6 21V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v17M14 9h4a1 1 0 0 1 1 1v11"/></svg>',
497
+ unlisted: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
498
+ public: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>',
499
+ };
500
+
501
+ function visibilityChip(visibility, orgDomain) {
502
+ if (visibility === 'me') return { icon: VIS_ICON.me, label: 'Only you', color: '#f59e0b' };
503
+ if (visibility === 'org') return { icon: VIS_ICON.org, label: 'Anyone at ' + escapeHtml(orgDomain || 'your organization'), color: '#5b9dff' };
504
+ if (visibility === 'unlisted') return { icon: VIS_ICON.unlisted, label: 'Unlisted', color: '#9aa0a6' };
505
+ return { icon: VIS_ICON.public, label: 'Public', color: '#30a46c' };
506
+ }
507
+
508
+ function renderAttributionBar(meta, handle) {
509
+ var cm = meta || {};
510
+ var chip = visibilityChip(cm.visibility || 'public', cm.org_domain);
511
+ var left = '';
512
+ // The handle is already the public URL namespace, so surfacing it leaks nothing new.
513
+ if (handle) left += 'Shared by <strong>' + escapeHtml(handle) + '</strong>';
514
+ if (cm.agent) left += (left ? '<span class="ash-dot">·</span>' : '') + 'Made with ' + escapeHtml(cm.agent);
515
+ var right = '<span class="ash-chip" style="color:' + chip.color + ';border-color:' + chip.color + '66">' + chip.icon + '<span>' + chip.label + '</span></span>';
516
+ if (cm.date) right += '<span class="ash-date">' + escapeHtml(cm.date) + '</span>';
517
+ // The load-bearing background + base colour ride an INLINE style so the host
518
+ // page's own CSS can never wash the bar out (inline beats a page stylesheet);
519
+ // the rest is a namespaced style block with !important on every colour so a
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.
525
+ return '<div class="agents-share-bar" role="contentinfo" aria-label="Sharing details" ' +
526
+ 'style="background:#0b0b0c !important;color:#e8e8e8 !important">' +
527
+ '<style>' +
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}' +
532
+ '.agents-share-bar *{box-sizing:border-box;font-family:inherit}' +
533
+ '.agents-share-bar .ash-left{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#cfd3d9 !important}' +
534
+ '.agents-share-bar strong{color:#fff !important;font-weight:600}' +
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}' +
537
+ '.agents-share-bar .ash-date{color:#9096a0 !important}' +
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}' +
539
+ '.agents-share-bar .ash-chip svg{flex:none;vertical-align:middle}' +
540
+ '</style>' +
541
+ '<span class="ash-left">' + left + '</span>' +
542
+ '<span class="ash-right">' + right + '</span>' +
543
+ '</div>';
544
+ }
545
+
546
+ function injectAttributionBar(html, meta, handle) {
547
+ var bar = renderAttributionBar(meta, handle);
548
+ var m = /<body[^>]*>/i.exec(html);
549
+ if (m) {
550
+ var at = m.index + m[0].length;
551
+ return html.slice(0, at) + bar + html.slice(at);
552
+ }
553
+ return bar + html;
554
+ }
555
+
476
556
  function json(body, status) {
477
557
  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
478
558
  }
@@ -507,23 +587,35 @@ function phoenixHandle(auth) {
507
587
  // First writer of a handle owns it. Later PUTs from the same userId are fine;
508
588
  // a different userId whose email local-part collides gets 409, not a silent overwrite.
509
589
  async function assertHandleOwner(bucket, handle, userId) {
510
- // Pages under the handle already stamp customMetadata.owner = userId.
511
- // Treat that as the source of truth so a colliding local-part cannot
512
- // PUT/DELETE another account's objects even if the claim object is missing.
590
+ // The __handles/<handle> claim object is the authoritative first-writer record
591
+ // of ownership. When it exists it decides ownership OUTRIGHT: the recorded
592
+ // userId may write, anyone else is refused. Consult it FIRST — a stray page
593
+ // under the namespace stamped with a different owner (e.g. a BYO WRITE_TOKEN
594
+ // publish, which stamps owner = SHARE_NAMESPACE rather than a userId, or a page
595
+ // published under the same human's earlier userId before a re-auth) must not
596
+ // lock the rightful claim holder out of their own handle (PHNX-3291).
597
+ const key = '__handles/' + handle;
598
+ const existing = await bucket.get(key);
599
+ if (existing) {
600
+ const claimed = existing.customMetadata && existing.customMetadata.userId;
601
+ if (claimed && claimed !== userId) {
602
+ return { error: json({ error: 'handle taken', handle: handle }, 409) };
603
+ }
604
+ return {};
605
+ }
606
+ // No claim object yet — fall back to the page-owner scan so a colliding email
607
+ // local-part cannot PUT/DELETE another account's objects in the window before
608
+ // the claim exists. (claimHandle writes the claim on the first Phoenix PUT.)
609
+ // Only a DIFFERENT Phoenix userId blocks: a BYO WRITE_TOKEN publish stamps
610
+ // owner = the namespace (=== handle) rather than a userId, so it is not a rival
611
+ // identity and must not lock the handle's first Phoenix claimant out (PHNX-3291).
513
612
  const list = await bucket.list({ prefix: handle + '/', include: ['customMetadata'] });
514
613
  for (const o of list.objects || []) {
515
614
  const owner = o.customMetadata && o.customMetadata.owner;
516
- if (owner && owner !== userId) {
615
+ if (owner && owner !== userId && owner !== handle) {
517
616
  return { error: json({ error: 'handle taken', handle: handle }, 409) };
518
617
  }
519
618
  }
520
- const key = '__handles/' + handle;
521
- const existing = await bucket.get(key);
522
- if (!existing) return {};
523
- const claimed = existing.customMetadata && existing.customMetadata.userId;
524
- if (claimed && claimed !== userId) {
525
- return { error: json({ error: 'handle taken', handle: handle }, 409) };
526
- }
527
619
  return {};
528
620
  }
529
621
 
@@ -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.`);
@@ -64,8 +64,18 @@ export interface SystemRepoStatus {
64
64
  /** True when the dir isn't a git repo or has no upstream — behind is unknown. */
65
65
  unknown: boolean;
66
66
  }
67
+ export interface UserRepoStatus {
68
+ dir: string;
69
+ /**
70
+ * True when `~/.agents` exists but is not a git repo (or is a repo with no
71
+ * `origin`) — a partial install `agents repo sync user` will adopt in place
72
+ * (PHNX-3301). A DISTINCT drift state, not a per-agent "N missing" count.
73
+ */
74
+ notGitRepo: boolean;
75
+ }
67
76
  export interface UnifiedSyncStatus {
68
77
  system: SystemRepoStatus;
78
+ user: UserRepoStatus;
69
79
  agents: AgentVersionStatus[];
70
80
  totals: {
71
81
  drifted: number;
@@ -93,6 +103,13 @@ export interface SyncStatusOptions {
93
103
  * the menu-bar surfaces; we read it once, here, so every surface agrees.
94
104
  */
95
105
  export declare function getSystemRepoStatus(): Promise<SystemRepoStatus>;
106
+ /**
107
+ * Detect whether `~/.agents` (the user config layer) is git-backed. A partial
108
+ * install — runtime state present but no `.git` (or no `origin`) — is a distinct
109
+ * drift state that `agents repo sync user` heals by adopting in place (PHNX-3301),
110
+ * surfaced separately from per-version resource gaps. Purely local; no network.
111
+ */
112
+ export declare function getUserRepoStatus(): Promise<UserRepoStatus>;
96
113
  /**
97
114
  * Compute unified sync status across the fleet. Resolves against non-project
98
115
  * layers only (`excludeProject: true`) — the GLOBAL version home is never