@phnx-labs/agents-cli 1.22.51 → 1.22.52

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 (59) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/commands/attach.js +7 -0
  3. package/dist/commands/browser.js +118 -56
  4. package/dist/commands/daemon.d.ts +2 -0
  5. package/dist/commands/daemon.js +8 -4
  6. package/dist/commands/detach.js +1 -1
  7. package/dist/commands/focus.d.ts +1 -10
  8. package/dist/commands/focus.js +14 -79
  9. package/dist/commands/go.d.ts +26 -0
  10. package/dist/commands/go.js +63 -5
  11. package/dist/commands/monitors.js +1 -1
  12. package/dist/commands/repo.js +31 -3
  13. package/dist/commands/sessions-resume.d.ts +1 -0
  14. package/dist/commands/sessions-resume.js +13 -2
  15. package/dist/commands/sessions-stop.js +1 -1
  16. package/dist/commands/sessions.d.ts +23 -13
  17. package/dist/commands/sessions.js +40 -20
  18. package/dist/commands/setup-browser.d.ts +5 -2
  19. package/dist/commands/setup-browser.js +14 -29
  20. package/dist/commands/setup-preferences.d.ts +22 -3
  21. package/dist/commands/setup-preferences.js +25 -8
  22. package/dist/commands/share.js +12 -8
  23. package/dist/commands/status.js +5 -0
  24. package/dist/commands/sync.js +58 -2
  25. package/dist/commands/tmux.d.ts +8 -1
  26. package/dist/commands/tmux.js +167 -17
  27. package/dist/lib/browser/ipc.d.ts +44 -0
  28. package/dist/lib/browser/ipc.js +120 -8
  29. package/dist/lib/browser/profiles.d.ts +39 -17
  30. package/dist/lib/browser/profiles.js +51 -52
  31. package/dist/lib/browser/runtime-state.d.ts +4 -2
  32. package/dist/lib/browser/runtime-state.js +4 -2
  33. package/dist/lib/browser/service.js +4 -3
  34. package/dist/lib/channels/owner-forward.d.ts +88 -0
  35. package/dist/lib/channels/owner-forward.js +116 -0
  36. package/dist/lib/channels/owner-sink.js +7 -0
  37. package/dist/lib/exec.d.ts +8 -0
  38. package/dist/lib/exec.js +7 -0
  39. package/dist/lib/feed-broadcast.js +15 -1
  40. package/dist/lib/git.d.ts +93 -0
  41. package/dist/lib/git.js +232 -0
  42. package/dist/lib/monitors/remote.d.ts +18 -1
  43. package/dist/lib/monitors/remote.js +15 -2
  44. package/dist/lib/notify.d.ts +7 -0
  45. package/dist/lib/notify.js +15 -1
  46. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  47. package/dist/lib/session/local-tmux-attach.js +164 -0
  48. package/dist/lib/session/remote-active.d.ts +8 -0
  49. package/dist/lib/session/remote-active.js +1 -0
  50. package/dist/lib/share/publish.d.ts +8 -11
  51. package/dist/lib/share/publish.js +16 -20
  52. package/dist/lib/share/worker-template.js +99 -12
  53. package/dist/lib/sync-status.d.ts +17 -0
  54. package/dist/lib/sync-status.js +21 -2
  55. package/dist/lib/tmux/index.d.ts +1 -1
  56. package/dist/lib/tmux/index.js +1 -1
  57. package/dist/lib/tmux/session.d.ts +10 -0
  58. package/dist/lib/tmux/session.js +29 -0
  59. package/package.json +1 -1
@@ -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
+ }
@@ -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,
@@ -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,68 @@ 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
+ return '<div class="agents-share-bar" role="contentinfo" aria-label="Sharing details" ' +
522
+ 'style="background:#0b0b0c !important;color:#e8e8e8 !important">' +
523
+ '<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}' +
527
+ '.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}' +
529
+ '.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}' +
532
+ '.agents-share-bar .ash-date{color:#9096a0 !important}' +
533
+ '.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}' +
535
+ '</style>' +
536
+ '<span class="ash-left">' + left + '</span>' +
537
+ '<span class="ash-right">' + right + '</span>' +
538
+ '</div>';
539
+ }
540
+
541
+ function injectAttributionBar(html, meta, handle) {
542
+ var bar = renderAttributionBar(meta, handle);
543
+ var m = /<body[^>]*>/i.exec(html);
544
+ if (m) {
545
+ var at = m.index + m[0].length;
546
+ return html.slice(0, at) + bar + html.slice(at);
547
+ }
548
+ return bar + html;
549
+ }
550
+
476
551
  function json(body, status) {
477
552
  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
478
553
  }
@@ -507,23 +582,35 @@ function phoenixHandle(auth) {
507
582
  // First writer of a handle owns it. Later PUTs from the same userId are fine;
508
583
  // a different userId whose email local-part collides gets 409, not a silent overwrite.
509
584
  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.
585
+ // The __handles/<handle> claim object is the authoritative first-writer record
586
+ // of ownership. When it exists it decides ownership OUTRIGHT: the recorded
587
+ // userId may write, anyone else is refused. Consult it FIRST — a stray page
588
+ // under the namespace stamped with a different owner (e.g. a BYO WRITE_TOKEN
589
+ // publish, which stamps owner = SHARE_NAMESPACE rather than a userId, or a page
590
+ // published under the same human's earlier userId before a re-auth) must not
591
+ // lock the rightful claim holder out of their own handle (PHNX-3291).
592
+ const key = '__handles/' + handle;
593
+ const existing = await bucket.get(key);
594
+ if (existing) {
595
+ const claimed = existing.customMetadata && existing.customMetadata.userId;
596
+ if (claimed && claimed !== userId) {
597
+ return { error: json({ error: 'handle taken', handle: handle }, 409) };
598
+ }
599
+ return {};
600
+ }
601
+ // No claim object yet — fall back to the page-owner scan so a colliding email
602
+ // local-part cannot PUT/DELETE another account's objects in the window before
603
+ // the claim exists. (claimHandle writes the claim on the first Phoenix PUT.)
604
+ // Only a DIFFERENT Phoenix userId blocks: a BYO WRITE_TOKEN publish stamps
605
+ // owner = the namespace (=== handle) rather than a userId, so it is not a rival
606
+ // identity and must not lock the handle's first Phoenix claimant out (PHNX-3291).
513
607
  const list = await bucket.list({ prefix: handle + '/', include: ['customMetadata'] });
514
608
  for (const o of list.objects || []) {
515
609
  const owner = o.customMetadata && o.customMetadata.owner;
516
- if (owner && owner !== userId) {
610
+ if (owner && owner !== userId && owner !== handle) {
517
611
  return { error: json({ error: 'handle taken', handle: handle }, 409) };
518
612
  }
519
613
  }
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
614
  return {};
528
615
  }
529
616
 
@@ -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
@@ -23,8 +23,9 @@ import { ALL_AGENT_IDS } from './agents.js';
23
23
  import { diffVersionResources, } from './doctor-diff.js';
24
24
  import { listInstalledVersions, getGlobalDefault } from './installations/versions.js';
25
25
  import { loadManifest } from './staleness/index.js';
26
- import { getSystemAgentsDir } from './state.js';
27
- import { isGitRepo } from './git.js';
26
+ import { getSystemAgentsDir, getUserAgentsDir } from './state.js';
27
+ import * as fs from 'fs';
28
+ import { isGitRepo, readOriginUrl } from './git.js';
28
29
  const STATUS_MAP = {
29
30
  ok: 'synced',
30
31
  diff: 'drifted',
@@ -73,6 +74,22 @@ export async function getSystemRepoStatus() {
73
74
  return base;
74
75
  }
75
76
  }
77
+ /**
78
+ * Detect whether `~/.agents` (the user config layer) is git-backed. A partial
79
+ * install — runtime state present but no `.git` (or no `origin`) — is a distinct
80
+ * drift state that `agents repo sync user` heals by adopting in place (PHNX-3301),
81
+ * surfaced separately from per-version resource gaps. Purely local; no network.
82
+ */
83
+ export async function getUserRepoStatus() {
84
+ const dir = getUserAgentsDir();
85
+ if (!fs.existsSync(dir))
86
+ return { dir, notGitRepo: false };
87
+ if (!isGitRepo(dir))
88
+ return { dir, notGitRepo: true };
89
+ // A repo with no `origin` is just as partial for adopt's purposes — reuse the
90
+ // single origin-URL reader rather than a second remote check.
91
+ return { dir, notGitRepo: readOriginUrl(dir) === null };
92
+ }
76
93
  /**
77
94
  * Compute unified sync status across the fleet. Resolves against non-project
78
95
  * layers only (`excludeProject: true`) — the GLOBAL version home is never
@@ -107,6 +124,7 @@ export async function computeSyncStatus(options = {}) {
107
124
  }
108
125
  }
109
126
  const system = await getSystemRepoStatus();
127
+ const user = await getUserRepoStatus();
110
128
  const agentsNeedingSync = new Set();
111
129
  let drifted = 0, missing = 0, orphan = 0, versionsNeedingSync = 0, versionsNeverSynced = 0;
112
130
  for (const v of agents) {
@@ -122,6 +140,7 @@ export async function computeSyncStatus(options = {}) {
122
140
  }
123
141
  return {
124
142
  system,
143
+ user,
125
144
  agents,
126
145
  totals: {
127
146
  drifted,
@@ -5,4 +5,4 @@
5
5
  */
6
6
  export { findTmuxBinary, isTmuxInstalled, getTmuxVersion, isTmuxVersionSupported, MIN_TMUX_VERSION, assertTmuxAvailable, TmuxUnavailableError, TmuxCommandError, runTmux, attachTmux, } from './binary.js';
7
7
  export { getDefaultSocketPath, getSessionMetaPath, ensureTmuxDir, } from './paths.js';
8
- export { assertValidSessionName, slugifyName, hasSession, createSession, killSession, killAll, listSessions, splitPane, sendKeys, capturePane, readSessionMeta, TmuxSessionError, reconcileSessionHooks, ensureSessionHookRepaired, type SessionMeta, type CreateSessionOptions, type ListedSession, type SplitOptions, type SendOptions, type CaptureOptions, } from './session.js';
8
+ export { assertValidSessionName, slugifyName, hasSession, createSession, killSession, teardownIfAgentExited, killAll, listSessions, splitPane, sendKeys, capturePane, readSessionMeta, TmuxSessionError, reconcileSessionHooks, ensureSessionHookRepaired, type SessionMeta, type CreateSessionOptions, type ListedSession, type SplitOptions, type SendOptions, type CaptureOptions, } from './session.js';
@@ -5,4 +5,4 @@
5
5
  */
6
6
  export { findTmuxBinary, isTmuxInstalled, getTmuxVersion, isTmuxVersionSupported, MIN_TMUX_VERSION, assertTmuxAvailable, TmuxUnavailableError, TmuxCommandError, runTmux, attachTmux, } from './binary.js';
7
7
  export { getDefaultSocketPath, getSessionMetaPath, ensureTmuxDir, } from './paths.js';
8
- export { assertValidSessionName, slugifyName, hasSession, createSession, killSession, killAll, listSessions, splitPane, sendKeys, capturePane, readSessionMeta, TmuxSessionError, reconcileSessionHooks, ensureSessionHookRepaired, } from './session.js';
8
+ export { assertValidSessionName, slugifyName, hasSession, createSession, killSession, teardownIfAgentExited, killAll, listSessions, splitPane, sendKeys, capturePane, readSessionMeta, TmuxSessionError, reconcileSessionHooks, ensureSessionHookRepaired, } from './session.js';
@@ -109,6 +109,16 @@ export declare function createSession(opts: CreateSessionOptions): Promise<Sessi
109
109
  export declare function killSession(name: string, socket?: string, opts?: {
110
110
  reapOrphans?: boolean;
111
111
  }): Promise<boolean>;
112
+ /**
113
+ * After an attach client returns: destroy the session if every pane is dead
114
+ * (the agent exited); leave it if any pane is still alive (Ctrl-b d).
115
+ *
116
+ * `runInTmux` already does this via `resolveAfterAttach`. The attach verbs
117
+ * (`agents tmux attach`, `sessions focus`/`resume --attach-only`, `jumpTo`)
118
+ * used to `process.exit` the tmux client status and leave a `remain-on-exit`
119
+ * husk — the session "came back" in `tmux ls` after the user exited the agent.
120
+ */
121
+ export declare function teardownIfAgentExited(name: string, socket?: string): Promise<'killed' | 'kept' | 'absent'>;
112
122
  /**
113
123
  * Kill every session on the shared server AND the server itself, then prune
114
124
  * meta files. Wipes the socket so the next `new` starts from a clean slate.
@@ -315,6 +315,35 @@ export async function killSession(name, socket, opts = {}) {
315
315
  removeSessionMeta(name);
316
316
  return true;
317
317
  }
318
+ /**
319
+ * After an attach client returns: destroy the session if every pane is dead
320
+ * (the agent exited); leave it if any pane is still alive (Ctrl-b d).
321
+ *
322
+ * `runInTmux` already does this via `resolveAfterAttach`. The attach verbs
323
+ * (`agents tmux attach`, `sessions focus`/`resume --attach-only`, `jumpTo`)
324
+ * used to `process.exit` the tmux client status and leave a `remain-on-exit`
325
+ * husk — the session "came back" in `tmux ls` after the user exited the agent.
326
+ */
327
+ export async function teardownIfAgentExited(name, socket) {
328
+ assertValidSessionName(name);
329
+ const sock = socket ?? getDefaultSocketPath();
330
+ if (!(await hasSession(name, sock)))
331
+ return 'absent';
332
+ const res = await runTmux({
333
+ socket: sock,
334
+ args: ['list-panes', '-t', `=${name}`, '-F', '#{pane_dead}'],
335
+ throwOnError: false,
336
+ }).catch(() => null);
337
+ if (!res || res.code !== 0) {
338
+ await killSession(name, sock).catch(() => { });
339
+ return 'killed';
340
+ }
341
+ const flags = res.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
342
+ if (flags.some((d) => d === '0'))
343
+ return 'kept';
344
+ await killSession(name, sock).catch(() => { });
345
+ return 'killed';
346
+ }
318
347
  /**
319
348
  * Kill every session on the shared server AND the server itself, then prune
320
349
  * meta files. Wipes the socket so the next `new` starts from a clean slate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.51",
3
+ "version": "1.22.52",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",