@farmslot/agent-runtime 0.9.0 → 0.10.0

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 (216) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +1 -1
  3. package/bin/farmslot-agent.mjs +1 -1
  4. package/dist/execution-template/create.d.ts.map +1 -1
  5. package/dist/execution-template/create.js +4 -9
  6. package/dist/execution-template/create.js.map +1 -1
  7. package/dist/execution-template/execution-template.test.js +85 -34
  8. package/dist/execution-template/execution-template.test.js.map +1 -1
  9. package/dist/execution-template/frontmatter.d.ts +6 -3
  10. package/dist/execution-template/frontmatter.d.ts.map +1 -1
  11. package/dist/execution-template/frontmatter.js +8 -10
  12. package/dist/execution-template/frontmatter.js.map +1 -1
  13. package/dist/execution-template/index.d.ts +2 -2
  14. package/dist/execution-template/index.d.ts.map +1 -1
  15. package/dist/execution-template/index.js +2 -2
  16. package/dist/execution-template/index.js.map +1 -1
  17. package/dist/execution-template/infer.d.ts +2 -3
  18. package/dist/execution-template/infer.d.ts.map +1 -1
  19. package/dist/execution-template/infer.js +2 -19
  20. package/dist/execution-template/infer.js.map +1 -1
  21. package/dist/execution-template/lint.d.ts.map +1 -1
  22. package/dist/execution-template/lint.js +12 -14
  23. package/dist/execution-template/lint.js.map +1 -1
  24. package/dist/execution-template/resolve.d.ts.map +1 -1
  25. package/dist/execution-template/resolve.js +0 -2
  26. package/dist/execution-template/resolve.js.map +1 -1
  27. package/dist/execution-template/select.d.ts.map +1 -1
  28. package/dist/execution-template/select.js +4 -8
  29. package/dist/execution-template/select.js.map +1 -1
  30. package/dist/execution-template/snapshot.d.ts.map +1 -1
  31. package/dist/execution-template/snapshot.js +0 -1
  32. package/dist/execution-template/snapshot.js.map +1 -1
  33. package/dist/execution-template/types.d.ts +10 -5
  34. package/dist/execution-template/types.d.ts.map +1 -1
  35. package/dist/execution-template/types.js.map +1 -1
  36. package/dist/native/account-claude.d.ts +5 -0
  37. package/dist/native/account-claude.d.ts.map +1 -0
  38. package/dist/native/account-claude.js +42 -0
  39. package/dist/native/account-claude.js.map +1 -0
  40. package/dist/native/account-codex.d.ts +4 -0
  41. package/dist/native/account-codex.d.ts.map +1 -0
  42. package/dist/native/account-codex.js +51 -0
  43. package/dist/native/account-codex.js.map +1 -0
  44. package/dist/native/account-common.d.ts +30 -0
  45. package/dist/native/account-common.d.ts.map +1 -0
  46. package/dist/native/account-common.js +155 -0
  47. package/dist/native/account-common.js.map +1 -0
  48. package/dist/native/account-cursor.d.ts +5 -0
  49. package/dist/native/account-cursor.d.ts.map +1 -0
  50. package/dist/native/account-cursor.js +48 -0
  51. package/dist/native/account-cursor.js.map +1 -0
  52. package/dist/native/account-grok.d.ts +4 -0
  53. package/dist/native/account-grok.d.ts.map +1 -0
  54. package/dist/native/account-grok.js +55 -0
  55. package/dist/native/account-grok.js.map +1 -0
  56. package/dist/native/account-observation.test.d.ts +2 -0
  57. package/dist/native/account-observation.test.d.ts.map +1 -0
  58. package/dist/native/account-observation.test.js +256 -0
  59. package/dist/native/account-observation.test.js.map +1 -0
  60. package/dist/native/account-profiles.d.ts +11 -0
  61. package/dist/native/account-profiles.d.ts.map +1 -0
  62. package/dist/native/account-profiles.js +172 -0
  63. package/dist/native/account-profiles.js.map +1 -0
  64. package/dist/native/account-profiles.test.d.ts +2 -0
  65. package/dist/native/account-profiles.test.d.ts.map +1 -0
  66. package/dist/native/account-profiles.test.js +172 -0
  67. package/dist/native/account-profiles.test.js.map +1 -0
  68. package/dist/native/account-types.d.ts +11 -0
  69. package/dist/native/account-types.d.ts.map +1 -0
  70. package/dist/native/account-types.js +2 -0
  71. package/dist/native/account-types.js.map +1 -0
  72. package/dist/native/acp-requests.d.ts +11 -0
  73. package/dist/native/acp-requests.d.ts.map +1 -0
  74. package/dist/native/acp-requests.js +109 -0
  75. package/dist/native/acp-requests.js.map +1 -0
  76. package/dist/native/acp-rpc.d.ts +15 -0
  77. package/dist/native/acp-rpc.d.ts.map +1 -0
  78. package/dist/native/acp-rpc.js +73 -0
  79. package/dist/native/acp-rpc.js.map +1 -0
  80. package/dist/native/acp.d.ts +15 -0
  81. package/dist/native/acp.d.ts.map +1 -0
  82. package/dist/native/acp.js +285 -0
  83. package/dist/native/acp.js.map +1 -0
  84. package/dist/native/acp.test.d.ts +2 -0
  85. package/dist/native/acp.test.d.ts.map +1 -0
  86. package/dist/native/acp.test.js +257 -0
  87. package/dist/native/acp.test.js.map +1 -0
  88. package/dist/native/claude.d.ts.map +1 -1
  89. package/dist/native/claude.js +14 -0
  90. package/dist/native/claude.js.map +1 -1
  91. package/dist/native/client.d.ts +13 -0
  92. package/dist/native/client.d.ts.map +1 -1
  93. package/dist/native/client.js +112 -1
  94. package/dist/native/client.js.map +1 -1
  95. package/dist/native/codex.d.ts.map +1 -1
  96. package/dist/native/codex.js +61 -3
  97. package/dist/native/codex.js.map +1 -1
  98. package/dist/native/cursor-model.d.ts +6 -0
  99. package/dist/native/cursor-model.d.ts.map +1 -0
  100. package/dist/native/cursor-model.js +74 -0
  101. package/dist/native/cursor-model.js.map +1 -0
  102. package/dist/native/cursor-model.test.d.ts +2 -0
  103. package/dist/native/cursor-model.test.d.ts.map +1 -0
  104. package/dist/native/cursor-model.test.js +40 -0
  105. package/dist/native/cursor-model.test.js.map +1 -0
  106. package/dist/native/cursor.d.ts +2 -0
  107. package/dist/native/cursor.d.ts.map +1 -0
  108. package/dist/native/cursor.js +30 -0
  109. package/dist/native/cursor.js.map +1 -0
  110. package/dist/native/durability.test.js +461 -1
  111. package/dist/native/durability.test.js.map +1 -1
  112. package/dist/native/filesystem-policy.test.d.ts +2 -0
  113. package/dist/native/filesystem-policy.test.d.ts.map +1 -0
  114. package/dist/native/filesystem-policy.test.js +136 -0
  115. package/dist/native/filesystem-policy.test.js.map +1 -0
  116. package/dist/native/grok.d.ts +2 -0
  117. package/dist/native/grok.d.ts.map +1 -0
  118. package/dist/native/grok.js +17 -0
  119. package/dist/native/grok.js.map +1 -0
  120. package/dist/native/host.js +39 -0
  121. package/dist/native/host.js.map +1 -1
  122. package/dist/native/index.d.ts +3 -0
  123. package/dist/native/index.d.ts.map +1 -1
  124. package/dist/native/index.js +3 -0
  125. package/dist/native/index.js.map +1 -1
  126. package/dist/native/ipc.d.ts +59 -0
  127. package/dist/native/ipc.d.ts.map +1 -1
  128. package/dist/native/ipc.js +76 -4
  129. package/dist/native/ipc.js.map +1 -1
  130. package/dist/native/manager.d.ts +22 -4
  131. package/dist/native/manager.d.ts.map +1 -1
  132. package/dist/native/manager.js +386 -46
  133. package/dist/native/manager.js.map +1 -1
  134. package/dist/native/process-tree.d.ts +11 -0
  135. package/dist/native/process-tree.d.ts.map +1 -1
  136. package/dist/native/process-tree.js +123 -20
  137. package/dist/native/process-tree.js.map +1 -1
  138. package/dist/native/process-tree.test.js +126 -1
  139. package/dist/native/process-tree.test.js.map +1 -1
  140. package/dist/native/process.d.ts +5 -1
  141. package/dist/native/process.d.ts.map +1 -1
  142. package/dist/native/process.js +36 -13
  143. package/dist/native/process.js.map +1 -1
  144. package/dist/native/process.test.d.ts +2 -0
  145. package/dist/native/process.test.d.ts.map +1 -0
  146. package/dist/native/process.test.js +56 -0
  147. package/dist/native/process.test.js.map +1 -0
  148. package/dist/native/profile-service.d.ts +5 -0
  149. package/dist/native/profile-service.d.ts.map +1 -0
  150. package/dist/native/profile-service.js +76 -0
  151. package/dist/native/profile-service.js.map +1 -0
  152. package/dist/native/registry.d.ts +20 -0
  153. package/dist/native/registry.d.ts.map +1 -0
  154. package/dist/native/registry.js +89 -0
  155. package/dist/native/registry.js.map +1 -0
  156. package/dist/native/registry.test.d.ts +2 -0
  157. package/dist/native/registry.test.d.ts.map +1 -0
  158. package/dist/native/registry.test.js +13 -0
  159. package/dist/native/registry.test.js.map +1 -0
  160. package/dist/native/review-sandbox.d.ts +12 -0
  161. package/dist/native/review-sandbox.d.ts.map +1 -0
  162. package/dist/native/review-sandbox.js +6 -0
  163. package/dist/native/review-sandbox.js.map +1 -0
  164. package/dist/native/review-sandbox.test.d.ts +2 -0
  165. package/dist/native/review-sandbox.test.d.ts.map +1 -0
  166. package/dist/native/review-sandbox.test.js +40 -0
  167. package/dist/native/review-sandbox.test.js.map +1 -0
  168. package/dist/native/service.d.ts.map +1 -1
  169. package/dist/native/service.js +42 -8
  170. package/dist/native/service.js.map +1 -1
  171. package/dist/native/supervisor.js +121 -33
  172. package/dist/native/supervisor.js.map +1 -1
  173. package/dist/native/types.d.ts +11 -1
  174. package/dist/native/types.d.ts.map +1 -1
  175. package/dist/native/worker-history.d.ts +16 -0
  176. package/dist/native/worker-history.d.ts.map +1 -0
  177. package/dist/native/worker-history.js +77 -0
  178. package/dist/native/worker-history.js.map +1 -0
  179. package/dist/native/worker-history.test.d.ts +2 -0
  180. package/dist/native/worker-history.test.d.ts.map +1 -0
  181. package/dist/native/worker-history.test.js +195 -0
  182. package/dist/native/worker-history.test.js.map +1 -0
  183. package/dist/native/worker-launch.d.ts +72 -0
  184. package/dist/native/worker-launch.d.ts.map +1 -0
  185. package/dist/native/worker-launch.js +134 -0
  186. package/dist/native/worker-launch.js.map +1 -0
  187. package/dist/native/worker-launch.test.d.ts +2 -0
  188. package/dist/native/worker-launch.test.d.ts.map +1 -0
  189. package/dist/native/worker-launch.test.js +61 -0
  190. package/dist/native/worker-launch.test.js.map +1 -0
  191. package/dist/native/worker-relocation.test.d.ts +2 -0
  192. package/dist/native/worker-relocation.test.d.ts.map +1 -0
  193. package/dist/native/worker-relocation.test.js +110 -0
  194. package/dist/native/worker-relocation.test.js.map +1 -0
  195. package/dist/native/worker-service.d.ts +4 -0
  196. package/dist/native/worker-service.d.ts.map +1 -0
  197. package/dist/native/worker-service.js +85 -0
  198. package/dist/native/worker-service.js.map +1 -0
  199. package/dist/task-init/index.d.ts +5 -1
  200. package/dist/task-init/index.d.ts.map +1 -1
  201. package/dist/task-init/index.js +7 -3
  202. package/dist/task-init/index.js.map +1 -1
  203. package/dist/task-init/task-document.d.ts.map +1 -1
  204. package/dist/task-init/task-document.js +2 -2
  205. package/dist/task-init/task-document.js.map +1 -1
  206. package/dist/task-init/write.d.ts +0 -6
  207. package/dist/task-init/write.d.ts.map +1 -1
  208. package/dist/task-init/write.js +1 -8
  209. package/dist/task-init/write.js.map +1 -1
  210. package/package.json +2 -2
  211. package/scripts/cursor-session-discovery.cjs +48 -0
  212. package/scripts/execution-template-cli.mjs +8 -11
  213. package/scripts/review-filesystem.cjs +39 -0
  214. package/scripts/review-session.cjs +45 -0
  215. package/scripts/review-terminal.cjs +134 -0
  216. package/scripts/task-init-cli.mjs +3 -3
@@ -1,22 +1,30 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
3
- import { closeSync, constants, fsyncSync, openSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { closeSync, constants, existsSync, fsyncSync, openSync, readdirSync, writeFileSync, } from 'node:fs';
4
4
  import { access, realpath, stat } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
- import { delimiter, dirname, isAbsolute, join } from 'node:path';
6
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
7
7
  import { promisify } from 'node:util';
8
- import { claudeNativeAdapter } from './claude.js';
9
- import { codexNativeAdapter } from './codex.js';
10
- import { privateDirectory, readJournal } from './storage.js';
8
+ import { assertNativeProfileCurrent, nativeProfileEnvironment, requireNativeProfile, } from './account-profiles.js';
9
+ import { nativeRunnerDefinitions as adapters } from './registry.js';
10
+ import { hostReviewSandboxAvailable, reviewProcessSandbox } from './review-sandbox.js';
11
+ import { durableWrite, privateDirectory, readJournal, readJson } from './storage.js';
12
+ import { NativeWorkerHistory } from './worker-history.js';
13
+ import { nativeWorkerEnvironment, nativeWorkerLaunchDigest, validateNativeWorkerFilesystemPolicy, } from './worker-launch.js';
11
14
  const exec = promisify(execFile);
12
- const adapters = {
13
- codex: { adapter: codexNativeAdapter, binary: 'codex' },
14
- claude: { adapter: claudeNativeAdapter, binary: 'claude' },
15
- };
16
- export async function resolveNativeExecutable(binary, cwd) {
15
+ export async function resolveNativeExecutable(binary, cwd, environment = process.env) {
17
16
  let executable;
18
- for (const directory of (process.env.PATH ?? '').split(delimiter)) {
19
- const candidate = join(directory, binary);
17
+ const requested = binary.startsWith('~/')
18
+ ? join(environment.HOME ?? homedir(), binary.slice(2))
19
+ : binary;
20
+ const candidates = isAbsolute(requested)
21
+ ? [requested]
22
+ : requested.includes('/')
23
+ ? [resolve(cwd, requested)]
24
+ : (environment.PATH ?? '')
25
+ .split(delimiter)
26
+ .map((directory) => resolve(cwd, directory, requested));
27
+ for (const candidate of candidates) {
20
28
  try {
21
29
  await access(candidate, constants.X_OK);
22
30
  }
@@ -25,39 +33,74 @@ export async function resolveNativeExecutable(binary, cwd) {
25
33
  continue;
26
34
  throw error;
27
35
  }
28
- executable = candidate;
36
+ if (candidate.includes('/.asdf/shims/')) {
37
+ try {
38
+ const resolved = await exec('asdf', ['which', basename(binary)], {
39
+ cwd,
40
+ timeout: 10_000,
41
+ env: environment,
42
+ });
43
+ executable = resolved.stdout.trim();
44
+ }
45
+ catch (error) {
46
+ // Fresh worktrees may not select the shim's runtime. For a PATH lookup,
47
+ // continue to another installed executable; explicit paths remain exact.
48
+ if (error.code === 1 &&
49
+ !isAbsolute(requested) &&
50
+ !requested.includes('/'))
51
+ continue;
52
+ throw error;
53
+ }
54
+ }
55
+ else
56
+ executable = candidate;
29
57
  break;
30
58
  }
31
59
  if (!executable)
32
60
  throw new Error(`Install and log into the native ${binary} runner first`);
33
- if (executable.includes('/.asdf/shims/')) {
34
- const resolved = await exec('asdf', ['which', binary], { cwd, timeout: 10_000 });
35
- executable = resolved.stdout.trim();
36
- }
37
61
  executable = await realpath(executable);
38
62
  const result = await exec(executable, ['--version'], {
39
63
  cwd,
40
64
  timeout: 10_000,
41
- env: nativeEnvironment(executable),
65
+ env: nativeEnvironment(executable, environment),
42
66
  });
43
67
  const version = result.stdout.trim();
44
68
  if (!version)
45
69
  throw new Error('Native executable did not provide version metadata');
46
70
  return { executable, version };
47
71
  }
48
- function executionContext() {
72
+ function executionContext(workerLaunch, runner, profile) {
73
+ const environment = profile
74
+ ? nativeProfileEnvironment(profile, workerLaunch ? nativeWorkerEnvironment(workerLaunch) : process.env)
75
+ : process.env;
49
76
  return {
50
- home: homedir(),
51
- codexHome: process.env.CODEX_HOME ?? '',
52
- claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? '',
77
+ home: profile ? (environment.HOME ?? homedir()) : homedir(),
78
+ codexHome: environment.CODEX_HOME ?? '',
79
+ claudeConfigDir: environment.CLAUDE_CONFIG_DIR ?? '',
80
+ ...(runner === 'cursor'
81
+ ? {
82
+ cursorConfigDir: environment.CURSOR_CONFIG_DIR ?? '',
83
+ cursorDataDir: environment.CURSOR_DATA_DIR ?? '',
84
+ xdgConfigHome: environment.XDG_CONFIG_HOME ?? '',
85
+ }
86
+ : {}),
87
+ ...(runner === 'grok' ? { grokHome: environment.GROK_HOME ?? '' } : {}),
88
+ ...(workerLaunch ? { workerLaunchDigest: nativeWorkerLaunchDigest(workerLaunch) } : {}),
89
+ ...(profile
90
+ ? {
91
+ profileId: profile.id,
92
+ profileDirectory: profile.directory,
93
+ profileAccountContextId: profile.accountContextId,
94
+ }
95
+ : {}),
53
96
  };
54
97
  }
55
- function nativeEnvironment(executable) {
98
+ function nativeEnvironment(executable, base = process.env) {
56
99
  // Native configuration may reference environment credentials. Inherit it in-process only;
57
100
  // never persist or send it over IPC. Remove parent Claude invocation context.
58
101
  const env = {
59
- ...process.env,
60
- PATH: `${dirname(executable)}${delimiter}${process.env.PATH ?? ''}`,
102
+ ...base,
103
+ PATH: `${dirname(executable)}${delimiter}${base.PATH ?? ''}`,
61
104
  };
62
105
  delete env.CLAUDECODE;
63
106
  return env;
@@ -81,7 +124,9 @@ export class NativeSessionManager {
81
124
  let pending = [];
82
125
  const commands = new Map();
83
126
  const events = [];
127
+ const workerHistory = new NativeWorkerHistory();
84
128
  for (const entry of entries) {
129
+ workerHistory.observe(entry);
85
130
  info = entry.info ?? info;
86
131
  context = entry.context ?? context;
87
132
  pending = entry.pending ?? pending;
@@ -95,6 +140,7 @@ export class NativeSessionManager {
95
140
  if (info.executionNodeId !== executionNodeId)
96
141
  throw new Error('Native journal belongs to another execution node');
97
142
  const record = {
143
+ workerHistory,
98
144
  info,
99
145
  context,
100
146
  commands,
@@ -157,29 +203,53 @@ export class NativeSessionManager {
157
203
  for (const command of commands)
158
204
  previous.commands.set(command.commandId, JSON.stringify(command));
159
205
  this.persisted.set(record, { info, context, pending, commands: previous.commands });
206
+ record.workerHistory.observe(entry);
160
207
  }
161
- create(owner, params) {
162
- return this.start(owner, params);
208
+ create(owner, params, workerLaunch) {
209
+ return this.start(owner, params, undefined, workerLaunch);
163
210
  }
164
- async ensure(owner, params) {
211
+ async ensure(owner, params, workerLaunch) {
165
212
  if (typeof params.sessionId !== 'string' ||
166
213
  !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(params.sessionId))
167
214
  throw new Error('Reserved sessionId must be a lowercase UUID');
168
215
  if ('resumeSessionId' in params && params.resumeSessionId !== undefined)
169
216
  throw new Error('Reserved sessionId is for initial creation, not resume');
170
- return this.start(owner, params, params.sessionId);
217
+ return this.start(owner, params, params.sessionId, workerLaunch);
171
218
  }
172
- async start(ownerPrincipalId, params, reservedId) {
219
+ resumeWorker(owner, params, launch) {
220
+ const assertCurrent = () => {
221
+ this.assertWorkerLease(owner, params.sessionId, params.generation, launch.leaseId, true);
222
+ const record = this.owned(owner, params.sessionId);
223
+ if (record.info.nativeSessionId !== params.resumeSessionId)
224
+ throw new Error('Worker recovery must preserve its recorded session identity');
225
+ if (existsSync(this.resumeCancellationPath(params.sessionId, launch.leaseId, params.commandId)))
226
+ throw new Error('Native worker recovery operation was cancelled before launch');
227
+ };
228
+ return this.start(owner, params, undefined, launch, assertCurrent, params.relocation);
229
+ }
230
+ async start(ownerPrincipalId, params, reservedId, workerLaunch, assertResumeCurrent, relocation) {
231
+ assertResumeCurrent?.();
232
+ const profile = params.profileId
233
+ ? requireNativeProfile(params.profileId, params.runner)
234
+ : undefined;
235
+ if (profile && params.accountContextId !== profile.accountContextId)
236
+ throw new Error('Native account profile changed; select its current account binding');
173
237
  if (params.executionNodeId !== undefined && params.executionNodeId !== this.executionNodeId)
174
238
  throw new Error('Native session targets another execution node');
175
239
  if (reservedId !== undefined) {
176
- const existing = this.existingCreation(ownerPrincipalId, params, reservedId);
240
+ const existing = this.existingCreation(ownerPrincipalId, params, reservedId, workerLaunch);
177
241
  if (existing)
178
242
  return this.createdSession(existing);
243
+ if (workerLaunch)
244
+ this.assertWorkerNotCancelled(reservedId);
179
245
  }
180
246
  const transport = adapters[params.runner];
181
247
  if (!transport)
182
248
  throw new Error(`Runner has no native transport: ${params.runner}`);
249
+ if (workerLaunch && !transport.supportsWorkers)
250
+ throw new Error('Native worker execution is not supported for this runner');
251
+ if (workerLaunch?.filesystemPolicy && !transport.supportsReadOnlyWorkspace)
252
+ throw new Error('Native runner cannot enforce a read-only source workspace');
183
253
  if (!isAbsolute(params.cwd) || !(await stat(params.cwd)).isDirectory())
184
254
  throw new Error('Native cwd must be an existing absolute directory');
185
255
  let previous;
@@ -198,27 +268,89 @@ export class NativeSessionManager {
198
268
  throw new Error('Close the current native input owner before resuming');
199
269
  if (previous.info.processPid && !previous.info.processStopped)
200
270
  throw new Error('Native process cleanup is unconfirmed; recovery is blocked');
201
- if (JSON.stringify(previous.context) !== JSON.stringify(executionContext()))
271
+ if (previous.context.workerLaunchDigest && !workerLaunch)
272
+ throw new Error('Worker recovery requires its recorded launch configuration');
273
+ if (previous.info.workerLeaseId !== workerLaunch?.leaseId)
274
+ throw new Error('Worker recovery must preserve its task lease');
275
+ if (JSON.stringify(previous.context) !==
276
+ JSON.stringify(executionContext(workerLaunch, params.runner, profile)))
202
277
  throw new Error('Resume must preserve the native account execution context');
203
- if (previous.info.cwd !== params.cwd)
278
+ if (relocation) {
279
+ if (!assertResumeCurrent ||
280
+ relocation.fromCwd !== previous.info.cwd ||
281
+ !previous.info.capabilities.resumeAcrossWorkspaces)
282
+ throw new Error('Native relocation requires the exact previous workspace and a portable saved session');
283
+ const roots = await Promise.all([previous.info.cwd, params.cwd].map(async (cwd) => {
284
+ const result = await exec('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd, timeout: 10_000 });
285
+ return realpath(result.stdout.trim());
286
+ }));
287
+ if (roots[0] !== roots[1])
288
+ throw new Error('Native relocation requires sibling worktrees of the same repository');
289
+ const unavailable = transport.adapter.workspaceResumeUnavailableReason?.(previous.info.version);
290
+ if (unavailable)
291
+ throw new Error(unavailable);
292
+ }
293
+ else if (previous.info.cwd !== params.cwd)
204
294
  throw new Error('Resume must preserve the recorded working directory');
205
295
  mode = params.mode ?? previous.info.mode;
206
296
  }
207
297
  if (!transport.adapter.capabilities.modes.includes(mode))
208
298
  throw new Error('Native runner does not support this interaction mode');
209
- const resolved = await resolveNativeExecutable(transport.binary, params.cwd);
299
+ const baseEnvironment = workerLaunch ? nativeWorkerEnvironment(workerLaunch) : process.env;
300
+ const environment = profile
301
+ ? nativeProfileEnvironment(profile, baseEnvironment)
302
+ : baseEnvironment;
303
+ const resolved = await resolveNativeExecutable(workerLaunch?.executable ?? transport.binary, params.cwd, environment);
210
304
  const unavailable = transport.adapter.resumeUnavailableReason?.(resolved.version);
211
305
  if (params.resumeSessionId && unavailable)
212
306
  throw new Error(unavailable);
307
+ let filesystemPolicy = workerLaunch?.filesystemPolicy;
308
+ if (filesystemPolicy) {
309
+ const unsupported = transport.adapter.filesystemPolicyUnavailableReason?.(resolved.version);
310
+ if (unsupported ||
311
+ (!transport.adapter.filesystemPolicyUnavailableReason && !hostReviewSandboxAvailable()))
312
+ throw new Error(unsupported ?? 'Native runner cannot enforce filesystem policy');
313
+ filesystemPolicy = validateNativeWorkerFilesystemPolicy({
314
+ readOnlyRoots: await Promise.all([...filesystemPolicy.readOnlyRoots, this.root].map((root) => realpath(root))),
315
+ writableRoots: await Promise.all(filesystemPolicy.writableRoots.map((root) => realpath(root))),
316
+ });
317
+ if (!filesystemPolicy.readOnlyRoots.includes(await realpath(params.cwd)))
318
+ throw new Error('Native worker cwd must be an explicit read-only source root');
319
+ if (!transport.adapter.filesystemPolicyUnavailableReason &&
320
+ existsSync(join(params.cwd, '.git'))) {
321
+ const gitDirectory = await exec('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: params.cwd, timeout: 10000 });
322
+ filesystemPolicy.readOnlyRoots.push(await realpath(gitDirectory.stdout.trim()));
323
+ }
324
+ if (workerLaunch?.safetyTier !== 'sandboxed')
325
+ throw new Error('Read-only native workers require sandboxed execution');
326
+ }
327
+ const hostProtection = filesystemPolicy && !transport.adapter.filesystemPolicyUnavailableReason
328
+ ? await reviewProcessSandbox(filesystemPolicy, transport.reviewRuntimeRoots?.(environment) ?? [])
329
+ : undefined;
330
+ if (relocation) {
331
+ const unavailable = transport.adapter.workspaceResumeUnavailableReason?.(resolved.version);
332
+ if (unavailable)
333
+ throw new Error(unavailable);
334
+ }
213
335
  // Validation above awaits filesystem/process probes. Recheck synchronously before reservation.
214
- const existing = this.existingCreation(ownerPrincipalId, params, reservedId);
336
+ assertResumeCurrent?.();
337
+ if (profile)
338
+ assertNativeProfileCurrent(profile);
339
+ const existing = this.existingCreation(ownerPrincipalId, params, reservedId, workerLaunch);
215
340
  if (existing)
216
341
  return this.createdSession(existing);
342
+ if (reservedId && workerLaunch)
343
+ this.assertWorkerNotCancelled(reservedId);
217
344
  // Reserve before starting the child, so simultaneous resume calls cannot create two input owners.
218
345
  if (params.resumeSessionId &&
219
346
  [...this.sessions.values()].some((record) => record.info.nativeSessionId === params.resumeSessionId &&
220
347
  !['closed', 'failed'].includes(record.info.state)))
221
348
  throw new Error('Native session already has an input owner');
349
+ const effort = workerLaunch
350
+ ? workerLaunch.effort
351
+ : previous
352
+ ? previous.info.effort
353
+ : transport.defaultEffort;
222
354
  const info = {
223
355
  id: previous?.info.id ?? reservedId ?? randomUUID(),
224
356
  generation: randomUUID(),
@@ -227,23 +359,35 @@ export class NativeSessionManager {
227
359
  nativeSessionId: params.resumeSessionId ?? '',
228
360
  ownerPrincipalId,
229
361
  executionNodeId: this.executionNodeId,
230
- accountContextId: this.executionNodeId === 'local'
231
- ? `native-local:${ownerPrincipalId}`
232
- : `native-node:${JSON.stringify([this.executionNodeId, ownerPrincipalId])}`,
362
+ accountContextId: profile?.accountContextId ??
363
+ (this.executionNodeId === 'local'
364
+ ? `native-local:${ownerPrincipalId}`
365
+ : `native-node:${JSON.stringify([this.executionNodeId, ownerPrincipalId])}`),
366
+ ...(profile ? { profileId: profile.id } : {}),
233
367
  cwd: params.cwd,
234
368
  ...resolved,
235
369
  model: params.model ?? previous?.info.model,
236
370
  mode,
237
371
  accountMode: 'native',
372
+ ...(effort ? { effort } : {}),
373
+ ...(workerLaunch ? { workerManaged: true, workerLeaseId: workerLaunch.leaseId } : {}),
238
374
  state: 'starting',
239
- capabilities: { ...transport.adapter.capabilities },
375
+ capabilities: {
376
+ ...transport.adapter.capabilities,
377
+ resumeAcrossWorkspaces: transport.adapter.capabilities.resumeAcrossWorkspaces === true &&
378
+ // A source policy pins exact paths in the launch digest. Relocation must
379
+ // not silently rewrite its grants while resuming the saved conversation.
380
+ !workerLaunch?.filesystemPolicy &&
381
+ !transport.adapter.workspaceResumeUnavailableReason?.(resolved.version),
382
+ },
240
383
  };
241
384
  const record = {
385
+ workerHistory: previous?.workerHistory ?? new NativeWorkerHistory(),
242
386
  info,
243
387
  events: previous?.events ?? [],
244
388
  commands: previous?.commands ?? new Map(),
245
389
  pendingRequests: new Map(),
246
- context: executionContext(),
390
+ context: executionContext(workerLaunch, params.runner, profile),
247
391
  sending: false,
248
392
  };
249
393
  this.sessions.set(info.id, record);
@@ -262,10 +406,13 @@ export class NativeSessionManager {
262
406
  throw error;
263
407
  }
264
408
  try {
409
+ record.startupAbort = new AbortController();
265
410
  record.startup = transport.adapter.start({
266
411
  ...params,
412
+ signal: record.startupAbort.signal,
267
413
  mode,
268
414
  model: info.model,
415
+ effort,
269
416
  onSpawn: (pid, identity) => {
270
417
  info.processPid = pid;
271
418
  info.processIdentity = identity;
@@ -273,12 +420,22 @@ export class NativeSessionManager {
273
420
  this.persist(record);
274
421
  },
275
422
  executable: resolved.executable,
276
- env: nativeEnvironment(resolved.executable),
423
+ env: nativeEnvironment(resolved.executable, {
424
+ ...environment,
425
+ ...(hostProtection ? { TMPDIR: hostProtection.temporaryDirectory } : {}),
426
+ }),
427
+ processSandbox: hostProtection?.sandbox,
428
+ ...(workerLaunch
429
+ ? { effort: workerLaunch.effort, safetyTier: workerLaunch.safetyTier, filesystemPolicy }
430
+ : {}),
277
431
  }, (event) => this.append(record, event));
278
432
  record.adapter = await record.startup;
279
433
  info.nativeSessionId = record.adapter.nativeSessionId;
280
434
  if (record.adapter.capabilities)
281
- info.capabilities = { ...record.adapter.capabilities };
435
+ info.capabilities = {
436
+ ...record.adapter.capabilities,
437
+ resumeAcrossWorkspaces: info.capabilities.resumeAcrossWorkspaces,
438
+ };
282
439
  if (info.state === 'starting')
283
440
  info.state = 'idle';
284
441
  this.persist(record);
@@ -290,29 +447,179 @@ export class NativeSessionManager {
290
447
  throw error;
291
448
  }
292
449
  }
293
- existingCreation(owner, params, reservedId) {
450
+ existingCreation(owner, params, reservedId, workerLaunch) {
294
451
  const record = reservedId ? this.sessions.get(reservedId) : undefined;
295
452
  if (!record)
296
453
  return undefined;
297
454
  if (record.info.ownerPrincipalId !== owner)
298
455
  throw new Error('Reserved sessionId belongs to another owner');
299
- for (const field of ['runner', 'cwd', 'model', 'mode']) {
456
+ if (record.info.workerLeaseId !== workerLaunch?.leaseId)
457
+ throw new Error('Reserved sessionId belongs to another worker task lease');
458
+ for (const field of ['runner', 'cwd', 'model', 'mode', 'profileId']) {
300
459
  const requested = field === 'mode' ? (params.mode ?? 'default') : params[field];
301
460
  if (record.info[field] !== requested)
302
461
  throw new Error(`Reserved sessionId launch configuration differs: ${field}`);
303
462
  }
304
- if (JSON.stringify(record.context) !== JSON.stringify(executionContext()))
463
+ if (record.context.workerLaunchDigest !==
464
+ (workerLaunch ? nativeWorkerLaunchDigest(workerLaunch) : undefined))
465
+ throw new Error('Reserved sessionId launch configuration differs: worker launch configuration');
466
+ const profile = params.profileId
467
+ ? requireNativeProfile(params.profileId, params.runner)
468
+ : undefined;
469
+ if (JSON.stringify(record.context) !==
470
+ JSON.stringify(executionContext(workerLaunch, params.runner, profile)))
305
471
  throw new Error('Reserved sessionId launch configuration differs: account execution context');
306
472
  return record;
307
473
  }
308
474
  async createdSession(record) {
309
475
  if (record.reservationError)
310
476
  throw record.reservationError;
477
+ if (record.workerLeaseError)
478
+ throw record.workerLeaseError;
311
479
  if (record.info.state === 'starting' && record.startup)
312
480
  await record.startup;
313
481
  // Failed/closed reservations remain explicit. A retry cannot create a replacement process.
314
482
  return this.snapshot(record);
315
483
  }
484
+ assertWorkerLease(owner, id, generation, leaseId, closing = false) {
485
+ const record = this.owned(owner, id);
486
+ if (record.info.generation !== generation || record.info.workerLeaseId !== leaseId)
487
+ throw new Error('Native worker generation or task lease changed');
488
+ if (record.workerLeaseError && !closing)
489
+ throw record.workerLeaseError;
490
+ }
491
+ async closeWorker(owner, id, generation, leaseId) {
492
+ this.assertWorkerLease(owner, id, generation, leaseId, true);
493
+ const record = this.owned(owner, id);
494
+ await this.close(owner, id);
495
+ return this.snapshot(record);
496
+ }
497
+ assertWorkerNotCancelled(id) {
498
+ if (existsSync(join(this.root, 'cancelled-workers', `${id}.json`)))
499
+ throw new Error('Native worker reservation was cancelled before launch');
500
+ }
501
+ resumeCancellationPath(id, leaseId, commandId) {
502
+ if (!commandId.trim())
503
+ throw new Error('Native recovery command ID is required');
504
+ const key = createHash('sha256')
505
+ .update(JSON.stringify([id, leaseId, commandId]))
506
+ .digest('hex');
507
+ return join(this.root, 'cancelled-resumes', `${key}.json`);
508
+ }
509
+ async cancelWorker(owner, id, leaseId, generation, sourceLeaseId, resumeCommandId) {
510
+ if (![id, leaseId].every((value) => /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value)))
511
+ throw new Error('Native worker reservation and lease must be lowercase UUIDs');
512
+ const root = join(this.root, 'cancelled-workers');
513
+ privateDirectory(root);
514
+ const markerPath = join(root, `${id}.json`);
515
+ const marker = existsSync(markerPath)
516
+ ? readJson(markerPath)
517
+ : undefined;
518
+ const record = this.sessions.get(id);
519
+ if (resumeCommandId !== undefined) {
520
+ if (!generation)
521
+ throw new Error('Native recovery cancellation requires its source generation');
522
+ if (!record ||
523
+ record.info.ownerPrincipalId !== owner ||
524
+ record.info.workerLeaseId !== leaseId)
525
+ throw new Error('Native recovery cancellation ownership changed');
526
+ // Keep every operation fence. A later explicit recovery uses a different ID,
527
+ // but must never make an older delayed request launchable again.
528
+ privateDirectory(join(this.root, 'cancelled-resumes'));
529
+ durableWrite(this.resumeCancellationPath(id, leaseId, resumeCommandId), { owner });
530
+ if (record.info.generation !== generation)
531
+ return {
532
+ cancelled: false,
533
+ reason: 'generation-changed',
534
+ sessionId: id,
535
+ leaseId,
536
+ generation: record.info.generation,
537
+ };
538
+ }
539
+ if (sourceLeaseId !== undefined) {
540
+ if (!generation ||
541
+ !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(sourceLeaseId) ||
542
+ sourceLeaseId === leaseId)
543
+ throw new Error('Native handoff cancellation requires an exact source lease and generation');
544
+ if (record?.info.workerLeaseId === sourceLeaseId) {
545
+ if (record.info.ownerPrincipalId !== owner || record.info.generation !== generation)
546
+ throw new Error('Native handoff cancellation ownership changed');
547
+ // Claim the reserved successor lease before stopping. A delayed transfer to
548
+ // that lease now observes the same closed record; stale source commands fail.
549
+ record.workerHistory.assertNewLease(leaseId);
550
+ record.info.workerLeaseId = leaseId;
551
+ try {
552
+ this.persist(record);
553
+ }
554
+ catch (error) {
555
+ record.workerLeaseError = error;
556
+ throw error;
557
+ }
558
+ }
559
+ }
560
+ if (marker &&
561
+ (marker.owner !== owner ||
562
+ (marker.leaseId !== leaseId && record?.info.workerLeaseId !== leaseId)))
563
+ throw new Error('Native worker cancellation belongs to another owner or task lease');
564
+ // A replay of an earlier cancel cannot stop an explicitly resumed generation.
565
+ if (marker?.leaseId === leaseId &&
566
+ record &&
567
+ record.info.generation !== (generation ?? marker.generation))
568
+ return {
569
+ cancelled: true,
570
+ sessionId: id,
571
+ leaseId,
572
+ generation: generation ?? marker.generation,
573
+ };
574
+ if (record) {
575
+ if (record.info.ownerPrincipalId !== owner ||
576
+ record.info.workerLeaseId !== leaseId ||
577
+ (generation !== undefined && record.info.generation !== generation))
578
+ throw new Error('Native worker cancellation ownership changed');
579
+ }
580
+ else if (generation && !marker) {
581
+ throw new Error('Known native worker session is unavailable; stop cannot be confirmed');
582
+ }
583
+ const cancelledGeneration = record?.info.generation ?? generation ?? marker?.generation;
584
+ // This durable tombstone also catches ensure calls still awaiting executable probes.
585
+ durableWrite(markerPath, { owner, leaseId, generation: cancelledGeneration });
586
+ if (record)
587
+ await this.close(owner, id);
588
+ return {
589
+ cancelled: true,
590
+ sessionId: id,
591
+ leaseId,
592
+ generation: cancelledGeneration,
593
+ ...(record ? { session: this.snapshot(record) } : {}),
594
+ };
595
+ }
596
+ transferWorker(owner, id, generation, leaseId, launch) {
597
+ const record = this.owned(owner, id);
598
+ if (record.workerLeaseError)
599
+ throw record.workerLeaseError;
600
+ if (record.info.generation !== generation ||
601
+ record.context.workerLaunchDigest !== nativeWorkerLaunchDigest(launch))
602
+ throw new Error('Native worker handoff must preserve generation and launch configuration');
603
+ if (record.info.workerLeaseId === launch.leaseId)
604
+ return this.snapshot(record);
605
+ this.assertWorkerLease(owner, id, generation, leaseId);
606
+ const stopped = ['closed', 'failed'].includes(record.info.state) &&
607
+ record.info.processStopped &&
608
+ Boolean(record.info.nativeSessionId);
609
+ if ((record.info.state !== 'idle' && !stopped) || record.sending || record.pendingRequests.size)
610
+ throw new Error('Native worker handoff requires an idle session or a confirmed stopped session with no pending requests');
611
+ record.workerHistory.assertNewLease(launch.leaseId);
612
+ record.info.workerLeaseId = launch.leaseId;
613
+ try {
614
+ this.persist(record);
615
+ }
616
+ catch (error) {
617
+ // The write may have landed. Neither lease may submit more worker input until recovery.
618
+ record.workerLeaseError = error;
619
+ throw error;
620
+ }
621
+ return this.snapshot(record);
622
+ }
316
623
  append(record, event) {
317
624
  const enriched = {
318
625
  ...event,
@@ -420,8 +727,27 @@ export class NativeSessionManager {
420
727
  pendingRequests: [...record.pendingRequests.values()],
421
728
  };
422
729
  }
730
+ readWorker(owner, id, leaseId, after, limit) {
731
+ const record = this.owned(owner, id);
732
+ if (record.workerLeaseError)
733
+ throw record.workerLeaseError;
734
+ const result = record.workerHistory.read(leaseId, record.events, [...record.pendingRequests.values()], after, limit);
735
+ return { ...result, session: this.snapshot({ info: result.session }) };
736
+ }
737
+ assertAccountCurrent(record) {
738
+ if (!record.info.profileId)
739
+ return;
740
+ assertNativeProfileCurrent({
741
+ id: record.info.profileId,
742
+ runner: record.info.runner,
743
+ directory: record.context.profileDirectory,
744
+ accountContextId: record.info.accountContextId,
745
+ state: 'active',
746
+ });
747
+ }
423
748
  async send(owner, id, commandId, text) {
424
749
  const record = this.owned(owner, id);
750
+ this.assertAccountCurrent(record);
425
751
  const existing = record.commands.get(commandId);
426
752
  if (existing) {
427
753
  if (existing.text !== text)
@@ -472,6 +798,7 @@ export class NativeSessionManager {
472
798
  }
473
799
  async respond(owner, id, requestId, response) {
474
800
  const record = this.owned(owner, id);
801
+ this.assertAccountCurrent(record);
475
802
  const pending = record.pendingRequests.get(requestId);
476
803
  if (!record.adapter || !pending || pending.generation !== record.info.generation)
477
804
  throw new Error('Native request is stale or belongs to another session');
@@ -501,6 +828,19 @@ export class NativeSessionManager {
501
828
  }
502
829
  record.info.state = 'closing';
503
830
  this.persist(record);
831
+ if (!record.adapter && record.startup) {
832
+ record.startupAbort?.abort();
833
+ try {
834
+ await record.startup;
835
+ }
836
+ catch (error) {
837
+ // Startup rejection is expected after cancellation only when the process
838
+ // adapter has independently confirmed that its entire owned tree stopped.
839
+ if (record.info.processStopped && ['closed', 'failed'].includes(record.info.state))
840
+ return;
841
+ throw error;
842
+ }
843
+ }
504
844
  const adapter = record.adapter ?? (await record.startup);
505
845
  await adapter?.close();
506
846
  if (!['closed', 'failed'].includes(this.owned(owner, id).info.state))