@phnx-labs/agents-cli 1.20.63 → 1.20.65

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 (197) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +46 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.js +149 -77
  10. package/dist/commands/feed.d.ts +4 -0
  11. package/dist/commands/feed.js +52 -19
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/lease.d.ts +23 -0
  14. package/dist/commands/lease.js +201 -0
  15. package/dist/commands/mailboxes.d.ts +20 -0
  16. package/dist/commands/mailboxes.js +390 -0
  17. package/dist/commands/mcp.js +55 -5
  18. package/dist/commands/monitors.d.ts +12 -0
  19. package/dist/commands/monitors.js +740 -0
  20. package/dist/commands/output.js +2 -2
  21. package/dist/commands/routines.js +43 -16
  22. package/dist/commands/secrets.d.ts +16 -0
  23. package/dist/commands/secrets.js +215 -64
  24. package/dist/commands/serve.js +31 -0
  25. package/dist/commands/sessions-export.d.ts +2 -0
  26. package/dist/commands/sessions-export.js +284 -0
  27. package/dist/commands/sessions-import.d.ts +2 -0
  28. package/dist/commands/sessions-import.js +230 -0
  29. package/dist/commands/sessions.d.ts +16 -0
  30. package/dist/commands/sessions.js +40 -6
  31. package/dist/commands/ssh.js +143 -5
  32. package/dist/commands/usage.d.ts +2 -0
  33. package/dist/commands/usage.js +7 -2
  34. package/dist/commands/versions.js +7 -3
  35. package/dist/commands/view.d.ts +27 -1
  36. package/dist/commands/view.js +32 -9
  37. package/dist/commands/webhook.js +10 -2
  38. package/dist/index.js +35 -14
  39. package/dist/lib/agents.d.ts +36 -0
  40. package/dist/lib/agents.js +80 -20
  41. package/dist/lib/auto-dispatch-provider.js +7 -2
  42. package/dist/lib/auto-dispatch.d.ts +3 -0
  43. package/dist/lib/auto-dispatch.js +3 -0
  44. package/dist/lib/browser/chrome.js +2 -2
  45. package/dist/lib/browser/drivers/ssh.js +19 -2
  46. package/dist/lib/cloud/antigravity.js +2 -2
  47. package/dist/lib/cloud/host.d.ts +59 -0
  48. package/dist/lib/cloud/host.js +224 -0
  49. package/dist/lib/cloud/registry.js +4 -0
  50. package/dist/lib/cloud/types.d.ts +6 -4
  51. package/dist/lib/comms-render.d.ts +37 -0
  52. package/dist/lib/comms-render.js +89 -0
  53. package/dist/lib/computer-rpc.js +3 -1
  54. package/dist/lib/crabbox/cli.d.ts +72 -0
  55. package/dist/lib/crabbox/cli.js +162 -9
  56. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  57. package/dist/lib/crabbox/runtimes.js +35 -2
  58. package/dist/lib/daemon.d.ts +20 -4
  59. package/dist/lib/daemon.js +68 -20
  60. package/dist/lib/devices/fleet.d.ts +3 -2
  61. package/dist/lib/devices/fleet.js +9 -0
  62. package/dist/lib/devices/health.d.ts +77 -0
  63. package/dist/lib/devices/health.js +186 -0
  64. package/dist/lib/devices/registry.d.ts +15 -0
  65. package/dist/lib/devices/registry.js +9 -0
  66. package/dist/lib/exec.d.ts +19 -2
  67. package/dist/lib/exec.js +41 -13
  68. package/dist/lib/fleet/apply.d.ts +63 -0
  69. package/dist/lib/fleet/apply.js +214 -0
  70. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  71. package/dist/lib/fleet/auth-sync.js +142 -0
  72. package/dist/lib/fleet/manifest.d.ts +29 -0
  73. package/dist/lib/fleet/manifest.js +127 -0
  74. package/dist/lib/fleet/types.d.ts +129 -0
  75. package/dist/lib/fleet/types.js +13 -0
  76. package/dist/lib/git.d.ts +27 -0
  77. package/dist/lib/git.js +34 -2
  78. package/dist/lib/hosts/dispatch.d.ts +29 -8
  79. package/dist/lib/hosts/dispatch.js +46 -18
  80. package/dist/lib/hosts/passthrough.js +2 -0
  81. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  82. package/dist/lib/hosts/providers/devices.js +98 -0
  83. package/dist/lib/hosts/registry.d.ts +10 -16
  84. package/dist/lib/hosts/registry.js +17 -50
  85. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  86. package/dist/lib/hosts/remote-cmd.js +71 -0
  87. package/dist/lib/hosts/run-target.d.ts +84 -0
  88. package/dist/lib/hosts/run-target.js +99 -0
  89. package/dist/lib/hosts/types.d.ts +23 -5
  90. package/dist/lib/hosts/types.js +22 -4
  91. package/dist/lib/linear-autoclose.d.ts +30 -0
  92. package/dist/lib/linear-autoclose.js +22 -0
  93. package/dist/lib/mailbox.d.ts +39 -0
  94. package/dist/lib/mailbox.js +112 -0
  95. package/dist/lib/mcp.d.ts +27 -1
  96. package/dist/lib/mcp.js +126 -12
  97. package/dist/lib/monitors/config.d.ts +161 -0
  98. package/dist/lib/monitors/config.js +372 -0
  99. package/dist/lib/monitors/dispatch.d.ts +28 -0
  100. package/dist/lib/monitors/dispatch.js +91 -0
  101. package/dist/lib/monitors/engine.d.ts +61 -0
  102. package/dist/lib/monitors/engine.js +201 -0
  103. package/dist/lib/monitors/sources/command.d.ts +11 -0
  104. package/dist/lib/monitors/sources/command.js +31 -0
  105. package/dist/lib/monitors/sources/device.d.ts +13 -0
  106. package/dist/lib/monitors/sources/device.js +35 -0
  107. package/dist/lib/monitors/sources/file.d.ts +14 -0
  108. package/dist/lib/monitors/sources/file.js +57 -0
  109. package/dist/lib/monitors/sources/http.d.ts +10 -0
  110. package/dist/lib/monitors/sources/http.js +34 -0
  111. package/dist/lib/monitors/sources/index.d.ts +14 -0
  112. package/dist/lib/monitors/sources/index.js +31 -0
  113. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  114. package/dist/lib/monitors/sources/poll.js +9 -0
  115. package/dist/lib/monitors/sources/types.d.ts +18 -0
  116. package/dist/lib/monitors/sources/types.js +9 -0
  117. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  118. package/dist/lib/monitors/sources/webhook.js +47 -0
  119. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  120. package/dist/lib/monitors/sources/ws.js +45 -0
  121. package/dist/lib/monitors/state.d.ts +69 -0
  122. package/dist/lib/monitors/state.js +144 -0
  123. package/dist/lib/paths.d.ts +13 -0
  124. package/dist/lib/paths.js +26 -4
  125. package/dist/lib/platform/exec.d.ts +16 -0
  126. package/dist/lib/platform/exec.js +17 -0
  127. package/dist/lib/plugins.js +101 -2
  128. package/dist/lib/redact.d.ts +14 -1
  129. package/dist/lib/redact.js +47 -1
  130. package/dist/lib/remote-agents-json.js +7 -1
  131. package/dist/lib/rotate.d.ts +6 -3
  132. package/dist/lib/rotate.js +0 -1
  133. package/dist/lib/routines.d.ts +37 -2
  134. package/dist/lib/routines.js +54 -12
  135. package/dist/lib/runner.d.ts +1 -0
  136. package/dist/lib/runner.js +348 -13
  137. package/dist/lib/sandbox.d.ts +9 -1
  138. package/dist/lib/sandbox.js +11 -2
  139. package/dist/lib/secrets/agent.d.ts +48 -10
  140. package/dist/lib/secrets/agent.js +123 -15
  141. package/dist/lib/secrets/bundles.d.ts +26 -0
  142. package/dist/lib/secrets/bundles.js +59 -8
  143. package/dist/lib/secrets/mcp.js +4 -2
  144. package/dist/lib/secrets/remote.d.ts +17 -0
  145. package/dist/lib/secrets/remote.js +40 -0
  146. package/dist/lib/self-update.d.ts +20 -0
  147. package/dist/lib/self-update.js +54 -1
  148. package/dist/lib/serve/control.d.ts +95 -0
  149. package/dist/lib/serve/control.js +260 -0
  150. package/dist/lib/serve/server.d.ts +35 -1
  151. package/dist/lib/serve/server.js +106 -76
  152. package/dist/lib/serve/stream.d.ts +43 -0
  153. package/dist/lib/serve/stream.js +116 -0
  154. package/dist/lib/serve/token.d.ts +35 -0
  155. package/dist/lib/serve/token.js +85 -0
  156. package/dist/lib/session/bundle.d.ts +164 -0
  157. package/dist/lib/session/bundle.js +200 -0
  158. package/dist/lib/session/remote-bundle.d.ts +12 -0
  159. package/dist/lib/session/remote-bundle.js +61 -0
  160. package/dist/lib/session/remote-list.js +5 -1
  161. package/dist/lib/session/state.d.ts +7 -25
  162. package/dist/lib/session/state.js +16 -6
  163. package/dist/lib/session/sync/agents.d.ts +54 -6
  164. package/dist/lib/session/sync/agents.js +0 -0
  165. package/dist/lib/session/sync/config.js +8 -2
  166. package/dist/lib/session/sync/manifest.d.ts +14 -3
  167. package/dist/lib/session/sync/manifest.js +4 -0
  168. package/dist/lib/session/sync/sync.d.ts +23 -2
  169. package/dist/lib/session/sync/sync.js +177 -74
  170. package/dist/lib/session/types.d.ts +30 -0
  171. package/dist/lib/ssh-tunnel.d.ts +19 -1
  172. package/dist/lib/ssh-tunnel.js +99 -8
  173. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  174. package/dist/lib/staleness/detectors/subagents.js +5 -192
  175. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  176. package/dist/lib/staleness/writers/subagents.js +11 -102
  177. package/dist/lib/startup/command-registry.d.ts +4 -0
  178. package/dist/lib/startup/command-registry.js +9 -0
  179. package/dist/lib/state.d.ts +5 -0
  180. package/dist/lib/state.js +12 -0
  181. package/dist/lib/subagents-registry.d.ts +85 -0
  182. package/dist/lib/subagents-registry.js +393 -0
  183. package/dist/lib/subagents.d.ts +8 -8
  184. package/dist/lib/subagents.js +32 -663
  185. package/dist/lib/sync-umbrella.d.ts +1 -0
  186. package/dist/lib/sync-umbrella.js +14 -3
  187. package/dist/lib/tmux/session.d.ts +7 -0
  188. package/dist/lib/tmux/session.js +3 -1
  189. package/dist/lib/triggers/webhook.d.ts +18 -0
  190. package/dist/lib/triggers/webhook.js +105 -0
  191. package/dist/lib/types.d.ts +35 -1
  192. package/dist/lib/usage.d.ts +42 -3
  193. package/dist/lib/usage.js +163 -21
  194. package/dist/lib/versions.js +14 -11
  195. package/dist/lib/workflows.d.ts +20 -0
  196. package/dist/lib/workflows.js +24 -0
  197. package/package.json +2 -1
@@ -47,14 +47,22 @@ const PROSE_QUESTION_FRESH_MS = 30 * 60_000;
47
47
  /** Claude tool names that structurally mean "the agent handed control back to you". */
48
48
  const PLAN_TOOL = 'ExitPlanMode';
49
49
  const ASK_TOOL = 'AskUserQuestion';
50
- /** Claude's live plan/checklist tool. */
50
+ /** The live plan/checklist tools: Claude's `TodoWrite` and Codex's `update_plan`. */
51
51
  const TODO_TOOL = 'TodoWrite';
52
+ const CODEX_PLAN_TOOL = 'update_plan';
52
53
  /**
53
- * Derive live plan progress from a `TodoWrite` tool call's args. Returns undefined
54
- * when there is no usable list, so a session with no plan carries no `todos` field.
54
+ * Derive live plan progress from a checklist tool call's args. Accepts both
55
+ * Claude's `TodoWrite` (`todos: [{content,status,activeForm}]`) and Codex's
56
+ * `update_plan` (`plan: [{step,status}]`) shapes, so the CLI is the single source
57
+ * of checklist state for every agent. Returns undefined when there is no usable
58
+ * list, so a session with no plan carries no `todos` field.
55
59
  */
56
60
  export function extractTodoProgress(args) {
57
- const raw = args?.todos;
61
+ const raw = Array.isArray(args?.todos)
62
+ ? args.todos
63
+ : Array.isArray(args?.plan)
64
+ ? args.plan
65
+ : undefined;
58
66
  if (!Array.isArray(raw) || raw.length === 0)
59
67
  return undefined;
60
68
  const items = [];
@@ -64,7 +72,9 @@ export function extractTodoProgress(args) {
64
72
  ? t.content
65
73
  : typeof t?.text === 'string' && t.text
66
74
  ? t.text
67
- : activeForm ?? '';
75
+ : typeof t?.step === 'string' && t.step
76
+ ? t.step
77
+ : activeForm ?? '';
68
78
  if (!content)
69
79
  continue;
70
80
  const status = t?.status === 'completed' || t?.status === 'in_progress' ? t.status : 'pending';
@@ -318,7 +328,7 @@ export function inferActivity(events, ctx = {}) {
318
328
  // Live plan progress: the most recent TodoWrite's checklist (RUSH-1380). Attached
319
329
  // to `base` so every return path below carries it — a working, waiting, or idle
320
330
  // session all keep showing how far the plan got.
321
- const lastTodo = lastOf(meaningful, e => e.type === 'tool_use' && e.tool === TODO_TOOL);
331
+ const lastTodo = lastOf(meaningful, e => e.type === 'tool_use' && (e.tool === TODO_TOOL || e.tool === CODEX_PLAN_TOOL));
322
332
  const todos = lastTodo ? extractTodoProgress(lastTodo.args) : undefined;
323
333
  const base = {
324
334
  activity: 'idle',
@@ -12,23 +12,54 @@
12
12
  * also exists locally always wins — the mirror only ever fills in sessions
13
13
  * originated on other machines.
14
14
  */
15
- export interface LocalTranscript {
15
+ /** One constituent file of a session — a session has exactly one for file-shaped
16
+ * agents (Claude, Codex, …), and many for directory-shaped ones (Kimi). */
17
+ export interface SessionFile {
16
18
  /** Absolute path on this machine. */
17
19
  absPath: string;
18
- /** Globally-unique session id (the grouping key across machines). */
19
- sessionId: string;
20
20
  /** Path relative to the agent's subdir root — preserved in the mirror layout. */
21
21
  relKey: string;
22
22
  }
23
+ export interface LocalTranscript {
24
+ /** Globally-unique session id (the grouping key across machines). */
25
+ sessionId: string;
26
+ /** Every file that makes up this session. Length 1 for file-shaped agents. */
27
+ files: SessionFile[];
28
+ }
23
29
  export interface SyncAgentSpec {
24
30
  id: string;
25
31
  /** Config subdir under the agent home that holds transcripts. */
26
32
  subdir: string;
27
33
  /** File extension to walk for this agent (defaults to .jsonl). */
28
34
  ext?: string;
35
+ /**
36
+ * A session is a DIRECTORY of files (e.g. Kimi: state.json + agents/…/wire.jsonl
37
+ * + per-tool task sidecars), not a single transcript. When set, every file under
38
+ * the session dir (matching `exts`, passing `fileFilter`) syncs, is stored under
39
+ * its own R2 sub-key, and is mirrored at its own relative path — instead of the
40
+ * file-shaped "one transcript per session" model.
41
+ */
42
+ dirShaped?: boolean;
43
+ /** Extensions a dir-shaped agent walks (defaults to `[ext ?? '.jsonl']`). */
44
+ exts?: string[];
45
+ /** Optional per-file exclusion for dir-shaped agents (lock/scratch files, …). */
46
+ fileFilter?(relKey: string): boolean;
47
+ /**
48
+ * Extensions whose files are append-only event logs and therefore CRDT-mergeable
49
+ * (G-Set union across forked copies). A dir-shaped session usually mixes an
50
+ * append-only conversation log (`wire.jsonl`) with mutable metadata blobs
51
+ * (`state.json`) — line-unioning the latter would corrupt it, so any file NOT
52
+ * matching an entry here is reconciled last-writer-wins instead. Undefined (the
53
+ * file-shaped default) means every file is mergeable — the single `.jsonl`
54
+ * transcript keeps its existing union behaviour.
55
+ */
56
+ mergeableExts?: string[];
29
57
  /** Derive the session id from a storage-relative key. */
30
58
  sessionIdFromRelKey(relKey: string): string;
31
59
  }
60
+ /** True when a file at `relKey` is an append-only log that CRDT-unions across
61
+ * forks; false when it must be reconciled last-writer-wins (mutable blob). */
62
+ export declare function isMergeableFile(spec: SyncAgentSpec, relKey: string): boolean;
32
63
  export declare const SYNC_AGENTS: SyncAgentSpec[];
33
64
  /**
34
65
  * List this machine's own transcript files for an agent, EXCLUDING the sync
@@ -38,10 +69,27 @@ export declare const SYNC_AGENTS: SyncAgentSpec[];
38
69
  export declare function listLocalTranscripts(spec: SyncAgentSpec): LocalTranscript[];
39
70
  /** Session ids this machine holds locally (live home), used to skip mirror writes. */
40
71
  export declare function localSessionIds(spec: SyncAgentSpec): Set<string>;
41
- /** Absolute mirror path for a remote machine's transcript — lands in a scan root. */
72
+ /**
73
+ * Absolute mirror path for a remote machine's transcript — lands in a scan root.
74
+ *
75
+ * `machine` and `relKey` come from a peer's manifest (untrusted: any peer with
76
+ * write access to the shared bucket controls them). Unlike the push side, which
77
+ * already drops `relKey` starting with `..` when building the manifest, the pull
78
+ * side would otherwise `fs.writeFileSync` at this path with peer-controlled
79
+ * content. `machine` is constrained to a single segment and `relKey` (which may
80
+ * legitimately nest, e.g. `projects/x/y.jsonl`) is contained beneath the
81
+ * per-machine mirror root, so a crafted `relKey` like `../../../.ssh/authorized_keys`
82
+ * cannot write outside `~/.agents/.history/backups/<agent>/<machine>/<subdir>`.
83
+ */
42
84
  export declare function mirrorPath(spec: SyncAgentSpec, machine: string, relKey: string): string;
43
- /** R2 object key for a transcript: sessions/<machine>/<agent>/<sessionId>.jsonl */
44
- export declare function objectKey(machine: string, agentId: string, sessionId: string): string;
85
+ /**
86
+ * R2 object key for a transcript.
87
+ * - file-shaped (relKey omitted): sessions/<machine>/<agent>/<sessionId>.jsonl —
88
+ * unchanged, so existing claude/codex/droid objects keep their keys.
89
+ * - dir-shaped (relKey given): sessions/<machine>/<agent>/<sessionId>/<relKey> —
90
+ * one object per constituent file of the session directory.
91
+ */
92
+ export declare function objectKey(machine: string, agentId: string, sessionId: string, relKey?: string): string;
45
93
  /** R2 object key for a machine's manifest. */
46
94
  export declare function manifestKey(machine: string): string;
47
95
  /** Prefix under which all machine manifests live (for discovery). */
Binary file
@@ -3,7 +3,7 @@
3
3
  * machine's stable identity. Credentials come from the `r2.backups` secrets
4
4
  * bundle (OS keychain on macOS, libsecret on Linux) — never from env or disk.
5
5
  */
6
- import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
6
+ import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../../secrets/bundles.js';
7
7
  /** Secrets bundle holding the R2 credentials. */
8
8
  export const SYNC_BUNDLE = 'r2.backups';
9
9
  /**
@@ -12,7 +12,13 @@ export const SYNC_BUNDLE = 'r2.backups';
12
12
  * without real credentials (no silent fallback).
13
13
  */
14
14
  function resolveR2Config() {
15
- const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'sessions-sync' });
15
+ // The daemon monitor loop is headless by construction (no TTY, ever), so
16
+ // isHeadlessSecretsContext() is true here and this resolves broker-only — a
17
+ // broker miss can never pop an unattended Touch ID sheet on the user's screen
18
+ // (same rationale as daemon.ts readDaemonClaudeOAuthToken). Using the shared
19
+ // predicate rather than a literal keeps it consistent with the other callers
20
+ // and lets any interactive caller of loadR2Config still prompt.
21
+ const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'sessions-sync', agentOnly: isHeadlessSecretsContext() });
16
22
  const accountId = env.R2_ACCOUNT_ID?.trim();
17
23
  const bucket = env.R2_BUCKET_NAME?.trim();
18
24
  const accessKeyId = env.R2_ACCESS_KEY_ID?.trim();
@@ -18,12 +18,23 @@ export interface ManifestEntry {
18
18
  /** Latest event timestamp in the transcript. */
19
19
  lastTs: string;
20
20
  }
21
- /** sessionId -> entry */
22
- export type AgentManifest = Record<string, ManifestEntry>;
21
+ /**
22
+ * sessionId -> the session's file entries.
23
+ * - file-shaped agents (claude/codex/droid) store a single `ManifestEntry`,
24
+ * byte-identical to the pre-multi-file format, so a machine on an older CLI
25
+ * reads them unchanged.
26
+ * - dir-shaped agents (kimi) store a `ManifestEntry[]`, one per constituent file.
27
+ * A machine that predates multi-file support skips these (an unknown agent is
28
+ * skipped whole; a kimi-aware-but-file-shaped reader fails to find the flat
29
+ * object key and simply retries — it never crashes or corrupts).
30
+ */
31
+ export type AgentManifest = Record<string, ManifestEntry | ManifestEntry[]>;
32
+ /** Normalize a manifest value to its file-entry list (single entry -> length-1). */
33
+ export declare function manifestEntries(value: ManifestEntry | ManifestEntry[]): ManifestEntry[];
23
34
  export interface Manifest {
24
35
  machine: string;
25
36
  updatedAt: string;
26
- /** agentId -> (sessionId -> entry) */
37
+ /** agentId -> (sessionId -> entry | entry[]) */
27
38
  agents: Record<string, AgentManifest>;
28
39
  }
29
40
  export declare function emptyManifest(machine: string, updatedAt: string): Manifest;
@@ -13,6 +13,10 @@ import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as crypto from 'crypto';
15
15
  import { getCacheDir } from '../../state.js';
16
+ /** Normalize a manifest value to its file-entry list (single entry -> length-1). */
17
+ export function manifestEntries(value) {
18
+ return Array.isArray(value) ? value : [value];
19
+ }
16
20
  export function emptyManifest(machine, updatedAt) {
17
21
  return { machine, updatedAt, agents: {} };
18
22
  }
@@ -55,10 +55,31 @@ export interface PendingSession {
55
55
  */
56
56
  export declare function selectSessionsToFetch(copies: Map<string, Map<string, RemoteCopy[]>>, localIdsByAgent: Map<string, Set<string>>, pullState: PullState): PendingSession[];
57
57
  /**
58
- * Resolve the mirror destination + merged content for one session. Pure.
58
+ * Resolve the mirror destination + reconciled content for ONE file across its
59
+ * copies (every copy here is the same file — same relKey — held by a different
60
+ * machine). Pure.
61
+ *
59
62
  * The canonical path comes from the lexicographically-smallest machine so every
60
- * puller derives an identical location; the content is the CRDT union of copies.
63
+ * puller derives an identical location. The content depends on the file's kind
64
+ * (see `isMergeableFile`):
65
+ * - append-only logs (a transcript `.jsonl`) take the CRDT G-Set union — every
66
+ * machine converges to byte-identical output regardless of order.
67
+ * - mutable blobs (Kimi `state.json`) can't be line-unioned without corruption,
68
+ * so they resolve **last-writer-wins**: the copy with the latest event
69
+ * timestamp, tie-broken by content hash so the pick is deterministic fleet-wide.
70
+ */
71
+ /**
72
+ * The `lastTs` a manifest entry carries for one file. Append-only logs (a
73
+ * conversation `.jsonl`) embed per-line event timestamps, so their recency is
74
+ * the latest line timestamp (`transcriptStats`). Mutable blobs (Kimi
75
+ * `state.json`, the per-tool `tasks/*.json` sidecars) carry no event timestamp —
76
+ * their own `updatedAt`/`createdAt` fields are agent-specific and unreliable — so
77
+ * their "last written" signal is the file mtime. Without this, `transcriptStats`
78
+ * returns `''` for every blob and the last-writer-wins branch in
79
+ * `resolveMirrorWrite` silently degrades to "highest-hash-wins", which can pick a
80
+ * stale copy over the genuinely newer one.
61
81
  */
82
+ export declare function deriveLastTs(spec: SyncAgentSpec, relKey: string, content: string, mtimeMs: number): string;
62
83
  export declare function resolveMirrorWrite(spec: SyncAgentSpec, copies: RemoteCopy[], contents: string[]): {
63
84
  dest: string;
64
85
  content: string;
@@ -18,10 +18,10 @@ import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import { R2Client } from './r2.js';
20
20
  import { loadR2Config, machineId } from './config.js';
21
- import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, SESSIONS_PREFIX, } from './agents.js';
21
+ import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, isMergeableFile, SESSIONS_PREFIX, } from './agents.js';
22
22
  import { mergeTranscripts, transcriptStats } from './crdt.js';
23
23
  import { resolveSyncEncKey, encryptTranscript, decryptTranscriptBody } from './transcript-crypto.js';
24
- import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, } from './manifest.js';
24
+ import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, manifestEntries, } from './manifest.js';
25
25
  const nowIso = () => new Date().toISOString();
26
26
  function specById(id) {
27
27
  return SYNC_AGENTS.find(s => s.id === id);
@@ -42,45 +42,59 @@ async function pushOwn(r2, me, encKey, opts, result) {
42
42
  for (const spec of SYNC_AGENTS) {
43
43
  const agentManifest = {};
44
44
  for (const t of listLocalTranscripts(spec)) {
45
- let stat;
46
- try {
47
- stat = fs.statSync(t.absPath);
48
- }
49
- catch {
50
- continue;
51
- }
52
- const prevEntry = prev?.agents?.[spec.id]?.[t.sessionId];
53
- if (prevEntry && ledgerUnchanged(ledger, t.absPath, stat.size, stat.mtimeMs)) {
54
- agentManifest[t.sessionId] = prevEntry; // unchanged: reuse, no read, no upload
55
- result.pushSkipped++;
56
- continue;
57
- }
58
- let content;
59
- try {
60
- content = fs.readFileSync(t.absPath, 'utf-8');
61
- }
62
- catch {
63
- continue;
64
- }
65
- // Identity for CRDT merge is the PLAINTEXT hash (ciphertext is
66
- // non-deterministic), so hash + manifest are computed on cleartext; only
67
- // the stored object body is sealed.
68
- const hash = hashContent(content);
69
- const { lastTs } = transcriptStats(content);
70
- const entry = { relKey: t.relKey, size: stat.size, hash, lastTs };
71
- const body = encKey ? encryptTranscript(content, encKey) : content;
72
- const contentType = encKey ? 'application/json' : 'application/x-ndjson';
73
- try {
74
- await r2.put(objectKey(me, spec.id, t.sessionId), body, contentType);
75
- ledgerRecord(ledger, t.absPath, stat.size, stat.mtimeMs, hash);
76
- agentManifest[t.sessionId] = entry;
77
- result.pushed++;
78
- if (opts.verbose)
79
- opts.log?.(` push ${spec.id}/${t.sessionId.slice(0, 8)} (${stat.size}B)`);
80
- }
81
- catch (err) {
82
- result.errors.push(`push ${spec.id}/${t.sessionId}: ${err.message}`);
45
+ // A session is one file (file-shaped) or many (dir-shaped). Push each file
46
+ // that changed, reusing the prior manifest entry (keyed by relKey) for the
47
+ // ones the ledger shows unchanged. Object keys nest under the session id for
48
+ // dir-shaped agents and stay flat for file-shaped ones (unchanged keys).
49
+ const prevVal = prev?.agents?.[spec.id]?.[t.sessionId];
50
+ const prevByRel = new Map((prevVal ? manifestEntries(prevVal) : []).map(e => [e.relKey, e]));
51
+ const entries = [];
52
+ for (const f of t.files) {
53
+ let stat;
54
+ try {
55
+ stat = fs.statSync(f.absPath);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ const prevEntry = prevByRel.get(f.relKey);
61
+ if (prevEntry && ledgerUnchanged(ledger, f.absPath, stat.size, stat.mtimeMs)) {
62
+ entries.push(prevEntry); // unchanged: reuse, no read, no upload
63
+ result.pushSkipped++;
64
+ continue;
65
+ }
66
+ let content;
67
+ try {
68
+ content = fs.readFileSync(f.absPath, 'utf-8');
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ // Identity for CRDT merge is the PLAINTEXT hash (ciphertext is
74
+ // non-deterministic), so hash + manifest are computed on cleartext; only
75
+ // the stored object body is sealed.
76
+ const hash = hashContent(content);
77
+ const lastTs = deriveLastTs(spec, f.relKey, content, stat.mtimeMs);
78
+ const entry = { relKey: f.relKey, size: stat.size, hash, lastTs };
79
+ const body = encKey ? encryptTranscript(content, encKey) : content;
80
+ const contentType = encKey ? 'application/json' : 'application/x-ndjson';
81
+ try {
82
+ await r2.put(objectKey(me, spec.id, t.sessionId, spec.dirShaped ? f.relKey : undefined), body, contentType);
83
+ ledgerRecord(ledger, f.absPath, stat.size, stat.mtimeMs, hash);
84
+ entries.push(entry);
85
+ result.pushed++;
86
+ const label = spec.dirShaped ? `${t.sessionId.slice(0, 8)}/${f.relKey}` : t.sessionId.slice(0, 8);
87
+ if (opts.verbose)
88
+ opts.log?.(` push ${spec.id}/${label} (${stat.size}B)`);
89
+ }
90
+ catch (err) {
91
+ result.errors.push(`push ${spec.id}/${t.sessionId}/${f.relKey}: ${err.message}`);
92
+ }
83
93
  }
94
+ // File-shaped: store the single entry (byte-identical to the old format so
95
+ // older CLIs read it unchanged). Dir-shaped: store the per-file array.
96
+ if (entries.length > 0)
97
+ agentManifest[t.sessionId] = spec.dirShaped ? entries : entries[0];
84
98
  }
85
99
  if (Object.keys(agentManifest).length > 0)
86
100
  manifest.agents[spec.id] = agentManifest;
@@ -115,17 +129,60 @@ export function selectSessionsToFetch(copies, localIdsByAgent, pullState) {
115
129
  return pending;
116
130
  }
117
131
  /**
118
- * Resolve the mirror destination + merged content for one session. Pure.
132
+ * Resolve the mirror destination + reconciled content for ONE file across its
133
+ * copies (every copy here is the same file — same relKey — held by a different
134
+ * machine). Pure.
135
+ *
119
136
  * The canonical path comes from the lexicographically-smallest machine so every
120
- * puller derives an identical location; the content is the CRDT union of copies.
137
+ * puller derives an identical location. The content depends on the file's kind
138
+ * (see `isMergeableFile`):
139
+ * - append-only logs (a transcript `.jsonl`) take the CRDT G-Set union — every
140
+ * machine converges to byte-identical output regardless of order.
141
+ * - mutable blobs (Kimi `state.json`) can't be line-unioned without corruption,
142
+ * so they resolve **last-writer-wins**: the copy with the latest event
143
+ * timestamp, tie-broken by content hash so the pick is deterministic fleet-wide.
121
144
  */
145
+ /**
146
+ * The `lastTs` a manifest entry carries for one file. Append-only logs (a
147
+ * conversation `.jsonl`) embed per-line event timestamps, so their recency is
148
+ * the latest line timestamp (`transcriptStats`). Mutable blobs (Kimi
149
+ * `state.json`, the per-tool `tasks/*.json` sidecars) carry no event timestamp —
150
+ * their own `updatedAt`/`createdAt` fields are agent-specific and unreliable — so
151
+ * their "last written" signal is the file mtime. Without this, `transcriptStats`
152
+ * returns `''` for every blob and the last-writer-wins branch in
153
+ * `resolveMirrorWrite` silently degrades to "highest-hash-wins", which can pick a
154
+ * stale copy over the genuinely newer one.
155
+ */
156
+ export function deriveLastTs(spec, relKey, content, mtimeMs) {
157
+ if (isMergeableFile(spec, relKey))
158
+ return transcriptStats(content).lastTs;
159
+ return new Date(mtimeMs).toISOString();
160
+ }
122
161
  export function resolveMirrorWrite(spec, copies, contents) {
123
162
  const canonical = [...copies].sort((a, b) => (a.machine < b.machine ? -1 : a.machine > b.machine ? 1 : 0))[0];
124
- const content = contents.length === 1 ? contents[0] : mergeTranscripts(contents);
163
+ let content;
164
+ if (contents.length === 1) {
165
+ content = contents[0];
166
+ }
167
+ else if (isMergeableFile(spec, canonical.entry.relKey)) {
168
+ content = mergeTranscripts(contents);
169
+ }
170
+ else {
171
+ // Last-writer-wins: highest (lastTs, hash) among the copies.
172
+ let win = 0;
173
+ for (let i = 1; i < copies.length; i++) {
174
+ const a = copies[i].entry, b = copies[win].entry;
175
+ if (a.lastTs > b.lastTs || (a.lastTs === b.lastTs && a.hash > b.hash))
176
+ win = i;
177
+ }
178
+ content = contents[win];
179
+ }
125
180
  return {
126
181
  dest: mirrorPath(spec, canonical.machine, canonical.entry.relKey),
127
182
  content,
128
- merged: contents.length > 1,
183
+ // "merged" means an actual CRDT line-union happened — not a last-writer-wins
184
+ // pick of one mutable blob over another (that discards a copy, it doesn't merge).
185
+ merged: contents.length > 1 && isMergeableFile(spec, canonical.entry.relKey),
129
186
  };
130
187
  }
131
188
  /**
@@ -176,9 +233,13 @@ async function pullAndReconcile(r2, me, encKey, opts, result) {
176
233
  let byAgent = copies.get(agentId);
177
234
  if (!byAgent)
178
235
  copies.set(agentId, (byAgent = new Map()));
179
- for (const [sessionId, entry] of Object.entries(sessions)) {
236
+ for (const [sessionId, value] of Object.entries(sessions)) {
180
237
  const list = byAgent.get(sessionId) ?? [];
181
- list.push({ machine: m, entry });
238
+ // One RemoteCopy per (machine, file): file-shaped sessions contribute one,
239
+ // dir-shaped ones contribute an entry per constituent file. `manifestEntries`
240
+ // also normalizes a single-object entry written by an older CLI.
241
+ for (const entry of manifestEntries(value))
242
+ list.push({ machine: m, entry });
182
243
  byAgent.set(sessionId, list);
183
244
  }
184
245
  }
@@ -197,43 +258,85 @@ async function pullAndReconcile(r2, me, encKey, opts, result) {
197
258
  result.pullSkipped += candidates - pending.length; // local-owned or unchanged
198
259
  for (const { agentId, sessionId, copies: list, sig } of pending) {
199
260
  const spec = specById(agentId);
200
- // Download each copy (could be a fork across >1 machine), keeping the
201
- // result positionally aligned to `list` null marks a copy we couldn't
202
- // fetch this tick (404 / consistency lag / error).
203
- const fetched = [];
261
+ // A dir-shaped session spans several files; reconcile each file independently
262
+ // (its copies across machines share one relKey). File-shaped sessions have a
263
+ // single group, so this collapses to the original one-file path.
264
+ const byRel = new Map();
204
265
  for (const c of list) {
205
- try {
206
- const body = await r2.get(objectKey(c.machine, agentId, sessionId));
207
- // Decrypt before the body reaches the CRDT union — the merge and the
208
- // mirror always operate on plaintext. A legacy plaintext object passes
209
- // through untouched; an envelope without a key throws (surfaced below).
210
- fetched.push(body === null ? null : decryptTranscriptBody(body, encKey));
211
- }
212
- catch (err) {
213
- result.errors.push(`get ${c.machine}/${sessionId}: ${err.message}`);
214
- fetched.push(null);
266
+ const g = byRel.get(c.entry.relKey);
267
+ if (g)
268
+ g.push(c);
269
+ else
270
+ byRel.set(c.entry.relKey, [c]);
271
+ }
272
+ // Resolve every file's write before touching disk. If ANY file's fetch is
273
+ // incomplete (a copy 404s / consistency lag), abandon the WHOLE session this
274
+ // tick — no writes, no pull-state stamp — so it retries intact next time
275
+ // rather than materializing half a session and stamping it done.
276
+ const writes = [];
277
+ let incomplete = false;
278
+ try {
279
+ for (const group of byRel.values()) {
280
+ const fetched = [];
281
+ for (const c of group) {
282
+ try {
283
+ const body = await r2.get(objectKey(c.machine, agentId, sessionId, spec.dirShaped ? c.entry.relKey : undefined));
284
+ // Decrypt before the body reaches the CRDT union — merge + mirror always
285
+ // operate on plaintext. A legacy plaintext object passes through
286
+ // untouched; an envelope without a key throws (surfaced below).
287
+ fetched.push(body === null ? null : decryptTranscriptBody(body, encKey));
288
+ }
289
+ catch (err) {
290
+ result.errors.push(`get ${c.machine}/${sessionId}/${c.entry.relKey}: ${err.message}`);
291
+ fetched.push(null);
292
+ }
293
+ }
294
+ const resolved = reconcileCopies(spec, group, fetched);
295
+ if (!resolved) {
296
+ incomplete = true;
297
+ break;
298
+ }
299
+ writes.push(resolved);
215
300
  }
216
301
  }
217
- // null ⇒ an incomplete fetch: skip the write AND the pull-state stamp so we
218
- // retry next tick instead of persisting a partial union / abandoning a branch.
219
- const resolved = reconcileCopies(spec, list, fetched);
220
- if (!resolved)
302
+ catch (err) {
303
+ // `reconcileCopies` -> `mirrorPath` now rejects unsafe peer-controlled
304
+ // machine/relKey (C1 containment). That rejection must stay scoped to this
305
+ // one session: without this catch a single malicious/malformed manifest
306
+ // entry would throw out of the whole `pending` loop, skip the
307
+ // `savePullState` below, and re-throw every tick — a peer-triggered DoS on
308
+ // everyone else's session-sync. Record it and skip; never stamp pull-state
309
+ // for a rejected entry (so nothing marks the bad session "done").
310
+ result.errors.push(`resolve mirror ${agentId}/${sessionId}: ${err.message}`);
221
311
  continue;
222
- const { dest, content, merged } = resolved;
312
+ }
313
+ if (incomplete)
314
+ continue; // retry next tick, session intact
223
315
  try {
224
- let existing = null;
225
- try {
226
- existing = fs.readFileSync(dest, 'utf-8');
316
+ let changed = false;
317
+ let mergedAny = false;
318
+ for (const { dest, content, merged } of writes) {
319
+ let existing = null;
320
+ try {
321
+ existing = fs.readFileSync(dest, 'utf-8');
322
+ }
323
+ catch { /* not present yet */ }
324
+ if (existing !== content) {
325
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
326
+ fs.writeFileSync(dest, content, 'utf-8');
327
+ changed = true;
328
+ if (merged)
329
+ mergedAny = true;
330
+ }
227
331
  }
228
- catch { /* not present yet */ }
229
- if (existing !== content) {
230
- fs.mkdirSync(path.dirname(dest), { recursive: true });
231
- fs.writeFileSync(dest, content, 'utf-8');
232
- if (merged)
332
+ if (changed) {
333
+ if (mergedAny)
233
334
  result.merged++;
234
335
  result.pulled++;
235
336
  if (opts.verbose) {
236
- opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)} <- ${list.map(c => c.machine).join('+')}`);
337
+ const machines = [...new Set(list.map(c => c.machine))].join('+');
338
+ const files = byRel.size > 1 ? ` (${byRel.size} files)` : '';
339
+ opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)}${files} <- ${machines}`);
237
340
  }
238
341
  }
239
342
  else {
@@ -41,6 +41,29 @@ export interface SessionAttachment {
41
41
  mediaType: string;
42
42
  sizeBytes?: number;
43
43
  }
44
+ /** One checklist item, as Claude's `TodoWrite` (`content`) or Codex's `update_plan` (`step`) emits it. */
45
+ export interface TodoItem {
46
+ content: string;
47
+ status: 'pending' | 'in_progress' | 'completed';
48
+ /** Present-continuous label shown while this item is the active step. */
49
+ activeForm?: string;
50
+ }
51
+ /**
52
+ * Live plan progress derived from the most recent checklist write in the transcript
53
+ * (Claude `TodoWrite` / Codex `update_plan`, RUSH-1380). Lets consumers show "N/M
54
+ * done" + the current step for any session — including remote / device-dispatched
55
+ * agents that carry no local tool-call stream — instead of only a coarse
56
+ * working/idle verb.
57
+ */
58
+ export interface TodoProgress {
59
+ items: TodoItem[];
60
+ /** Count of completed items. */
61
+ done: number;
62
+ /** Total items. */
63
+ total: number;
64
+ /** The in-progress item's activeForm (falls back to its content). The live step. */
65
+ activeForm?: string;
66
+ }
44
67
  /** Metadata attached when a session was spawned by `agents teams`. */
45
68
  export interface TeamOrigin {
46
69
  /** Teammate name if set, otherwise first 8 chars of the agent UUID. */
@@ -116,6 +139,13 @@ export interface SessionMeta {
116
139
  * recover the plan text — the CLI now carries it on the metadata row.
117
140
  */
118
141
  plan?: string;
142
+ /**
143
+ * Live plan progress from the most recent checklist write (Claude `TodoWrite`
144
+ * / Codex `update_plan`, RUSH-1503). Populated on `agents sessions <id> --json`
145
+ * from the state engine so the Factory panel reads the CLI's computed checklist
146
+ * instead of re-parsing the transcript. Absent when the session wrote no list.
147
+ */
148
+ todos?: TodoProgress;
119
149
  /**
120
150
  * True when the session was spawned programmatically (SDK entrypoint) rather
121
151
  * than by a human at the Claude CLI. Captured at scan time from the JSONL
@@ -49,6 +49,8 @@ export declare const REMOTE_HELPER_PORT = 8765;
49
49
  export declare const REMOTE_TASK_NAME = "AgentsComputerHelper";
50
50
  /** Basename of the cross-published exe under native/computer-win/dist. */
51
51
  export declare const WIN_HELPER_EXE = "computer-helper-win.exe";
52
+ /** Basename of the daemon's shared-secret token file under %LOCALAPPDATA%\agents. */
53
+ export declare const WIN_HELPER_TOKEN_FILE = "helper-token";
52
54
  /**
53
55
  * Locate a locally built Windows daemon exe — a repo-checkout build output from
54
56
  * `scripts/build-win.sh`, or one bundled next to the package. Local paths are
@@ -101,6 +103,16 @@ export declare function remoteStatePath(device: string): string;
101
103
  export declare function readRemoteState(device: string): RemoteTunnelState | null;
102
104
  export declare function writeRemoteState(state: RemoteTunnelState): void;
103
105
  export declare function clearRemoteState(device: string): void;
106
+ /**
107
+ * Local file holding the shared-secret token for a device's helper daemon.
108
+ * Written at `setup --host` (0600), read at `start --host` so the RPC client
109
+ * authenticates. The remote daemon reads the same value from its own
110
+ * `--token-file`; the CLI is the source of truth and never round-trips it back.
111
+ */
112
+ export declare function helperTokenPath(device: string): string;
113
+ export declare function readHelperToken(device: string): string | null;
114
+ export declare function writeHelperToken(device: string, token: string): void;
115
+ export declare function clearHelperToken(device: string): void;
104
116
  /** Resolve a registered device to its ssh pieces, or throw a clear error. */
105
117
  export declare function resolveRemoteDevice(name: string): Promise<{
106
118
  device: DeviceProfile;
@@ -123,7 +135,13 @@ export declare function buildVerifyPushScript(remotePath: string, expectedBytes:
123
135
  * survives ssh disconnect — the same rationale as the browser WMI launch. The
124
136
  * task is started immediately so the caller need not log out/in.
125
137
  */
126
- export declare function buildRegisterTaskScript(port: number, taskName: string): string;
138
+ /**
139
+ * PowerShell that writes the shared-secret token under %LOCALAPPDATA%\agents
140
+ * with an owner-only ACL (inheritance removed, read granted only to the current
141
+ * user) and echoes the resolved path so the caller can pass it to `--token-file`.
142
+ */
143
+ export declare function buildWriteTokenScript(token: string): string;
144
+ export declare function buildRegisterTaskScript(port: number, taskName: string, tokenPath: string): string;
127
145
  /** PowerShell that unregisters the task and stops any running daemon process. */
128
146
  export declare function buildUnregisterTaskScript(taskName: string): string;
129
147
  /** Convert a Windows path returned by PowerShell into the scp/SFTP path form. */