@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.
- package/CHANGELOG.md +22 -0
- package/README.md +5 -0
- package/dist/bin/agents +0 -0
- package/dist/bootstrap.js +0 -1
- package/dist/cli/command-registry.js +1 -2
- package/dist/commands/browser.d.ts +15 -0
- package/dist/commands/browser.js +115 -40
- package/dist/commands/feed.js +3 -11
- package/dist/commands/secrets.js +87 -97
- package/dist/commands/sessions-picker.d.ts +1 -0
- package/dist/commands/sessions-picker.js +27 -5
- package/dist/commands/sessions-share.d.ts +25 -0
- package/dist/commands/sessions-share.js +166 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/setup-browser.js +1 -1
- package/dist/commands/setup-preferences.js +2 -2
- package/dist/commands/webhook.js +14 -9
- package/dist/lib/browser/cdp.js +4 -0
- package/dist/lib/browser/chrome.js +12 -1
- package/dist/lib/browser/drivers/ssh.js +1 -0
- package/dist/lib/browser/profiles.d.ts +3 -3
- package/dist/lib/browser/profiles.js +7 -7
- package/dist/lib/browser/service.d.ts +24 -1
- package/dist/lib/browser/service.js +38 -14
- package/dist/lib/browser/types.d.ts +1 -1
- package/dist/lib/daemon-webhooks.d.ts +3 -1
- package/dist/lib/daemon-webhooks.js +12 -7
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/observe-aliases.d.ts +2 -2
- package/dist/lib/observe-aliases.js +2 -11
- package/dist/lib/project-key.js +7 -0
- package/dist/lib/runner.js +21 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +1 -1
- package/dist/lib/secrets/headless.d.ts +16 -0
- package/dist/lib/secrets/headless.js +21 -0
- package/dist/lib/secrets/remote.d.ts +9 -7
- package/dist/lib/secrets/remote.js +18 -9
- package/dist/lib/session/share-html.d.ts +55 -0
- package/dist/lib/session/share-html.js +319 -0
- package/dist/lib/settings-manifest.d.ts +2 -0
- package/dist/lib/settings-manifest.js +81 -3
- package/dist/lib/share/publish.d.ts +38 -0
- package/dist/lib/share/publish.js +81 -8
- package/dist/lib/startup/command-registry.d.ts +2 -1
- package/dist/lib/startup/command-registry.js +4 -2
- package/dist/lib/triggers/handlers.d.ts +37 -2
- package/dist/lib/triggers/handlers.js +56 -5
- package/dist/lib/triggers/webhook.d.ts +80 -6
- package/dist/lib/triggers/webhook.js +127 -3
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/wrap.d.ts +33 -0
- package/dist/lib/wrap.js +70 -0
- 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
|
|
173
|
-
* json
|
|
174
|
-
* any keychain read — no Touch ID prompt) and confirming every pushed key
|
|
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
|
|
197
|
-
*
|
|
198
|
-
* are dropped immediately and never retained or
|
|
199
|
-
* verdict; the caller renders
|
|
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
|
-
|
|
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
|
|
303
|
-
* json
|
|
304
|
-
* any keychain read — no Touch ID prompt) and confirming every pushed key
|
|
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
|
|
363
|
-
*
|
|
364
|
-
* are dropped immediately and never retained or
|
|
365
|
-
* verdict; the caller renders
|
|
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, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
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 `<details>` 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">◙</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> · <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
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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'
|