@phnx-labs/agents-cli 1.22.103 → 1.22.105

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 (68) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +2 -2
  4. package/dist/commands/computer.d.ts +100 -79
  5. package/dist/commands/computer.js +290 -830
  6. package/dist/commands/run-device-picker.d.ts +58 -0
  7. package/dist/commands/run-device-picker.js +222 -0
  8. package/dist/commands/setup-computer.js +20 -1
  9. package/dist/commands/setup-secrets.d.ts +2 -2
  10. package/dist/commands/setup-secrets.js +1 -1
  11. package/dist/commands/view.js +4 -6
  12. package/dist/lib/account-catalog.d.ts +22 -1
  13. package/dist/lib/account-catalog.js +72 -38
  14. package/dist/lib/accounting/usage.js +18 -14
  15. package/dist/lib/agent-spec/agents.d.ts +1 -0
  16. package/dist/lib/agent-spec/agents.js +1 -1
  17. package/dist/lib/browser/drivers/ssh.js +1 -1
  18. package/dist/lib/computer/context.d.ts +83 -0
  19. package/dist/lib/computer/context.js +91 -0
  20. package/dist/lib/computer/policy.d.ts +46 -0
  21. package/dist/lib/computer/policy.js +160 -0
  22. package/dist/lib/computer/record.d.ts +38 -0
  23. package/dist/lib/computer/record.js +86 -0
  24. package/dist/lib/computer/sessions-list.js +6 -6
  25. package/dist/lib/computer-client.d.ts +150 -0
  26. package/dist/lib/computer-client.js +222 -0
  27. package/dist/lib/exec.js +35 -1
  28. package/dist/lib/harness/adapters/claude.d.ts +37 -0
  29. package/dist/lib/harness/adapters/claude.js +69 -0
  30. package/dist/lib/helper-download.d.ts +1 -1
  31. package/dist/lib/helper-download.js +1 -1
  32. package/dist/lib/helper-versions.d.ts +9 -4
  33. package/dist/lib/helper-versions.js +8 -8
  34. package/dist/lib/installations/shims.js +8 -4
  35. package/dist/lib/menubar/download-menubar.d.ts +2 -1
  36. package/dist/lib/menubar/download-menubar.js +2 -1
  37. package/dist/lib/secrets-client.d.ts +3 -3
  38. package/dist/lib/secrets-client.js +15 -38
  39. package/dist/lib/session/db.js +1 -1
  40. package/dist/lib/sha256-asset.d.ts +2 -1
  41. package/dist/lib/sha256-asset.js +2 -1
  42. package/dist/lib/ssh-tunnel.d.ts +61 -0
  43. package/dist/lib/ssh-tunnel.js +105 -0
  44. package/dist/lib/summarizer/summarize.d.ts +2 -2
  45. package/dist/lib/summarizer/summarize.js +10 -3
  46. package/package.json +2 -3
  47. package/dist/commands/computer-actions.d.ts +0 -55
  48. package/dist/commands/computer-actions.js +0 -594
  49. package/dist/computer.d.ts +0 -2
  50. package/dist/computer.js +0 -7
  51. package/dist/lib/computer/actions.d.ts +0 -36
  52. package/dist/lib/computer/actions.js +0 -162
  53. package/dist/lib/computer/computer-rpc.d.ts +0 -39
  54. package/dist/lib/computer/computer-rpc.js +0 -447
  55. package/dist/lib/computer/des.d.ts +0 -1
  56. package/dist/lib/computer/des.js +0 -114
  57. package/dist/lib/computer/dispatch.d.ts +0 -10
  58. package/dist/lib/computer/dispatch.js +0 -133
  59. package/dist/lib/computer/download.d.ts +0 -54
  60. package/dist/lib/computer/download.js +0 -83
  61. package/dist/lib/computer/loop.d.ts +0 -62
  62. package/dist/lib/computer/loop.js +0 -98
  63. package/dist/lib/computer/model.d.ts +0 -44
  64. package/dist/lib/computer/model.js +0 -157
  65. package/dist/lib/computer/rfb-client.d.ts +0 -53
  66. package/dist/lib/computer/rfb-client.js +0 -562
  67. package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
  68. package/dist/lib/computer/ssh-tunnel.js +0 -584
@@ -134,7 +134,7 @@ export function findInPath(command, options = {}) {
134
134
  if (process.platform !== 'win32')
135
135
  fs.accessSync(full, fs.constants.X_OK);
136
136
  const native = resolveNativeBinaryPath(command, full, options);
137
- if (native)
137
+ if (native && (!options.accept || options.accept(native)))
138
138
  return native;
139
139
  }
140
140
  catch {
@@ -11,7 +11,7 @@ export { shellQuote };
11
11
  // The `ssh -L` tunnel spawn is shared with `agents computer --device`; it lives in
12
12
  // the single ssh-tunnel helper. Calling it with no options preserves this
13
13
  // driver's original foreground, stderr-captured behavior exactly.
14
- import { startSSHTunnel } from '../../computer/ssh-tunnel.js';
14
+ import { startSSHTunnel } from '../../ssh-tunnel.js';
15
15
  import { encodePwshBase64 } from '../../pwsh.js';
16
16
  /** Lifecycle for a registry-driven tunnel vs a profile the daemon launched. */
17
17
  export function persistRemoteLifecycle(persistRemote) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * context.ts — everything agents-cli knows that the standalone `computer`
3
+ * engine cannot work out for itself, serialized as one JSON object onto fd 3.
4
+ *
5
+ * This is the whole contract of the consumer half, and it is deliberately four
6
+ * fields wide. The engine accepts exactly this shape:
7
+ *
8
+ * version `1`. The engine matches on it; a field added later must be
9
+ * optional so an older engine keeps working.
10
+ * permissions which apps are allowed, derived from `Computer(<bundle-id>)`
11
+ * rules in the agents permissions resource layer. The engine
12
+ * would have to re-learn resource layering to compute this.
13
+ * peers which executables the daemon accepts a connection from.
14
+ * target the `--device <name>` target, resolved against the fleet:
15
+ * devices registry, ssh identity, platform. The engine matches
16
+ * its own `--device <alias>` against this instead of reading a
17
+ * registry it does not have. Absent for a local invocation.
18
+ * session who is acting — actor id and agent session — so an action
19
+ * lands in the right session history.
20
+ *
21
+ * WHAT IS DELIBERATELY NOT HERE. The transport (`COMPUTER_HELPER_TCP`,
22
+ * `COMPUTER_HELPER_VNC`, `COMPUTER_HELPER_SOCKET`), the remote helper's auth
23
+ * token, and the policy-file paths are the ENGINE's: it provisions the Windows
24
+ * helper, mints and stores the token, opens the `ssh -L` tunnel and hydrates its
25
+ * own transport from the state it wrote. agents-cli publishing a loopback
26
+ * endpoint here (or on `COMPUTER_HELPER_TCP`) would be a second, drifting copy
27
+ * of that answer — and a copy without the token, which the daemon rejects with
28
+ * `auth_failed`. Service-manager safety (launchd/systemd registration under a
29
+ * redirected HOME) is likewise the standalone's own: it inherits `HOME` and
30
+ * `AGENTS_REAL_HOME` and renders its own manifest, so agents-cli neither
31
+ * computes a label nor issues a verdict for it.
32
+ *
33
+ * The context is PUSHED (written and closed) rather than exposed as a callback,
34
+ * so the engine never re-enters agents-cli and there is exactly one direction of
35
+ * dependency.
36
+ */
37
+ /** A `--device <name>` target, resolved against the fleet. */
38
+ export interface ComputerTargetContext {
39
+ /** The device name as the user typed it. */
40
+ alias: string;
41
+ /** `user@host`, already validated against ssh option injection. */
42
+ host: string;
43
+ user: string;
44
+ /** Bare host — the ssh-config Host name or address, without the user. */
45
+ hostname: string;
46
+ platform: string;
47
+ /** Per-device ssh identity flags, in argv order. Possibly empty. */
48
+ sshArgs: string[];
49
+ }
50
+ /** Who is acting, so the engine can stamp the action it reports back. */
51
+ export interface ComputerSessionContext {
52
+ sessionId?: string;
53
+ launchId?: string;
54
+ actor: string;
55
+ }
56
+ export interface ComputerContext {
57
+ version: 1;
58
+ permissions?: {
59
+ allow: string[];
60
+ };
61
+ peers: {
62
+ allow: string[];
63
+ };
64
+ target?: ComputerTargetContext;
65
+ session: ComputerSessionContext;
66
+ }
67
+ export interface BuildContextOptions {
68
+ /** `--device <name>`, if given. */
69
+ device?: string;
70
+ /** Direct host targeting, which bypasses fleet resolution. */
71
+ host?: string;
72
+ /** Resolved path of the standalone executable, for the peer allow list. */
73
+ computerBin?: string;
74
+ }
75
+ /**
76
+ * Build the context handed to the engine on fd 3.
77
+ *
78
+ * `device` resolution goes through the shared fleet resolver and keeps the
79
+ * Windows expectation the computer subsystem has always enforced — a
80
+ * `--device` pointing at a Mac gets the same refusal as before, from the fleet
81
+ * layer that can actually see the device's platform.
82
+ */
83
+ export declare function buildComputerContext(opts?: BuildContextOptions): Promise<ComputerContext>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * context.ts — everything agents-cli knows that the standalone `computer`
3
+ * engine cannot work out for itself, serialized as one JSON object onto fd 3.
4
+ *
5
+ * This is the whole contract of the consumer half, and it is deliberately four
6
+ * fields wide. The engine accepts exactly this shape:
7
+ *
8
+ * version `1`. The engine matches on it; a field added later must be
9
+ * optional so an older engine keeps working.
10
+ * permissions which apps are allowed, derived from `Computer(<bundle-id>)`
11
+ * rules in the agents permissions resource layer. The engine
12
+ * would have to re-learn resource layering to compute this.
13
+ * peers which executables the daemon accepts a connection from.
14
+ * target the `--device <name>` target, resolved against the fleet:
15
+ * devices registry, ssh identity, platform. The engine matches
16
+ * its own `--device <alias>` against this instead of reading a
17
+ * registry it does not have. Absent for a local invocation.
18
+ * session who is acting — actor id and agent session — so an action
19
+ * lands in the right session history.
20
+ *
21
+ * WHAT IS DELIBERATELY NOT HERE. The transport (`COMPUTER_HELPER_TCP`,
22
+ * `COMPUTER_HELPER_VNC`, `COMPUTER_HELPER_SOCKET`), the remote helper's auth
23
+ * token, and the policy-file paths are the ENGINE's: it provisions the Windows
24
+ * helper, mints and stores the token, opens the `ssh -L` tunnel and hydrates its
25
+ * own transport from the state it wrote. agents-cli publishing a loopback
26
+ * endpoint here (or on `COMPUTER_HELPER_TCP`) would be a second, drifting copy
27
+ * of that answer — and a copy without the token, which the daemon rejects with
28
+ * `auth_failed`. Service-manager safety (launchd/systemd registration under a
29
+ * redirected HOME) is likewise the standalone's own: it inherits `HOME` and
30
+ * `AGENTS_REAL_HOME` and renders its own manifest, so agents-cli neither
31
+ * computes a label nor issues a verdict for it.
32
+ *
33
+ * The context is PUSHED (written and closed) rather than exposed as a callback,
34
+ * so the engine never re-enters agents-cli and there is exactly one direction of
35
+ * dependency.
36
+ */
37
+ import { resolveActor } from '../actor.js';
38
+ import { loadComputerAllowList, loadDefaultPeers } from './policy.js';
39
+ import { resolveRemoteDevice } from '../ssh-tunnel.js';
40
+ /**
41
+ * Which agent session is acting. Same precedence the admission cache used
42
+ * before the extraction — the harness-native id first, then agents' own.
43
+ */
44
+ function agentSessionId(env = process.env) {
45
+ return env.CODEX_THREAD_ID
46
+ || env.CLAUDE_CODE_SESSION_ID
47
+ || env.CLAUDE_SESSION_ID
48
+ || env.AGENTS_SESSION_ID
49
+ || env.AGENT_SESSION_ID
50
+ || env.AGENTS_RUN_ID
51
+ || undefined;
52
+ }
53
+ /**
54
+ * Build the context handed to the engine on fd 3.
55
+ *
56
+ * `device` resolution goes through the shared fleet resolver and keeps the
57
+ * Windows expectation the computer subsystem has always enforced — a
58
+ * `--device` pointing at a Mac gets the same refusal as before, from the fleet
59
+ * layer that can actually see the device's platform.
60
+ */
61
+ export async function buildComputerContext(opts = {}) {
62
+ let target;
63
+ if (opts.device) {
64
+ const resolved = await resolveRemoteDevice(opts.device, {
65
+ expectPlatform: 'windows',
66
+ forWhat: '`agents computer --device` drives the Windows computer-helper daemon, so it',
67
+ });
68
+ target = {
69
+ alias: opts.device,
70
+ host: resolved.target,
71
+ user: resolved.user,
72
+ hostname: resolved.host,
73
+ platform: resolved.device.platform,
74
+ sshArgs: resolved.identityArgs,
75
+ };
76
+ }
77
+ return {
78
+ version: 1,
79
+ ...(!opts.device && !opts.host && !process.env.COMPUTER_HELPER_TCP && !process.env.COMPUTER_HELPER_VNC
80
+ ? { permissions: { allow: loadComputerAllowList() } } : {}),
81
+ peers: { allow: loadDefaultPeers({ computerBin: opts.computerBin }) },
82
+ // Spread rather than assigned: a local invocation must not ship a `target`
83
+ // key at all, so the engine never has to distinguish absent from null.
84
+ ...(target ? { target } : {}),
85
+ session: {
86
+ sessionId: agentSessionId(),
87
+ launchId: process.env.AGENT_LAUNCH_ID,
88
+ actor: resolveActor().id,
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,46 @@
1
+ /** Resolve Agents permission groups and caller identities for the standalone
2
+ * engine. The engine alone writes helper policy and peer files. */
3
+ export declare function loadComputerAllowList(): string[];
4
+ /**
5
+ * Default peer set: the standalone `computer` executable, this `agents` CLI's
6
+ * own runtime, plus Rush.app if it's installed. realpath() the symlink chain so
7
+ * we record the on-disk path the helper will see via proc_pidpath, not the shim
8
+ * path.
9
+ *
10
+ * The standalone's path is the one that changed with PHNX-4075: the daemon's
11
+ * caller is now the engine process, not this CLI. `agents`' own execPath stays
12
+ * on the list because the engine may be a `.js` bin run through this same
13
+ * runtime (`invocation()` in computer-client.ts), in which case proc_pidpath
14
+ * still reports the runtime.
15
+ *
16
+ * Why path-based instead of codesign-team-id? The agents CLI is unsigned
17
+ * today (npm distribution), and even if we sign Rush.app the team-id
18
+ * check would need a separate roundtrip. Path is concrete and fast; the
19
+ * daemon already runs as the user so anyone who can swap a binary at
20
+ * these paths can do worse via other means.
21
+ */
22
+ export declare function loadDefaultPeers(opts?: {
23
+ computerBin?: string;
24
+ }): string[];
25
+ /**
26
+ * Parse a `host:port` VNC endpoint, defaulting the port to 5901. Pure.
27
+ *
28
+ * Kept on the consumer side because the `--vnc` FLAG is parsed here — the
29
+ * platform gate has to know whether a remote desktop was named before the
30
+ * engine is ever spawned (see `shouldBlockOffPlatform`). The RFB protocol
31
+ * implementation itself went to the engine.
32
+ */
33
+ export declare function parseVncEndpoint(raw: string | undefined): {
34
+ host: string;
35
+ port: number;
36
+ } | null;
37
+ export declare function resolveTcpEndpoint(): {
38
+ host: string;
39
+ port: number;
40
+ token: string | null;
41
+ } | null;
42
+ export declare function resolveVncEndpoint(): {
43
+ host: string;
44
+ port: number;
45
+ password: string;
46
+ } | null;
@@ -0,0 +1,160 @@
1
+ /** Resolve Agents permission groups and caller identities for the standalone
2
+ * engine. The engine alone writes helper policy and peer files. */
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { getUserPermissionsDir, getPermissionsDir } from '../state.js';
6
+ // Walk all permission group YAMLs (user dir wins on name collision) and
7
+ // collect Computer(<bundle-id>) patterns from each group's `allow:` list.
8
+ // Returns distinct bundle ids. Line-by-line regex extraction matches
9
+ // buildPermissionsFromGroups: YAML parsers stumble on the nested quotes in
10
+ // some rule values, but the strict pattern below catches our shape cleanly.
11
+ export function loadComputerAllowList() {
12
+ const seenFiles = new Set();
13
+ const allowed = new Set();
14
+ for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
15
+ const groupsDir = path.join(baseDir, 'groups');
16
+ if (!fs.existsSync(groupsDir))
17
+ continue;
18
+ let entries;
19
+ try {
20
+ entries = fs.readdirSync(groupsDir, { withFileTypes: true });
21
+ }
22
+ catch {
23
+ continue;
24
+ }
25
+ for (const entry of entries) {
26
+ if (!entry.isFile())
27
+ continue;
28
+ if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml'))
29
+ continue;
30
+ // User dir wins on filename collision.
31
+ const stem = entry.name.replace(/\.(yaml|yml)$/, '');
32
+ if (seenFiles.has(stem))
33
+ continue;
34
+ seenFiles.add(stem);
35
+ const filePath = path.join(groupsDir, entry.name);
36
+ let content;
37
+ try {
38
+ content = fs.readFileSync(filePath, 'utf-8');
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ // Strict regex: optional whitespace, dash, quoted Computer(<id>).
44
+ // Only honors `allow:` lines — `deny:` Computer patterns would be a
45
+ // contradiction (everything is deny-by-default already).
46
+ let inAllow = false;
47
+ for (const rawLine of content.split('\n')) {
48
+ const line = rawLine.replace(/\r$/, '');
49
+ const sectionMatch = line.match(/^(\w+)\s*:\s*$/);
50
+ if (sectionMatch) {
51
+ inAllow = sectionMatch[1] === 'allow';
52
+ continue;
53
+ }
54
+ if (!inAllow)
55
+ continue;
56
+ const ruleMatch = line.match(/^\s*-\s*"Computer\(([^)]+)\)"\s*$/);
57
+ if (ruleMatch) {
58
+ const bundleId = ruleMatch[1].trim();
59
+ if (bundleId.length > 0)
60
+ allowed.add(bundleId);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return [...allowed].sort();
66
+ }
67
+ /**
68
+ * Default peer set: the standalone `computer` executable, this `agents` CLI's
69
+ * own runtime, plus Rush.app if it's installed. realpath() the symlink chain so
70
+ * we record the on-disk path the helper will see via proc_pidpath, not the shim
71
+ * path.
72
+ *
73
+ * The standalone's path is the one that changed with PHNX-4075: the daemon's
74
+ * caller is now the engine process, not this CLI. `agents`' own execPath stays
75
+ * on the list because the engine may be a `.js` bin run through this same
76
+ * runtime (`invocation()` in computer-client.ts), in which case proc_pidpath
77
+ * still reports the runtime.
78
+ *
79
+ * Why path-based instead of codesign-team-id? The agents CLI is unsigned
80
+ * today (npm distribution), and even if we sign Rush.app the team-id
81
+ * check would need a separate roundtrip. Path is concrete and fast; the
82
+ * daemon already runs as the user so anyone who can swap a binary at
83
+ * these paths can do worse via other means.
84
+ */
85
+ export function loadDefaultPeers(opts = {}) {
86
+ const out = new Set();
87
+ const add = (p) => {
88
+ try {
89
+ out.add(fs.realpathSync(p));
90
+ }
91
+ catch {
92
+ out.add(p);
93
+ }
94
+ };
95
+ // The standalone engine — the process that actually opens the socket now.
96
+ if (opts.computerBin)
97
+ add(opts.computerBin);
98
+ // The runtime currently running this CLI. Still a possible proc_pidpath when
99
+ // the engine is a .js bin executed through it.
100
+ if (process.execPath)
101
+ add(process.execPath);
102
+ // Rush.app — the consumer Electron client. Both the helper-binary and
103
+ // the main app binary are possible callers depending on how Rush wires
104
+ // the RPC client.
105
+ const rushCandidates = [
106
+ '/Applications/Rush.app/Contents/MacOS/Rush',
107
+ '/Applications/Rush.app/Contents/MacOS/Electron',
108
+ ];
109
+ for (const p of rushCandidates) {
110
+ if (fs.existsSync(p))
111
+ add(p);
112
+ }
113
+ return [...out].sort();
114
+ }
115
+ /**
116
+ * Parse a `host:port` VNC endpoint, defaulting the port to 5901. Pure.
117
+ *
118
+ * Kept on the consumer side because the `--vnc` FLAG is parsed here — the
119
+ * platform gate has to know whether a remote desktop was named before the
120
+ * engine is ever spawned (see `shouldBlockOffPlatform`). The RFB protocol
121
+ * implementation itself went to the engine.
122
+ */
123
+ export function parseVncEndpoint(raw) {
124
+ if (!raw || raw.length === 0)
125
+ return null;
126
+ const idx = raw.lastIndexOf(':');
127
+ const host = idx >= 0 ? raw.slice(0, idx) : raw;
128
+ const portStr = idx >= 0 ? raw.slice(idx + 1) : '5901';
129
+ const port = Number(portStr);
130
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
131
+ return null;
132
+ return { host: host || '127.0.0.1', port };
133
+ }
134
+ // Resolve the TCP endpoint for a remote daemon (the Windows helper), if
135
+ // configured. That helper binds loopback TCP and is reached over an `ssh -L`
136
+ // tunnel, so the endpoint is a local forwarded port. COMPUTER_HELPER_TCP is
137
+ // "host:port" (host defaults to 127.0.0.1); COMPUTER_HELPER_TOKEN is the shared
138
+ // secret sent in the first `auth` frame.
139
+ export function resolveTcpEndpoint() {
140
+ const raw = process.env.COMPUTER_HELPER_TCP;
141
+ if (!raw || raw.length === 0)
142
+ return null;
143
+ const [hostPart, portPart] = raw.includes(':') ? raw.split(':') : ['127.0.0.1', raw];
144
+ const port = Number(portPart);
145
+ if (!Number.isInteger(port) || port <= 0)
146
+ return null;
147
+ const token = process.env.COMPUTER_HELPER_TOKEN;
148
+ return { host: hostPart || '127.0.0.1', port, token: token && token.length > 0 ? token : null };
149
+ }
150
+ // Resolve the VNC/RFB endpoint for driving a remote GUI desktop over the RFB
151
+ // protocol (an x11vnc/Xvnc server — e.g. a headless Linux desktop or an LXD
152
+ // container exposing x11vnc on the host's Tailscale IP). COMPUTER_HELPER_VNC is
153
+ // "host:port" (port defaults to 5901); COMPUTER_HELPER_VNC_PASSWORD is the VNC
154
+ // password.
155
+ export function resolveVncEndpoint() {
156
+ const parsed = parseVncEndpoint(process.env.COMPUTER_HELPER_VNC);
157
+ if (!parsed)
158
+ return null;
159
+ return { ...parsed, password: process.env.COMPUTER_HELPER_VNC_PASSWORD ?? '' };
160
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * record.ts — turn the engine's NDJSON action events into agents-cli's own
3
+ * records: a feed event and a row in the computer-session history that
4
+ * `agents computer sessions` and `agents sessions --computer` read.
5
+ *
6
+ * WHY THIS STAYS HERE. The feed, the actor registry, and `sessions.db` are
7
+ * agents-cli state. Handing the standalone engine a writer for all three would
8
+ * have made it a second author of the session index — precisely the
9
+ * "one engine, one executor" rule the repo holds elsewhere. Instead the engine
10
+ * reports what it did on fd 4 and agents-cli, which owns those stores, records it.
11
+ *
12
+ * Before PHNX-4075 this was `emitComputerAction`, called inline by each verb in
13
+ * the same process. The behavior is unchanged; only the trigger moved from a
14
+ * function call to a line on a pipe.
15
+ *
16
+ * WHAT THE ENGINE OWNS, AND IS NOT REWRITTEN HERE: the action's identity. The
17
+ * engine mints the `invocationId` that groups a whole run into one session row,
18
+ * names the `host` it drove, and echoes back the session/launch/actor it was
19
+ * handed. Re-deriving any of those from this process would describe the CLI that
20
+ * spawned the engine rather than the run that happened — and for `--device` the
21
+ * two genuinely differ.
22
+ */
23
+ import type { ComputerActionEvent } from '../computer-client.js';
24
+ /**
25
+ * Fallback grouping id for an engine that reported no `invocationId` of its own.
26
+ * One per `agents computer` process, so such a run still collapses to a single
27
+ * session row instead of N unrelated ones.
28
+ */
29
+ export declare const COMPUTER_INVOCATION_ID: `${string}-${string}-${string}-${string}-${string}`;
30
+ /**
31
+ * Record one action the engine performed. Never throws: the action already
32
+ * happened and already reported its own success or failure on the engine's
33
+ * stderr, so a bookkeeping failure must not turn a successful click into a
34
+ * failed command.
35
+ */
36
+ export declare function recordComputerAction(event: ComputerActionEvent, opts?: {
37
+ device?: string;
38
+ }): void;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * record.ts — turn the engine's NDJSON action events into agents-cli's own
3
+ * records: a feed event and a row in the computer-session history that
4
+ * `agents computer sessions` and `agents sessions --computer` read.
5
+ *
6
+ * WHY THIS STAYS HERE. The feed, the actor registry, and `sessions.db` are
7
+ * agents-cli state. Handing the standalone engine a writer for all three would
8
+ * have made it a second author of the session index — precisely the
9
+ * "one engine, one executor" rule the repo holds elsewhere. Instead the engine
10
+ * reports what it did on fd 4 and agents-cli, which owns those stores, records it.
11
+ *
12
+ * Before PHNX-4075 this was `emitComputerAction`, called inline by each verb in
13
+ * the same process. The behavior is unchanged; only the trigger moved from a
14
+ * function call to a line on a pipe.
15
+ *
16
+ * WHAT THE ENGINE OWNS, AND IS NOT REWRITTEN HERE: the action's identity. The
17
+ * engine mints the `invocationId` that groups a whole run into one session row,
18
+ * names the `host` it drove, and echoes back the session/launch/actor it was
19
+ * handed. Re-deriving any of those from this process would describe the CLI that
20
+ * spawned the engine rather than the run that happened — and for `--device` the
21
+ * two genuinely differ.
22
+ */
23
+ import { randomUUID } from 'node:crypto';
24
+ import { emit as emitEvent } from '../feed/events.js';
25
+ import { recordComputerSession } from '../session/db.js';
26
+ import { resolveActor } from '../actor.js';
27
+ import { truncate } from '../feed/events.js';
28
+ import { TASK_PREVIEW_MAX_CHARS } from './sessions-list.js';
29
+ /**
30
+ * Fallback grouping id for an engine that reported no `invocationId` of its own.
31
+ * One per `agents computer` process, so such a run still collapses to a single
32
+ * session row instead of N unrelated ones.
33
+ */
34
+ export const COMPUTER_INVOCATION_ID = randomUUID();
35
+ /**
36
+ * Record one action the engine performed. Never throws: the action already
37
+ * happened and already reported its own success or failure on the engine's
38
+ * stderr, so a bookkeeping failure must not turn a successful click into a
39
+ * failed command.
40
+ */
41
+ export function recordComputerAction(event, opts = {}) {
42
+ const { event: _kind, command, invocationId,
43
+ // The ledger's `pid` is the EMITTING process's by construction (events.ts
44
+ // stamps `process.pid` over any payload value), so the engine's own pid
45
+ // cannot be carried in it. Dropped rather than passed in to be silently
46
+ // overwritten.
47
+ pid: _enginePid, host, sessionId, launchId, actor, ...rest } = event;
48
+ const runId = invocationId || COMPUTER_INVOCATION_ID;
49
+ // The driven machine. `host` is the field `sessions-list.ts` reads for a
50
+ // remote run; `opts.device` is the fallback for an engine that drove the
51
+ // device this CLI resolved but did not stamp it.
52
+ const drivenHost = host ?? opts.device;
53
+ // The task preview is bounded HERE, not upstream. agents-cli owns the ledger
54
+ // and therefore its retention/privacy rule (see sessions-list.ts): an engine
55
+ // that reported a full `--task` string must not be able to write an unbounded
56
+ // one into the session index.
57
+ const extra = typeof rest.task === 'string'
58
+ ? { ...rest, task: truncate(rest.task, TASK_PREVIEW_MAX_CHARS) }
59
+ : rest;
60
+ try {
61
+ emitEvent('computer.action', {
62
+ command,
63
+ invocationId: runId,
64
+ ...(drivenHost ? { host: drivenHost } : {}),
65
+ ...(sessionId ? { sessionId } : {}),
66
+ ...(launchId ? { launchId } : {}),
67
+ ...extra,
68
+ });
69
+ }
70
+ catch {
71
+ // Feed emission is best-effort; the action is already done.
72
+ }
73
+ try {
74
+ recordComputerSession({
75
+ invocationId: runId,
76
+ sessionId: sessionId ?? process.env.AGENT_SESSION_ID ?? process.env.AGENTS_SESSION_ID,
77
+ launchId: launchId ?? process.env.AGENT_LAUNCH_ID,
78
+ actor: actor ?? resolveActor().id,
79
+ actionCount: 1,
80
+ taskPreview: typeof extra.task === 'string' ? extra.task : undefined,
81
+ });
82
+ }
83
+ catch {
84
+ // Recording is best-effort; the action and its event are already done.
85
+ }
86
+ }
@@ -2,9 +2,9 @@
2
2
  * Read-only task/run history over the `computer.action` event ledger
3
3
  * (`~/.agents/.history/events/YYYY-MM-DD/events.jsonl`, see `../events.ts`) —
4
4
  * the durable, already-existing audit log every `agents computer <verb>`
5
- * invocation (the explicit CLI verbs in `commands/computer-actions.ts`, and
6
- * the embedded `computer run` loop in `computer/dispatch.ts`) writes through
7
- * `emitComputerAction()`. Backs both `agents computer sessions` and the
5
+ * invocation writes through `computer/record.ts`'s `recordComputerAction()`,
6
+ * fed by the action events the standalone engine streams back (PHNX-4075).
7
+ * Backs both `agents computer sessions` and the
8
8
  * `agents sessions --computer` alias.
9
9
  *
10
10
  * There is no separate capture directory the way browser tasks have
@@ -24,7 +24,7 @@
24
24
  * the ledger's 7 days, and one row per CLI process would otherwise grow without
25
25
  * limit. It is metadata only. Nothing sensitive is persisted: `type` /
26
26
  * `type-text` events already carry only `textLength`, never the typed text
27
- * (see `commands/computer-actions.ts` `emitComputerAction` call sites) — the
27
+ * (see `computer/record.ts` `recordComputerAction`) — the
28
28
  * mission this module fulfils changes NONE of that. A `run --task`
29
29
  * description is the agent's OWN instruction, not typed-into-a-target-app
30
30
  * content (the same class of thing `agents sessions` already stores
@@ -35,10 +35,10 @@
35
35
  * deliberate exception to (not a bypass of) the automatic prompt-redaction
36
36
  * path in `events.ts` `sanitizePayload`.
37
37
  *
38
- * Grouping key: `emitComputerAction()` stamps one random `invocationId` for
38
+ * Grouping key: `recordComputerAction()` stamps one random `invocationId` for
39
39
  * the lifetime of the emitting CLI process. The event's own `pid` field is the emitting
40
40
  * CLI PROCESS's pid, never the target app's (that's `targetPid` — see
41
- * `computer-actions.ts` `emitComputerAction`, and its `#11` test guarding
41
+ * `computer/record.ts` `recordComputerAction`, and its `#11` test guarding
42
42
  * this). One `agents computer <verb>` invocation is one process, and
43
43
  * `computer run`'s whole embedded observe/act/verify loop is ALSO one
44
44
  * process. Grouping by `invocationId` gives exactly one row per CLI invocation without