@phnx-labs/agents-cli 1.22.40 → 1.22.41

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 (58) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +0 -1
  5. package/dist/cli/command-registry.js +1 -2
  6. package/dist/commands/browser.d.ts +15 -0
  7. package/dist/commands/browser.js +115 -40
  8. package/dist/commands/feed.js +3 -11
  9. package/dist/commands/secrets.js +87 -97
  10. package/dist/commands/sessions-picker.d.ts +1 -0
  11. package/dist/commands/sessions-picker.js +27 -5
  12. package/dist/commands/sessions-share.d.ts +25 -0
  13. package/dist/commands/sessions-share.js +166 -0
  14. package/dist/commands/sessions.js +2 -0
  15. package/dist/commands/setup-browser.js +1 -1
  16. package/dist/commands/setup-preferences.js +2 -2
  17. package/dist/commands/webhook.js +14 -9
  18. package/dist/lib/browser/cdp.js +4 -0
  19. package/dist/lib/browser/chrome.js +12 -1
  20. package/dist/lib/browser/drivers/ssh.js +1 -0
  21. package/dist/lib/browser/profiles.d.ts +3 -3
  22. package/dist/lib/browser/profiles.js +7 -7
  23. package/dist/lib/browser/service.d.ts +24 -1
  24. package/dist/lib/browser/service.js +38 -14
  25. package/dist/lib/browser/types.d.ts +1 -1
  26. package/dist/lib/daemon-webhooks.d.ts +3 -1
  27. package/dist/lib/daemon-webhooks.js +12 -7
  28. package/dist/lib/device-config.js +1 -1
  29. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  30. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  31. package/dist/lib/observe-aliases.d.ts +2 -2
  32. package/dist/lib/observe-aliases.js +2 -11
  33. package/dist/lib/project-key.js +7 -0
  34. package/dist/lib/runner.js +21 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/secrets/bundles.d.ts +1 -1
  38. package/dist/lib/secrets/bundles.js +1 -1
  39. package/dist/lib/secrets/headless.d.ts +16 -0
  40. package/dist/lib/secrets/headless.js +21 -0
  41. package/dist/lib/secrets/remote.d.ts +9 -7
  42. package/dist/lib/secrets/remote.js +18 -9
  43. package/dist/lib/session/share-html.d.ts +55 -0
  44. package/dist/lib/session/share-html.js +319 -0
  45. package/dist/lib/settings-manifest.d.ts +2 -0
  46. package/dist/lib/settings-manifest.js +81 -3
  47. package/dist/lib/share/publish.d.ts +38 -0
  48. package/dist/lib/share/publish.js +81 -8
  49. package/dist/lib/startup/command-registry.d.ts +2 -1
  50. package/dist/lib/startup/command-registry.js +4 -2
  51. package/dist/lib/triggers/handlers.d.ts +37 -2
  52. package/dist/lib/triggers/handlers.js +56 -5
  53. package/dist/lib/triggers/webhook.d.ts +80 -6
  54. package/dist/lib/triggers/webhook.js +127 -3
  55. package/dist/lib/types.d.ts +1 -1
  56. package/dist/lib/wrap.d.ts +33 -0
  57. package/dist/lib/wrap.js +70 -0
  58. package/package.json +1 -1
@@ -25,3 +25,19 @@
25
25
  * the per-caller broker-only pattern used across the headless secrets readers.
26
26
  */
27
27
  export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
28
+ /**
29
+ * True when this process runs structurally inside a coding-agent session: any
30
+ * `agents run` launch (AGENTS_RUNTIME / AGENT_SESSION_ID ride the child env —
31
+ * exec.ts buildExecEnv — and are inherited by every shell the agent spawns), or
32
+ * a harness's own tool shell launched outside agents-cli (Claude Code stamps
33
+ * CLAUDECODE on its Bash tool).
34
+ *
35
+ * Distinct from isHeadlessSecretsContext above on both axes: it is
36
+ * platform-independent (no biometry involved), and a TTY does not clear it (an
37
+ * agent inside tmux has one). Its job is the materialization boundary, not the
38
+ * prompt boundary: a command that would PRINT a plaintext value refuses under
39
+ * an agent, because anything printed lands in the agent's context and session
40
+ * transcript. The agent path to secret values is injection — `secrets exec`,
41
+ * `run --secrets` — which places them only in a child process env.
42
+ */
43
+ export declare function isAgentInvocationContext(env?: NodeJS.ProcessEnv): boolean;
@@ -33,3 +33,24 @@ export function isHeadlessSecretsContext(env = process.env, platform = process.p
33
33
  return true;
34
34
  return false;
35
35
  }
36
+ /**
37
+ * True when this process runs structurally inside a coding-agent session: any
38
+ * `agents run` launch (AGENTS_RUNTIME / AGENT_SESSION_ID ride the child env —
39
+ * exec.ts buildExecEnv — and are inherited by every shell the agent spawns), or
40
+ * a harness's own tool shell launched outside agents-cli (Claude Code stamps
41
+ * CLAUDECODE on its Bash tool).
42
+ *
43
+ * Distinct from isHeadlessSecretsContext above on both axes: it is
44
+ * platform-independent (no biometry involved), and a TTY does not clear it (an
45
+ * agent inside tmux has one). Its job is the materialization boundary, not the
46
+ * prompt boundary: a command that would PRINT a plaintext value refuses under
47
+ * an agent, because anything printed lands in the agent's context and session
48
+ * transcript. The agent path to secret values is injection — `secrets exec`,
49
+ * `run --secrets` — which places them only in a child process env.
50
+ */
51
+ export function isAgentInvocationContext(env = process.env) {
52
+ return Boolean(env.AGENTS_RUNTIME ||
53
+ env.AGENT_SESSION_ID ||
54
+ env.AGENTS_SESSION_ID ||
55
+ env.CLAUDECODE);
56
+ }
@@ -169,9 +169,10 @@ export type RemoteKeychainWriteVerification = {
169
169
  * metadata `noAcl` last — bundles.ts writeBundleWithItems). The metadata-only bundle
170
170
  * then fails every later read with the confusing `Bundle '<b>' key '<k>': stored
171
171
  * item '<item>' not found` (bundles.ts resolveBundleEnv). We catch it by reading
172
- * the bundle back the same way a release will (`secrets export --plaintext --format
173
- * json`, driven headlessly on the remote so its `agentOnly` guard FAILS FAST before
174
- * any keychain read — no Touch ID prompt) and confirming every pushed key returned.
172
+ * the bundle back the same way a later `secrets exec`/resolve will (the marker-gated
173
+ * json transport, driven headlessly on the remote so its `agentOnly` guard FAILS FAST
174
+ * before any keychain read — no Touch ID prompt) and confirming every pushed key
175
+ * returned.
175
176
  *
176
177
  * Pure so both branches are unit-testable without a real locked keychain: inject the
177
178
  * "read-back failed / key absent" condition through `readBack`.
@@ -193,10 +194,11 @@ export declare function keychainWriteFailureMessage(host: string, bundle: string
193
194
  /**
194
195
  * Read a bundle back from a remote over SSH (headlessly, so it fails fast rather
195
196
  * than prompting Touch ID) and confirm the pushed keys materialized. Drives the
196
- * remote's own `secrets export <bundle> --plaintext --format json` — the same read
197
- * a headless release performs but keeps only the KEY NAMES; the plaintext values
198
- * are dropped immediately and never retained or logged. Returns a verification
199
- * verdict; the caller renders `keychainWriteFailureMessage` on failure.
197
+ * remote's marker-gated json transport (`secrets export <bundle> --plaintext
198
+ * --format json` under AGENTS_SECRETS_REMOTE_TRANSPORT) but keeps only the KEY
199
+ * NAMES; the plaintext values are dropped immediately and never retained or
200
+ * logged. Returns a verification verdict; the caller renders
201
+ * `keychainWriteFailureMessage` on failure.
200
202
  */
201
203
  export declare function verifyRemoteKeychainPush(target: string, bundle: string, pushedKeys: string[], opts?: {
202
204
  osLookupName?: string;
@@ -212,7 +212,12 @@ export function remoteSecretsStream(target, args, opts = {}) {
212
212
  */
213
213
  export async function remoteResolveEnv(target, bundle, opts = {}) {
214
214
  assertValidSshTarget(target);
215
- const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName));
215
+ // AGENTS_SECRETS_REMOTE_TRANSPORT is the marker that lets the remote's
216
+ // `export --plaintext --format json` emit at all — the public shell-eval
217
+ // export mode was removed (RUSH-2774), and this machine-to-machine resolve is
218
+ // the only surviving caller of the JSON emitter. Riding the legacy argv keeps
219
+ // a new driver compatible with an old remote during a fleet rollout.
220
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName), { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
216
221
  // Resolving a remote bundle streams its plaintext values back over ssh stdout,
217
222
  // so this read is secret-bearing: pin the managed host key and never leave a
218
223
  // reusable control master to the source (RUSH-2527, `credentialTransportSshOpts`).
@@ -299,9 +304,10 @@ function isLockedKeychainReadBackError(stderr) {
299
304
  * metadata `noAcl` last — bundles.ts writeBundleWithItems). The metadata-only bundle
300
305
  * then fails every later read with the confusing `Bundle '<b>' key '<k>': stored
301
306
  * item '<item>' not found` (bundles.ts resolveBundleEnv). We catch it by reading
302
- * the bundle back the same way a release will (`secrets export --plaintext --format
303
- * json`, driven headlessly on the remote so its `agentOnly` guard FAILS FAST before
304
- * any keychain read — no Touch ID prompt) and confirming every pushed key returned.
307
+ * the bundle back the same way a later `secrets exec`/resolve will (the marker-gated
308
+ * json transport, driven headlessly on the remote so its `agentOnly` guard FAILS FAST
309
+ * before any keychain read — no Touch ID prompt) and confirming every pushed key
310
+ * returned.
305
311
  *
306
312
  * Pure so both branches are unit-testable without a real locked keychain: inject the
307
313
  * "read-back failed / key absent" condition through `readBack`.
@@ -359,13 +365,16 @@ export function keychainWriteFailureMessage(host, bundle, reason) {
359
365
  /**
360
366
  * Read a bundle back from a remote over SSH (headlessly, so it fails fast rather
361
367
  * than prompting Touch ID) and confirm the pushed keys materialized. Drives the
362
- * remote's own `secrets export <bundle> --plaintext --format json` — the same read
363
- * a headless release performs but keeps only the KEY NAMES; the plaintext values
364
- * are dropped immediately and never retained or logged. Returns a verification
365
- * verdict; the caller renders `keychainWriteFailureMessage` on failure.
368
+ * remote's marker-gated json transport (`secrets export <bundle> --plaintext
369
+ * --format json` under AGENTS_SECRETS_REMOTE_TRANSPORT) but keeps only the KEY
370
+ * NAMES; the plaintext values are dropped immediately and never retained or
371
+ * logged. Returns a verification verdict; the caller renders
372
+ * `keychainWriteFailureMessage` on failure.
366
373
  */
367
374
  export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {}) {
368
- const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName));
375
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName),
376
+ // Same transport marker as remoteResolveEnv — see the comment there (RUSH-2774).
377
+ { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
369
378
  // This read-back streams the just-pushed plaintext over ssh stdout, so it is
370
379
  // as secret-bearing as the push itself — the push path passes `secret: true`
371
380
  // so it too pins the managed host key and leaves no reusable control master to
@@ -0,0 +1,55 @@
1
+ import type { SessionMeta } from './types.js';
2
+ export interface SessionHtmlOptions {
3
+ /** Whether the Markdown was rendered with redaction on — shown in the footer. */
4
+ redacted?: boolean;
5
+ }
6
+ /** One metadata chip in the page header. */
7
+ interface Chip {
8
+ label: string;
9
+ value: string;
10
+ }
11
+ export declare function escapeHtml(text: string): string;
12
+ /**
13
+ * Lift every folded reasoning block out of the Markdown, leaving a sentinel.
14
+ *
15
+ * `renderer.html` escapes raw HTML unconditionally — the transcript body is model
16
+ * and tool output, so nothing in it may round-trip as markup. That also escaped
17
+ * the `<details>` wrapper `--reasoning fold` emits, which is why the disclosure
18
+ * element has to be reconstructed here instead of allowlisted through.
19
+ *
20
+ * Allowlisting was tried twice and is the wrong shape: marked hands the same
21
+ * markup over as several different raw strings depending on context (a whole-token
22
+ * gate passed the opening tag and escaped the closing one in a real document), and
23
+ * a bare `<details>` in ordinary prose is a complete INLINE html token — so prose
24
+ * that merely mentions the tag would emit a live, unclosed element that swallows
25
+ * every later turn into it. Reconstructing both ends here makes the pairing
26
+ * structural: it cannot be unbalanced by anything the transcript contains.
27
+ */
28
+ export declare function liftFoldBlocks(markdown: string): {
29
+ text: string;
30
+ blocks: string[];
31
+ };
32
+ /** Human duration — "13 minutes", never "12m 49s". */
33
+ export declare function formatDuration(ms: number): string;
34
+ /**
35
+ * The chips shown under the title.
36
+ *
37
+ * Deliberately excludes `account` (an email — the publish-time sensitive-content
38
+ * scan rejects those, and correctly), `cwd`, and `machine`: a published transcript
39
+ * should not carry the operator's identity or local paths in its chrome. Host and
40
+ * repo already ride in the share object's provenance metadata, which is the right
41
+ * home for them.
42
+ */
43
+ export declare function buildChips(session: SessionMeta): Chip[];
44
+ /**
45
+ * The page's `<title>` — `deriveLabel()` reads this as the share's gallery label.
46
+ *
47
+ * The document heading is derived from the session's first prompt, which in a real
48
+ * session is routinely a pasted URL plus a file path plus the actual request. Left
49
+ * whole it renders as a three-line wall above the transcript and an unreadable
50
+ * gallery row, so it is collapsed to one line and cut at a word boundary. `--label`
51
+ * overrides it outright.
52
+ */
53
+ export declare function sessionPageTitle(session: SessionMeta, markdown: string): string;
54
+ export declare function renderSessionHtmlDocument(session: SessionMeta, markdown: string, options?: SessionHtmlOptions): string;
55
+ export {};
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Wrap an already-rendered session transcript in ONE self-contained HTML page for
3
+ * `agents sessions share`.
4
+ *
5
+ * Takes the Markdown document as an argument rather than producing it, so the
6
+ * decisions that make a transcript safe to publish — redaction, synthetic-turn
7
+ * filtering, tool-output truncation, reasoning visibility — stay in
8
+ * `renderSessionMarkdownDocument()` and the HTML path can never drift from the
9
+ * Markdown one. That also keeps this a pure presentation function with no import
10
+ * back into the command layer.
11
+ *
12
+ * Self-contained on purpose: an inline <style>, no external assets, no CDN, no
13
+ * dependency on the artifacts-cli host CLI (which is not configured on every box,
14
+ * RUSH-2728). The page is uploaded verbatim to the share Worker.
15
+ *
16
+ * Terminal-coded per the agents-cli brand (#0a0a0a bg, #a3e635 lime accent,
17
+ * JetBrains Mono), with a light theme under `prefers-color-scheme: light` and an
18
+ * in-page toggle so a published link is readable in bright light and dim alike.
19
+ */
20
+ import { marked, Renderer } from 'marked';
21
+ export function escapeHtml(text) {
22
+ return text
23
+ .replace(/&/g, '&amp;')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
27
+ .replace(/'/g, '&#39;');
28
+ }
29
+ /**
30
+ * A transcript is untrusted text: it carries whatever the model wrote and whatever
31
+ * a tool printed. marked passes raw HTML through by default, so a session that
32
+ * merely *discussed* a `<script>` tag would ship an executable one on a public URL.
33
+ * Escaping the html token's raw source neutralizes that without mangling the
34
+ * visible text, and non-http(s) link schemes are dropped so `javascript:` cannot
35
+ * ride in through a Markdown link.
36
+ */
37
+ /**
38
+ * The only raw HTML the transcript renderer itself emits: `--reasoning fold` wraps
39
+ * each thinking block in a disclosure element (`session/render.ts` — the `fold`
40
+ * branch pushes `<details>\n<summary>Reasoning</summary>` and `</details>`).
41
+ *
42
+ * Escaping those along with everything else published `&lt;details&gt;` as visible
43
+ * text and no collapsing, so `--reasoning fold` shipped broken. They are allowed
44
+ * back through by EXACT match on the token's raw source, not by parsing tags:
45
+ * attribute-free `details`/`summary` carry no script, no URL, and no event handler,
46
+ * and an exact-string gate cannot be widened by crafted input the way a tag parser
47
+ * can. Anything else — including `<details onclick=…>` — still escapes.
48
+ */
49
+ /**
50
+ * Sentinel standing in for one folded reasoning block while the document goes
51
+ * through marked. Deliberately not valid Markdown or HTML, and any pre-existing
52
+ * occurrence in the transcript is neutralized before substitution.
53
+ */
54
+ const FOLD_SENTINEL = 'aGeNtSfOlDbLoCk';
55
+ // The neutralization below is only sound because this literal is BORDER-FREE: its
56
+ // first character occurs nowhere else in it, so no occurrence can overlap another
57
+ // and splitting on it cannot leave a fresh one behind. A bordered literal (`aba`
58
+ // splits into a leftover `a` + `ba`) is defeatable. Keep that property if you
59
+ // ever change the string.
60
+ /** The exact block `session/render.ts` pushes on its `fold` branch. */
61
+ const FOLD_BLOCK = /<details>\n<summary>Reasoning<\/summary>\n\n([\s\S]*?)\n\n<\/details>/g;
62
+ /**
63
+ * Lift every folded reasoning block out of the Markdown, leaving a sentinel.
64
+ *
65
+ * `renderer.html` escapes raw HTML unconditionally — the transcript body is model
66
+ * and tool output, so nothing in it may round-trip as markup. That also escaped
67
+ * the `<details>` wrapper `--reasoning fold` emits, which is why the disclosure
68
+ * element has to be reconstructed here instead of allowlisted through.
69
+ *
70
+ * Allowlisting was tried twice and is the wrong shape: marked hands the same
71
+ * markup over as several different raw strings depending on context (a whole-token
72
+ * gate passed the opening tag and escaped the closing one in a real document), and
73
+ * a bare `<details>` in ordinary prose is a complete INLINE html token — so prose
74
+ * that merely mentions the tag would emit a live, unclosed element that swallows
75
+ * every later turn into it. Reconstructing both ends here makes the pairing
76
+ * structural: it cannot be unbalanced by anything the transcript contains.
77
+ */
78
+ export function liftFoldBlocks(markdown) {
79
+ const blocks = [];
80
+ // A transcript that literally contains the sentinel must not be able to forge a
81
+ // disclosure element, so break any occurrence before inserting our own.
82
+ const safe = markdown.split(FOLD_SENTINEL).join(`${FOLD_SENTINEL[0]}​${FOLD_SENTINEL.slice(1)}`);
83
+ const text = safe.replace(FOLD_BLOCK, (_match, inner) => {
84
+ blocks.push(inner);
85
+ return `${FOLD_SENTINEL}${blocks.length - 1}${FOLD_SENTINEL}`;
86
+ });
87
+ return { text, blocks };
88
+ }
89
+ function safeRenderer() {
90
+ const renderer = new Renderer();
91
+ renderer.html = ({ raw }) => escapeHtml(raw);
92
+ const isSafeHref = (href) => /^(https?:|mailto:|#|\/)/i.test(href.trim());
93
+ renderer.link = ({ href, title, tokens }) => {
94
+ const text = renderer.parser.parseInline(tokens);
95
+ if (!isSafeHref(href))
96
+ return text;
97
+ const attrs = title ? ` title="${escapeHtml(title)}"` : '';
98
+ return `<a href="${escapeHtml(href)}"${attrs} rel="noopener noreferrer nofollow">${text}</a>`;
99
+ };
100
+ renderer.image = ({ href, title, text }) => {
101
+ if (!isSafeHref(href))
102
+ return escapeHtml(text);
103
+ const attrs = title ? ` title="${escapeHtml(title)}"` : '';
104
+ return `<img src="${escapeHtml(href)}" alt="${escapeHtml(text)}"${attrs} loading="lazy" />`;
105
+ };
106
+ return renderer;
107
+ }
108
+ /** Human duration — "13 minutes", never "12m 49s". */
109
+ export function formatDuration(ms) {
110
+ const minutes = Math.round(ms / 60_000);
111
+ if (minutes < 1)
112
+ return 'under a minute';
113
+ if (minutes < 60)
114
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
115
+ const hours = Math.round(ms / 3_600_000);
116
+ if (hours < 24)
117
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
118
+ const days = Math.round(ms / 86_400_000);
119
+ return `${days} day${days === 1 ? '' : 's'}`;
120
+ }
121
+ /**
122
+ * The chips shown under the title.
123
+ *
124
+ * Deliberately excludes `account` (an email — the publish-time sensitive-content
125
+ * scan rejects those, and correctly), `cwd`, and `machine`: a published transcript
126
+ * should not carry the operator's identity or local paths in its chrome. Host and
127
+ * repo already ride in the share object's provenance metadata, which is the right
128
+ * home for them.
129
+ */
130
+ export function buildChips(session) {
131
+ const chips = [{ label: 'agent', value: session.agent }];
132
+ if (session.model)
133
+ chips.push({ label: 'model', value: session.model });
134
+ if (session.mode)
135
+ chips.push({ label: 'mode', value: session.mode });
136
+ if (session.project)
137
+ chips.push({ label: 'project', value: session.project });
138
+ if (session.gitBranch)
139
+ chips.push({ label: 'branch', value: session.gitBranch });
140
+ if (session.ticketId)
141
+ chips.push({ label: 'ticket', value: session.ticketId });
142
+ const date = (session.timestamp || '').slice(0, 10);
143
+ if (date)
144
+ chips.push({ label: 'date', value: date });
145
+ if (session.durationMs)
146
+ chips.push({ label: 'duration', value: formatDuration(session.durationMs) });
147
+ if (session.messageCount)
148
+ chips.push({ label: 'turns', value: String(session.messageCount) });
149
+ if (session.toolCallCount)
150
+ chips.push({ label: 'tools', value: String(session.toolCallCount) });
151
+ return chips;
152
+ }
153
+ /** Longest title that stays one readable line in the header and the gallery. */
154
+ const TITLE_MAX = 90;
155
+ /**
156
+ * The page's `<title>` — `deriveLabel()` reads this as the share's gallery label.
157
+ *
158
+ * The document heading is derived from the session's first prompt, which in a real
159
+ * session is routinely a pasted URL plus a file path plus the actual request. Left
160
+ * whole it renders as a three-line wall above the transcript and an unreadable
161
+ * gallery row, so it is collapsed to one line and cut at a word boundary. `--label`
162
+ * overrides it outright.
163
+ */
164
+ export function sessionPageTitle(session, markdown) {
165
+ const heading = /^#\s+(.+)$/m.exec(markdown)?.[1]?.replace(/\s+/g, ' ').trim();
166
+ if (!heading)
167
+ return `${session.agent} session ${session.shortId || session.id}`;
168
+ if (heading.length <= TITLE_MAX)
169
+ return heading;
170
+ const cut = heading.slice(0, TITLE_MAX);
171
+ const lastSpace = cut.lastIndexOf(' ');
172
+ // A single unbroken token longer than the cap (a URL) has no boundary to cut on;
173
+ // a hard slice beats returning the whole thing.
174
+ return `${(lastSpace > TITLE_MAX / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
175
+ }
176
+ export function renderSessionHtmlDocument(session, markdown, options = {}) {
177
+ const title = sessionPageTitle(session, markdown);
178
+ // The <h1> is re-rendered as the page header, so drop it from the body to
179
+ // avoid printing the title twice.
180
+ const { text, blocks } = liftFoldBlocks(markdown.replace(/^#\s+.+\n/, ''));
181
+ const render = (md) => marked.parse(md, { renderer: safeRenderer(), async: false });
182
+ // Reconstruct each disclosure element around its own separately-rendered
183
+ // content, so the open/close pairing is ours and cannot be unbalanced by the
184
+ // transcript.
185
+ const body = blocks.reduce((html, inner, i) => html.replace(new RegExp(`<p>${FOLD_SENTINEL}${i}${FOLD_SENTINEL}</p>`),
186
+ // A FUNCTION replacement, never a string one. String.replace expands `$&`,
187
+ // `` $` ``, `$'` and `$n` inside a string replacement, and marked turns
188
+ // `& < > " '` into entities — so reasoning text containing `$'` (bash
189
+ // ANSI-C quoting), `$&` (sed), or `$<` (make) would splice the matched
190
+ // sentinel back into the page and destroy the character after it. A
191
+ // function's return value is inserted verbatim.
192
+ () => `<details><summary>Reasoning</summary>\n${render(inner)}</details>`), render(text));
193
+ const chips = buildChips(session)
194
+ .map((c) => `<span class="chip"><span class="k">${escapeHtml(c.label)}</span>${escapeHtml(c.value)}</span>`)
195
+ .join('\n ');
196
+ const redacted = options.redacted !== false;
197
+ return `<!DOCTYPE html>
198
+ <html lang="en" data-theme="auto">
199
+ <head>
200
+ <meta charset="utf-8" />
201
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
202
+ <meta name="robots" content="noindex" />
203
+ <title>${escapeHtml(title)}</title>
204
+ <style>
205
+ :root {
206
+ --bg: #0a0a0a; --panel: #121212; --border: #262626; --fg: #e5e5e5;
207
+ --dim: #737373; --accent: #a3e635; --quote: #1a1a1a;
208
+ }
209
+ html[data-theme="light"] {
210
+ --bg: #fafafa; --panel: #ffffff; --border: #e5e5e5; --fg: #171717;
211
+ --dim: #737373; --accent: #4d7c0f; --quote: #f5f5f5;
212
+ }
213
+ @media (prefers-color-scheme: light) {
214
+ html[data-theme="auto"] {
215
+ --bg: #fafafa; --panel: #ffffff; --border: #e5e5e5; --fg: #171717;
216
+ --dim: #737373; --accent: #4d7c0f; --quote: #f5f5f5;
217
+ }
218
+ }
219
+ * { box-sizing: border-box; }
220
+ body {
221
+ margin: 0; background: var(--bg); color: var(--fg);
222
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, sans-serif;
223
+ font-size: 15px; line-height: 1.65;
224
+ }
225
+ header {
226
+ border-bottom: 1px solid var(--border); padding: 28px 20px 20px;
227
+ }
228
+ header .inner, main { max-width: 900px; margin: 0 auto; }
229
+ header .mark {
230
+ color: var(--accent); font-weight: 700; letter-spacing: .5px; font-size: 12px;
231
+ text-transform: uppercase; font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
232
+ }
233
+ header h1 { font-size: 24px; line-height: 1.3; margin: 10px 0 14px; }
234
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
235
+ .chip {
236
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px;
237
+ color: var(--fg); background: var(--panel); border: 1px solid var(--border);
238
+ border-radius: 10px; padding: 2px 9px;
239
+ }
240
+ .chip .k { color: var(--dim); margin-right: 6px; }
241
+ .toggle {
242
+ float: right; cursor: pointer; background: none; border: 1px solid var(--border);
243
+ color: var(--dim); border-radius: 6px; padding: 2px 8px; font-size: 14px;
244
+ }
245
+ main { padding: 24px 20px 64px; }
246
+ h2 {
247
+ font-size: 13px; color: var(--accent); border-bottom: 1px solid var(--border);
248
+ padding-bottom: 6px; margin: 36px 0 14px; text-transform: uppercase;
249
+ letter-spacing: 1px; font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
250
+ }
251
+ h3 { font-size: 15px; margin: 26px 0 8px; }
252
+ h4 { font-size: 13px; color: var(--dim); margin: 20px 0 6px; font-weight: 600; }
253
+ a { color: var(--accent); }
254
+ blockquote {
255
+ margin: 0 0 20px; padding: 12px 16px; background: var(--quote);
256
+ border-left: 2px solid var(--border); border-radius: 0 6px 6px 0; color: var(--dim);
257
+ }
258
+ blockquote p { margin: 0 0 6px; }
259
+ blockquote p:last-child { margin: 0; }
260
+ pre {
261
+ background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
262
+ padding: 12px 14px; overflow-x: auto;
263
+ }
264
+ code {
265
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 12.5px;
266
+ }
267
+ :not(pre) > code {
268
+ background: var(--panel); border: 1px solid var(--border);
269
+ border-radius: 4px; padding: 1px 5px;
270
+ }
271
+ hr { border: none; border-top: 1px solid var(--border); margin: 32px 0; }
272
+ details { margin: 0 0 12px; }
273
+ summary { cursor: pointer; color: var(--dim); font-size: 13px; }
274
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
275
+ th, td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
276
+ footer {
277
+ max-width: 900px; margin: 0 auto; padding: 0 20px 48px;
278
+ color: var(--dim); font-size: 12px;
279
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
280
+ }
281
+ footer a { color: var(--dim); }
282
+ </style>
283
+ </head>
284
+ <body>
285
+ <header>
286
+ <div class="inner">
287
+ <button class="toggle" id="theme" title="Toggle light and dark">&#9689;</button>
288
+ <div class="mark">agents session</div>
289
+ <h1>${escapeHtml(title)}</h1>
290
+ <div class="chips">
291
+ ${chips}
292
+ </div>
293
+ </div>
294
+ </header>
295
+ <main>
296
+ ${body}
297
+ </main>
298
+ <footer>
299
+ ${redacted ? 'Secret-redacted transcript' : 'Unredacted transcript'} rendered by
300
+ <a href="https://agents-cli.sh">agents-cli</a> &middot; <code>agents sessions share</code>
301
+ </footer>
302
+ <script>
303
+ (function () {
304
+ var root = document.documentElement;
305
+ var saved = null;
306
+ try { saved = localStorage.getItem('agents-share-theme'); } catch (e) {}
307
+ if (saved) root.setAttribute('data-theme', saved);
308
+ document.getElementById('theme').addEventListener('click', function () {
309
+ var dark = getComputedStyle(root).getPropertyValue('--bg').trim() === '#0a0a0a';
310
+ var next = dark ? 'light' : 'dark';
311
+ root.setAttribute('data-theme', next);
312
+ try { localStorage.setItem('agents-share-theme', next); } catch (e) {}
313
+ });
314
+ })();
315
+ </script>
316
+ </body>
317
+ </html>
318
+ `;
319
+ }
@@ -13,6 +13,8 @@
13
13
  * version home. It never overwrites a value the target already has: scalars
14
14
  * keep the target's value, objects merge recursively, arrays union. That makes
15
15
  * the operation idempotent and safe to run on every `agents add` / `agents use`.
16
+ * (One scoped exception: the 'claude-trust' strategy promotes a stamped-default
17
+ * `hasTrustDialogAccepted: false` to `true` — see the note on that entry.)
16
18
  */
17
19
  import type { AgentId } from './types.js';
18
20
  export interface CarryForwardResult {
@@ -13,16 +13,26 @@
13
13
  * version home. It never overwrites a value the target already has: scalars
14
14
  * keep the target's value, objects merge recursively, arrays union. That makes
15
15
  * the operation idempotent and safe to run on every `agents add` / `agents use`.
16
+ * (One scoped exception: the 'claude-trust' strategy promotes a stamped-default
17
+ * `hasTrustDialogAccepted: false` to `true` — see the note on that entry.)
16
18
  */
17
19
  import * as fs from 'fs';
18
20
  import * as path from 'path';
19
21
  import * as TOML from 'smol-toml';
22
+ import { atomicWriteFileSync } from './fs-atomic.js';
20
23
  import { getBackupsDir } from './state.js';
21
24
  const SETTINGS_MANIFEST = {
22
25
  claude: [
23
26
  { rel: '.claude/settings.json', strategy: 'json-merge' },
24
27
  { rel: '.claude/settings.local.json', strategy: 'copy-if-absent' },
25
28
  { rel: '.claude/keybindings.json', strategy: 'copy-if-absent' },
29
+ // `.claude.json` holds the login (oauthAccount) and per-session stats, so it
30
+ // must never merge wholesale — but it is also where Claude records workspace
31
+ // trust (`projects[<path>].hasTrustDialogAccepted`). Without carrying that,
32
+ // every newly pinned version re-shows the trust dialog once per project
33
+ // (issue #2776). The 'claude-trust' strategy projects ONLY the trust flags
34
+ // out of the source file; credentials and stats stay per-version.
35
+ { rel: '.claude.json', strategy: 'claude-trust' },
26
36
  ],
27
37
  codex: [
28
38
  {
@@ -33,9 +43,10 @@ const SETTINGS_MANIFEST = {
33
43
  // `.codex/auth.json` is deliberately NOT carried forward. Copying it seeded
34
44
  // every new Codex version with the current default's ChatGPT token, so two
35
45
  // installed versions always reported the same account and could never sign
36
- // into separate accounts. Claude omits its credential (`.claude.json`) for
37
- // the same reason a version home holds its own login, keeping accounts
38
- // per-version. A fresh Codex version installs signed-out; run `codex login`
46
+ // into separate accounts. Claude never merges `.claude.json` for the same
47
+ // reason (its 'claude-trust' entry extracts only the trust flags) a
48
+ // version home holds its own login, keeping accounts per-version. A fresh
49
+ // Codex version installs signed-out; run `codex login`
39
50
  // (or `agents run codex --version <v>`) inside it to authenticate.
40
51
  { rel: '.codex/instructions.md', strategy: 'copy-if-absent' },
41
52
  { rel: '.codex/hooks.json', strategy: 'copy-if-absent' },
@@ -70,6 +81,21 @@ export function fillGaps(target, source) {
70
81
  }
71
82
  return out;
72
83
  }
84
+ /**
85
+ * Project paths the source `.claude.json` records an accepted trust dialog for.
86
+ * Only an explicit `true` counts: Claude Code never persists a decline (the
87
+ * dialog exits without writing), so `false` is only ever the stamped default —
88
+ * not a user decision worth propagating. Verified against Claude Code
89
+ * 2.1.219/2.1.220 (decline paths exit via code 1 / code 0 without a config
90
+ * write); if a future Claude Code starts persisting declines, this premise —
91
+ * and the false→true promotion in the 'claude-trust' case — must be revisited.
92
+ */
93
+ function trustedClaudeProjects(source) {
94
+ const projects = isPlainObject(source.projects) ? source.projects : {};
95
+ return Object.entries(projects)
96
+ .filter(([, project]) => isPlainObject(project) && project.hasTrustDialogAccepted === true)
97
+ .map(([projectPath]) => projectPath);
98
+ }
73
99
  function stripStateKeys(obj, stateKeys) {
74
100
  if (!stateKeys?.length)
75
101
  return obj;
@@ -128,6 +154,58 @@ export function carryForwardSettings(agent, fromHome, toHome) {
128
154
  result.applied.push(entry.rel);
129
155
  break;
130
156
  }
157
+ case 'claude-trust': {
158
+ // Projection, not a merge: pull ONLY `projects[<path>].hasTrustDialogAccepted`
159
+ // out of the source `.claude.json`. Everything else in that file
160
+ // (oauthAccount, onboarding state, per-session stats) stays per-version.
161
+ // Trust granted anywhere wins over the target's stamped-default `false`
162
+ // (headless runs create project entries with the flag unset-as-false
163
+ // without ever showing the dialog), but a target entry's other keys
164
+ // are preserved untouched.
165
+ const source = JSON.parse(fs.readFileSync(sourcePath, 'utf-8'));
166
+ const trusted = trustedClaudeProjects(source);
167
+ if (trusted.length === 0)
168
+ break;
169
+ const targetExists = fs.existsSync(targetPath);
170
+ const parsedTarget = targetExists
171
+ ? JSON.parse(fs.readFileSync(targetPath, 'utf-8'))
172
+ : {};
173
+ // A target that isn't an object (or whose `projects` isn't) is not
174
+ // ours to repair — skip rather than clobber it with a rebuilt shape.
175
+ if (!isPlainObject(parsedTarget))
176
+ break;
177
+ const targetObj = parsedTarget;
178
+ if ('projects' in targetObj && !isPlainObject(targetObj.projects))
179
+ break;
180
+ const targetProjects = isPlainObject(targetObj.projects) ? { ...targetObj.projects } : {};
181
+ let changed = false;
182
+ for (const projectPath of trusted) {
183
+ const existing = isPlainObject(targetProjects[projectPath])
184
+ ? targetProjects[projectPath]
185
+ : {};
186
+ if (existing.hasTrustDialogAccepted === true)
187
+ continue;
188
+ targetProjects[projectPath] = { ...existing, hasTrustDialogAccepted: true };
189
+ changed = true;
190
+ }
191
+ if (!changed)
192
+ break;
193
+ if (targetExists) {
194
+ backupFile(backupRoot, toHome, entry.rel);
195
+ result.backupDir = backupRoot;
196
+ }
197
+ else {
198
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
199
+ }
200
+ // Atomic (tmp + rename): a running Claude session rewrites this exact
201
+ // file (it holds the login and session stats), so a plain write risks
202
+ // a reader seeing a partial file. The OUTSIDE `.claude.json` is the
203
+ // real file (the INSIDE `.claude/.claude.json` symlink resolves to it
204
+ // and survives the rename).
205
+ atomicWriteFileSync(targetPath, JSON.stringify({ ...targetObj, projects: targetProjects }, null, 2) + '\n');
206
+ result.applied.push(entry.rel);
207
+ break;
208
+ }
131
209
  case 'json-merge':
132
210
  case 'toml-merge': {
133
211
  const parse = entry.strategy === 'json-merge'