@michelj/context-guard 0.4.4 → 0.6.2

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 (101) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +72 -102
  4. package/README.zh-CN.md +72 -102
  5. package/SKILL.md +26 -33
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/bin/build-runtime.mjs +96 -0
  9. package/bin/context-guard-skill.js +287 -69
  10. package/bin/postinstall.js +1 -1
  11. package/hooks.json +80 -4
  12. package/licenses/JSONParse-MIT.txt +24 -0
  13. package/licenses/Marked-MIT.txt +44 -0
  14. package/licenses/Portless-Apache-2.0.txt +201 -0
  15. package/package.json +31 -5
  16. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  17. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  18. package/prototype/attachments.mjs +75 -0
  19. package/prototype/coordinator-markdown.mjs +283 -0
  20. package/prototype/coordinator-working-blot.mjs +124 -0
  21. package/prototype/vendor/marked.mjs +2189 -0
  22. package/prototype/workbench-app.js +5197 -0
  23. package/prototype/workbench-data.js +33 -0
  24. package/prototype/workbench-sync.mjs +898 -0
  25. package/prototype/workbench.css +1050 -0
  26. package/prototype/workbench.html +139 -4861
  27. package/prototype/working-blot-atlas.png +0 -0
  28. package/references/agent-handoff.md +40 -0
  29. package/references/claude-runtime.md +120 -0
  30. package/references/cloud-sync-interface.md +66 -0
  31. package/references/design-current.md +14 -0
  32. package/references/map-mount.md +41 -0
  33. package/references/map-read.md +50 -0
  34. package/references/memory-definition.md +120 -0
  35. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  36. package/references/memory-filesystem-v2/Bug.md +162 -0
  37. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  38. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  40. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  41. package/references/memory-filesystem-v2/Idea.md +36 -0
  42. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  44. package/references/memory-filesystem-v2/README.md +60 -0
  45. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  46. package/references/memory-filesystem-v2/Todo.md +137 -0
  47. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  48. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  50. package/references/named-workbench.md +124 -0
  51. package/references/plan-review.md +12 -0
  52. package/references/server-memory.md +276 -0
  53. package/references/test-check.md +7 -0
  54. package/references/user-reply.md +38 -0
  55. package/references/workbench-interface.md +531 -0
  56. package/roles.md +13 -0
  57. package/scripts/context_guard.py +1163 -321
  58. package/scripts/context_guard_hook.py +1864 -63
  59. package/scripts/map_owns.py +68 -138
  60. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  61. package/scripts/shared/filesystem-v2.mjs +430 -0
  62. package/scripts/shared/io.mjs +117 -0
  63. package/scripts/shared/map-model.mjs +506 -0
  64. package/scripts/shared/memory-schema.mjs +13 -0
  65. package/scripts/shared/protocol-blobs.mjs +112 -0
  66. package/scripts/shared/protocol-map.mjs +146 -0
  67. package/scripts/shared/protocol-snapshots.mjs +84 -0
  68. package/scripts/shared/protocol-store.mjs +624 -0
  69. package/scripts/shared/protocol-workflow.mjs +226 -0
  70. package/scripts/shared/protocol.mjs +125 -0
  71. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  72. package/scripts/workbench/access.mjs +496 -0
  73. package/scripts/workbench/attachments.mjs +92 -0
  74. package/scripts/workbench/browser-login.mjs +78 -0
  75. package/scripts/workbench/claude-runtime.mjs +372 -0
  76. package/scripts/workbench/cli.mjs +980 -0
  77. package/scripts/workbench/device-heartbeat.mjs +72 -0
  78. package/scripts/workbench/hook-status.mjs +38 -0
  79. package/scripts/workbench/inbox.mjs +155 -0
  80. package/scripts/workbench/journal.mjs +56 -0
  81. package/scripts/workbench/memory-merge.mjs +65 -0
  82. package/scripts/workbench/memory.mjs +252 -0
  83. package/scripts/workbench/named-proxy.mjs +108 -0
  84. package/scripts/workbench/named.mjs +152 -0
  85. package/scripts/workbench/portless-routes.mjs +51 -0
  86. package/scripts/workbench/project.mjs +327 -0
  87. package/scripts/workbench/projections.mjs +68 -0
  88. package/scripts/workbench/protocol-client.mjs +165 -0
  89. package/scripts/workbench/protocol-delivery.mjs +133 -0
  90. package/scripts/workbench/protocol-device.mjs +316 -0
  91. package/scripts/workbench/protocol-events.mjs +53 -0
  92. package/scripts/workbench/protocol-repository.mjs +58 -0
  93. package/scripts/workbench/reconcile.mjs +244 -0
  94. package/scripts/workbench/registry.mjs +111 -0
  95. package/scripts/workbench/runtime.mjs +54 -0
  96. package/scripts/workbench/server.mjs +1171 -0
  97. package/scripts/workbench/store.mjs +243 -0
  98. package/scripts/workbench/sync-coordinator.mjs +518 -0
  99. package/scripts/workbench/sync.mjs +86 -0
  100. package/references/bug-record-template.md +0 -37
  101. package/references/context-template.md +0 -19
@@ -0,0 +1,372 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawn, execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+ import { atomicWrite, encode, hash, readJSON, withFileLock } from '../shared/io.mjs';
8
+ import { canonical } from '../shared/protocol.mjs';
9
+
10
+ const ownFile = fileURLToPath(import.meta.url);
11
+ const exec = promisify(execFile);
12
+ const git = async (root, ...args) => (await exec('git', args, { cwd: root, windowsHide: true })).stdout.trim();
13
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
15
+ const alive = pid => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; } };
16
+ async function attestCreatedSession(root, sessionId) {
17
+ const file = path.join(root, '.codex/context/sessions.jsonl');
18
+ await withFileLock(file + '.lock', async () => {
19
+ const text = await fs.readFile(file, 'utf8').catch(error => error.code === 'ENOENT' ? '' : Promise.reject(error));
20
+ if (text.split('\n').some(line => {
21
+ try { return JSON.parse(line).session_id === sessionId; } catch { return false; }
22
+ })) return;
23
+ await fs.mkdir(path.dirname(file), { recursive: true });
24
+ await fs.appendFile(file, `${JSON.stringify({ at: new Date().toISOString(), event: 'session-start', platform: 'claude', session_id: sessionId,
25
+ source: 'claude-runtime-provision', worktree_root: root })}\n`);
26
+ });
27
+ }
28
+ const providerKeys = new Set(['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_ATTRIBUTION_HEADER']);
29
+ function verifyRecovery(active, deliveryId) {
30
+ if (!deliveryId || active?.id !== deliveryId || active.state !== 'interrupted') fail('RECOVERY_NOT_AVAILABLE', 'Recovery does not match the active interrupted delivery');
31
+ if (alive(active.workerPid) || alive(active.childPid)) fail('RUNTIME_BUSY', 'The previous native process has not exited');
32
+ }
33
+
34
+ export async function claudeEnvironment(config, credentials, parent = process.env, platform = process.platform) {
35
+ // Keep native shell/profile discovery without inheriting unrelated credentials.
36
+ const keep = new Set(['PATH', 'SYSTEMROOT', 'LANG', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'HOME', 'CONTEXT_GUARD_NAMED_STATE_DIR',
37
+ 'CONTEXT_GUARD_GITHUB_BILLING_WAIVER_REPO', 'CONTEXT_GUARD_GITHUB_BILLING_WAIVER_UNTIL',
38
+ ...(platform === 'win32' ? ['COMSPEC', 'PATHEXT', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'CLAUDE_CODE_GIT_BASH_PATH'] : [])]);
39
+ const env = Object.fromEntries(Object.entries(parent).filter(([key, value]) => keep.has(key.toUpperCase()) && typeof value === 'string'));
40
+ if (platform === 'win32' && !Object.keys(env).some(key => key.toUpperCase() === 'CLAUDE_CODE_GIT_BASH_PATH')) {
41
+ const searchPath = Object.entries(env).find(([key]) => key.toUpperCase() === 'PATH')?.[1] || '';
42
+ for (const directory of searchPath.split(';').filter(Boolean)) {
43
+ // A WSL bash shim is not Git Bash. Resolve beside a real Git installation.
44
+ const base = directory.replace(/^"|"$/g, '');
45
+ const gitPath = path.win32.join(base, 'git.exe');
46
+ const bashPath = path.win32.resolve(base, '..', 'bin', 'bash.exe');
47
+ if (await fs.stat(gitPath).then(s => s.isFile(), () => false) && await fs.stat(bashPath).then(s => s.isFile(), () => false)) {
48
+ env.CLAUDE_CODE_GIT_BASH_PATH = bashPath;
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ return { ...env, ...credentials, CLAUDE_CONFIG_DIR: config.configDir, CLAUDE_HOME: config.configDir, CONTEXT_GUARD_HEADLESS: '1',
54
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', DISABLE_AUTOUPDATER: '1', PYTHONDONTWRITEBYTECODE: '1' };
55
+ }
56
+
57
+ export function claudeArguments(config, { sessionId, resume }) {
58
+ if (!uuid.test(sessionId)) fail('INVALID_SESSION', 'Claude requires a real Session UUID');
59
+ return [...(config.args || []), '-p', resume ? '--resume' : '--session-id', sessionId,
60
+ '--name', config.name, '--output-format', 'stream-json', '--verbose', '--include-hook-events',
61
+ '--strict-mcp-config', '--model', config.model,
62
+ ...(config.systemPromptFile ? ['--append-system-prompt-file', config.systemPromptFile] : []),
63
+ ...(config.permissionMode === 'bypassPermissions' ? ['--dangerously-skip-permissions'] : []),
64
+ ...(config.tools ? ['--tools', config.tools] : [])];
65
+ }
66
+
67
+ // This is an invocation adapter, not a second task queue. Cloud retains FIFO.
68
+ // A busy native turn rejects the next delivery for the existing inbox to retry.
69
+ export class ClaudeRuntime {
70
+ constructor(directory) { this.directory = directory; }
71
+ sessionFile(sessionId) {
72
+ if (!uuid.test(sessionId)) fail('INVALID_SESSION', 'Claude requires a real Session UUID');
73
+ return path.join(this.directory, sessionId, 'session.json');
74
+ }
75
+ jobFile(sessionId, deliveryId) { return path.join(path.dirname(this.sessionFile(sessionId)), 'deliveries', `${hash(deliveryId)}.json`); }
76
+ async configure(sessionId, config) {
77
+ if (!path.isAbsolute(config.command || '') || !path.isAbsolute(config.root || '') || !path.isAbsolute(config.configDir || '') || !path.isAbsolute(config.environmentFile || '')) fail('INVALID_RUNTIME', 'Claude runtime requires absolute local configuration paths');
78
+ if (!config.name || !config.model || !['executor', 'ci'].includes(config.role)) fail('INVALID_RUNTIME', 'Claude name, model and role are required');
79
+ if (config.role === 'ci' && (!uuid.test(config.executorSessionId || '') || config.executorSessionId === sessionId ||
80
+ !Array.isArray(config.ciCommands) || !config.ciCommands.length || config.ciCommands.some(command => typeof command !== 'string' || !command.trim()))) fail('INVALID_RUNTIME', 'CI requires an explicit developer Session and allowed test commands');
81
+ if (config.args && (!Array.isArray(config.args) || config.args.some(arg => typeof arg !== 'string'))) fail('INVALID_RUNTIME', 'Claude runtime arguments must be strings');
82
+ if (config.permissionMode === 'bypassPermissions' && config.isolated !== true) fail('INVALID_RUNTIME', 'Permission bypass requires an explicitly isolated runtime');
83
+ const file = this.sessionFile(sessionId);
84
+ await withFileLock(file + '.lock', async () => {
85
+ const state = await readJSON(file, {});
86
+ if (state.active) fail('RUNTIME_BUSY', 'Stop the native turn before changing its configuration');
87
+ await atomicWrite(file, encode({ ...state, initialized: state.initialized || config.resumeExisting === true,
88
+ config: { ...config, rootAlias: config.root, root: await fs.realpath(config.root) }, sessionId, updatedAt: new Date().toISOString() }));
89
+ });
90
+ return { configured: true, sessionId, role: config.role };
91
+ }
92
+ async status(sessionId) {
93
+ const state = await readJSON(this.sessionFile(sessionId), null);
94
+ if (!state) return { configured: false };
95
+ const job = state.active ? await readJSON(state.active, null) : null;
96
+ const status = !job ? 'stopped'
97
+ : job.state === 'interrupted' ? 'interrupted'
98
+ : job.state === 'failed' ? 'failed'
99
+ : ['starting', 'dispatching', 'running'].includes(job.state) ? alive(job.workerPid) ? 'active' : 'unknown'
100
+ : 'stopped';
101
+ return { configured: true, name: state.config.name, role: state.config.role,
102
+ status,
103
+ at: job?.updatedAt || state.updatedAt || '', deliveryId: job?.id || null,
104
+ error: job?.error || null };
105
+ }
106
+ async provision(request, { baseRef, start = true }) {
107
+ if (!request || !uuid.test(request.sessionId || '') || !uuid.test(request.templateSessionId || '') ||
108
+ request.sessionId === request.templateSessionId || typeof request.id !== 'string' || !/^[a-f0-9]{64}$/.test(request.id) ||
109
+ typeof request.name !== 'string' || !request.name.trim() || request.name.length > 200) fail('INVALID_CREATION', 'Use the bounded Cloud creation request');
110
+ const directory = path.dirname(this.sessionFile(request.sessionId));
111
+ const receiptFile = path.join(directory, 'creation.json');
112
+ return withFileLock(receiptFile + '.lock', async () => {
113
+ const fingerprint = hash(canonical({ id: request.id, sessionId: request.sessionId, templateSessionId: request.templateSessionId, name: request.name }));
114
+ let receipt = await readJSON(receiptFile, null);
115
+ if (receipt && receipt.fingerprint !== fingerprint) fail('ID_REUSED', 'Native creation identity differs');
116
+ const template = await readJSON(this.sessionFile(request.templateSessionId), null);
117
+ if (template?.config.role !== 'executor' || template.config.allowSessionCreation !== true) fail('CREATION_NOT_ENABLED', 'The local operator must enable this developer template');
118
+ if (!receipt) {
119
+ if (typeof baseRef !== 'string' || !baseRef.startsWith('refs/')) fail('MAIN_REQUIRED', 'Use the registered project Main ref');
120
+ const sha = await git(template.config.root, 'rev-parse', '--verify', `${baseRef}^{commit}`);
121
+ receipt = { fingerprint, templateSessionId: request.templateSessionId, root: path.join(directory, 'worktree'), configDir: path.join(directory, 'profile'), sha, branch: `claude/session-${request.sessionId}` };
122
+ await atomicWrite(receiptFile, encode(receipt));
123
+ }
124
+ const root = receipt.root;
125
+ if (await fs.stat(root).then(() => true, e => { if (e.code === 'ENOENT') return false; throw e; })) {
126
+ if (await fs.realpath(await git(root, 'rev-parse', '--show-toplevel')) !== await fs.realpath(root) ||
127
+ await git(root, 'branch', '--show-current') !== receipt.branch ||
128
+ await fs.realpath(await git(root, 'rev-parse', '--path-format=absolute', '--git-common-dir')) !== await fs.realpath(await git(template.config.root, 'rev-parse', '--path-format=absolute', '--git-common-dir'))) fail('WORKTREE_MISMATCH', 'Preserve the existing creation directory');
129
+ } else {
130
+ await git(template.config.root, 'worktree', 'add', '-b', receipt.branch, root, receipt.sha);
131
+ }
132
+ const existing = await readJSON(this.sessionFile(request.sessionId), null);
133
+ if (!existing) {
134
+ // Copy only the installed Skill and reviewed settings, never transcripts,
135
+ // global configuration, or another Session's memory.
136
+ const source = template.config.configDir, target = receipt.configDir;
137
+ const settings = await readJSON(path.join(source, 'settings.json'), null);
138
+ if (!settings) fail('SETTINGS_REQUIRED', 'Template needs installed Claude settings and hooks');
139
+ const roots = new RegExp([...new Set([source, template.config.root, template.config.rootAlias || template.config.root])].sort((a, b) => b.length - a.length)
140
+ .map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g');
141
+ const relocate = value => {
142
+ if (typeof value === 'string') return value.replace(roots, match => match === source ? target : root);
143
+ if (Array.isArray(value)) return value.map(relocate);
144
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, relocate(child)]));
145
+ return value;
146
+ };
147
+ await fs.mkdir(path.join(target, 'skills'), { recursive: true, mode: 0o700 });
148
+ await fs.cp(path.join(source, 'skills', 'context-guard'), path.join(target, 'skills', 'context-guard'), { recursive: true });
149
+ await atomicWrite(path.join(target, 'settings.json'), encode(relocate(settings)));
150
+ await this.configure(request.sessionId, { ...template.config, root, configDir: target,
151
+ systemPromptFile: template.config.systemPromptFile ? relocate(template.config.systemPromptFile) : undefined,
152
+ name: request.name.trim(), resumeExisting: false, allowSessionCreation: false,
153
+ permissionMode: 'bypassPermissions', isolated: true });
154
+ }
155
+ await attestCreatedSession(root, request.sessionId);
156
+ if (!start) return { sessionId: request.sessionId, root, state: 'prepared' };
157
+ const prior = await readJSON(this.jobFile(request.sessionId, `create:${request.id}`), null);
158
+ if (prior && ['failed', 'interrupted'].includes(prior.state)) fail('NATIVE_START_FAILED', 'The saved native startup did not complete; preserve its evidence');
159
+ const delivery = await this.deliver({ id: `create:${request.id}`, sessionId: request.sessionId, root, platform: 'claude',
160
+ message: '这是人类请求创建的独立开发会话。读取已安装的 Context Guard Skill,让原生启动 Hook 完成会话绑定,然后只回复“会话已准备”。没有分配开发任务,不要修改业务文件、创建 Plan 或声明任务完成。' });
161
+ return { sessionId: request.sessionId, root, state: 'starting', deliveryId: delivery.deliveryId };
162
+ });
163
+ }
164
+ async ciReceiver(executorSessionId) {
165
+ const creation = await readJSON(path.join(path.dirname(this.sessionFile(executorSessionId)), 'creation.json'), null);
166
+ const candidates = [];
167
+ for (const name of await fs.readdir(this.directory).catch(error => { if (error.code === 'ENOENT') return []; throw error; })) {
168
+ if (!uuid.test(name)) continue;
169
+ const state = await readJSON(this.sessionFile(name), null);
170
+ if (state?.config.role === 'ci' && [executorSessionId, creation?.templateSessionId].includes(state.config.executorSessionId)) candidates.push({ sessionId: name, root: state.config.root });
171
+ }
172
+ if (candidates.length !== 1) fail('CI_RECEIVER_REQUIRED', 'Configure exactly one independent CI receiver for this developer Session');
173
+ return candidates[0];
174
+ }
175
+ async acceptsCiExecutor(config, sessionId) {
176
+ if (!uuid.test(sessionId || '')) return false;
177
+ if (sessionId === config.executorSessionId) return true;
178
+ const creation = await readJSON(path.join(path.dirname(this.sessionFile(sessionId)), 'creation.json'), null);
179
+ const executor = await readJSON(this.sessionFile(sessionId), null);
180
+ return creation?.templateSessionId === config.executorSessionId && executor?.config.role === 'executor' &&
181
+ executor.config.root === await fs.realpath(creation.root);
182
+ }
183
+ async ciContext(sessionId, { verifySource = false } = {}) {
184
+ const state = await readJSON(this.sessionFile(sessionId), null);
185
+ if (state?.config.role !== 'ci') return null;
186
+ const job = state.active && await readJSON(state.active, null);
187
+ if (!job?.execution || ['finished', 'failed', 'interrupted'].includes(job.state) || !await this.acceptsCiExecutor(state.config, job.execution.session.id)) fail('CI_NOT_ACTIVE', 'No assigned CI task is active');
188
+ if (verifySource && (await git(state.config.root, 'rev-parse', 'HEAD') !== job.execution.sourceSha || await git(state.config.root, 'status', '--porcelain'))) fail('CI_SOURCE_CHANGED', 'CI result requires the unchanged assigned code SHA');
189
+ return { ...job.execution, mode: 'ci', commands: state.config.ciCommands };
190
+ }
191
+ async recover(sessionId, input, root) {
192
+ if (!input || Object.keys(input).some(key => !['operationId', 'deliveryId', 'message'].includes(key)) ||
193
+ typeof input.operationId !== 'string' || !input.operationId.trim() || input.operationId.length > 128 ||
194
+ typeof input.deliveryId !== 'string' || !input.deliveryId || input.deliveryId.length > 256 ||
195
+ typeof input.message !== 'string' || !input.message.trim() || input.message.length > 4000) fail('INVALID_RECOVERY', 'Recovery requires an operationId, exact interrupted deliveryId and continuation message');
196
+ const state = await readJSON(this.sessionFile(sessionId), null);
197
+ if (!state || await fs.realpath(root) !== state.config.root) fail('WORKTREE_MISMATCH', 'Recover only the configured worktree');
198
+ const previous = await readJSON(this.jobFile(sessionId, input.deliveryId), null);
199
+ if (!previous || previous.state !== 'interrupted') fail('RECOVERY_NOT_AVAILABLE', 'The specified delivery is not interrupted');
200
+ return this.deliver({ id: `recovery:${input.operationId}`, sessionId, root: state.config.root, platform: 'claude',
201
+ recoverDeliveryId: input.deliveryId,
202
+ message: `Context Guard:本地操作者明确要求恢复当前会话。先核对原任务、审核与已有文件/回执,不重复已完成的操作;这不是新任务或新的开发授权。\n${input.message}`,
203
+ ...(previous.execution ? { execution: previous.execution } : {}) });
204
+ }
205
+ guidanceRecovery(input, deliveryId) {
206
+ return { operationId: `guide:${hash(input.id)}`, deliveryId, message: input.message };
207
+ }
208
+ async deliverGuidance(input) {
209
+ if (input.deliveryType === 'task.message') {
210
+ const native = await this.status(input.sessionId);
211
+ if (native.status === 'interrupted' && native.deliveryId) {
212
+ return this.recover(input.sessionId, this.guidanceRecovery(input, native.deliveryId), input.root);
213
+ }
214
+ }
215
+ return this.deliver(input);
216
+ }
217
+ async receivedGuidance(input) {
218
+ if (await this.received(input)) return true;
219
+ if (input.deliveryType !== 'task.message') return false;
220
+ const operationId = this.guidanceRecovery(input, '').operationId;
221
+ const prior = await readJSON(this.jobFile(input.sessionId, `recovery:${operationId}`), null);
222
+ if (!prior?.recoveredDeliveryId) return false;
223
+ await this.recover(input.sessionId, this.guidanceRecovery(input, prior.recoveredDeliveryId), input.root);
224
+ return true;
225
+ }
226
+ async deliver(input) {
227
+ const file = this.sessionFile(input.sessionId), jobFile = this.jobFile(input.sessionId, input.id);
228
+ const fingerprint = hash(canonical(input));
229
+ await withFileLock(file + '.lock', async () => {
230
+ const previous = await readJSON(jobFile, null);
231
+ if (previous) {
232
+ if (previous.fingerprint !== fingerprint) fail('ID_REUSED', 'Claude delivery identity differs');
233
+ if (previous.state === 'starting') {
234
+ const state = await readJSON(file);
235
+ if (state.active && state.active !== jobFile) {
236
+ if (!input.recoverDeliveryId) fail('RUNTIME_BUSY', 'Another native turn owns this Session');
237
+ verifyRecovery(await readJSON(state.active, null), input.recoverDeliveryId);
238
+ }
239
+ await atomicWrite(file, encode({ ...state, active: jobFile }));
240
+ }
241
+ return; // The saved invocation is never automatically executed twice.
242
+ }
243
+ const state = await readJSON(file, null);
244
+ if (!state) fail('RUNTIME_NOT_CONFIGURED', 'Register this Claude runtime before delivery');
245
+ if (await fs.realpath(input.root) !== state.config.root) fail('WORKTREE_MISMATCH', 'Claude delivery targets a different worktree');
246
+ if (state.active) {
247
+ const active = await readJSON(state.active, null);
248
+ if (input.recoverDeliveryId) {
249
+ verifyRecovery(active, input.recoverDeliveryId);
250
+ } else if (!active || !['finished', 'failed'].includes(active.state)) {
251
+ // CI Sessions are reusable workers. An interrupted prior check has no
252
+ // remaining authority and must not permanently block the next exact
253
+ // SHA assignment; keep its immutable job file as evidence and let the
254
+ // new Cloud delivery become active.
255
+ if (!(state.config.role === 'ci' && active?.state === 'interrupted' && input.execution)) {
256
+ fail('RUNTIME_BUSY', 'Claude turn is active or interrupted; retain the Cloud delivery for retry');
257
+ }
258
+ }
259
+ } else if (input.recoverDeliveryId) {
260
+ fail('RECOVERY_NOT_AVAILABLE', 'There is no active interrupted delivery to recover');
261
+ }
262
+ if (state.config.role === 'ci') {
263
+ if (!await this.acceptsCiExecutor(state.config, input.execution?.session?.id) || !/^[a-f0-9]{40}$/.test(input.execution?.sourceSha || '')) fail('CI_ASSIGNMENT_MISMATCH', 'CI requires a verified developer handoff');
264
+ if (await git(state.config.root, 'status', '--porcelain')) fail('CI_SOURCE_CHANGED', 'Preserve dirty CI files before changing the assigned SHA');
265
+ await git(state.config.root, 'checkout', '--detach', input.execution.sourceSha);
266
+ }
267
+ const job = { id: input.id, fingerprint, sessionId: input.sessionId, message: input.message,
268
+ ...(input.execution ? { execution: input.execution } : {}),
269
+ ...(input.recoverDeliveryId ? { recoveredDeliveryId: input.recoverDeliveryId } : {}),
270
+ state: 'starting', resume: Boolean(state.initialized || input.recoverDeliveryId), updatedAt: new Date().toISOString() };
271
+ await atomicWrite(jobFile, encode(job));
272
+ await atomicWrite(file, encode({ ...state, active: jobFile }));
273
+ });
274
+ await this.wake(input.sessionId, jobFile);
275
+ // Persisted intent is already accepted locally, even if the server restarts.
276
+ return { deliveryId: input.id, state: 'received', sessionId: input.sessionId };
277
+ }
278
+ async received(input) {
279
+ const job = await readJSON(this.jobFile(input.sessionId, input.id), null);
280
+ if (!job || job.fingerprint !== hash(canonical(input))) return false;
281
+ await this.deliver(input);
282
+ return true;
283
+ }
284
+ async wake(sessionId, jobFile) {
285
+ const job = await readJSON(jobFile);
286
+ if (job.state !== 'starting') return;
287
+ const worker = spawn(process.execPath, [ownFile, '--worker', this.sessionFile(sessionId), jobFile], {
288
+ detached: true, windowsHide: true, stdio: 'ignore',
289
+ });
290
+ await new Promise((resolve, reject) => { worker.once('spawn', resolve); worker.once('error', reject); });
291
+ worker.unref();
292
+ }
293
+ }
294
+
295
+ async function runWorker(file, jobFile) {
296
+ const session = await readJSON(file), config = session.config;
297
+ const job = await readJSON(jobFile);
298
+ if (job.state !== 'starting') return;
299
+ const update = async fields => {
300
+ Object.assign(job, fields, { updatedAt: new Date().toISOString() });
301
+ await atomicWrite(jobFile, encode(job));
302
+ };
303
+ // Written before spawning Claude. A crash after this point is uncertain, not
304
+ // permission to submit the same prompt again.
305
+ await update({ workerPid: process.pid, state: 'dispatching' });
306
+ let child, stopReason = null;
307
+ try {
308
+ const credentials = await readJSON(config.environmentFile);
309
+ if (Object.keys(credentials).some(key => !providerKeys.has(key)) || Object.values(credentials).some(value => typeof value !== 'string')) fail('INVALID_ENVIRONMENT', 'Only provider settings are accepted in the private environment file');
310
+ const env = await claudeEnvironment(config, credentials);
311
+ child = spawn(config.command, claudeArguments(config, { sessionId: session.sessionId, resume: job.resume }), {
312
+ cwd: config.root, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
313
+ });
314
+ const exit = new Promise((resolve, reject) => { child.once('close', resolve); child.once('error', reject); });
315
+ exit.catch(() => {}); // A spawn failure may precede opening the private log.
316
+ let buffer = '', bytes = 0, result = null, initialized = false;
317
+ const decoder = new StringDecoder('utf8');
318
+ const output = await fs.open(jobFile + '.jsonl', 'a', 0o600);
319
+ let writes = Promise.resolve();
320
+ const idleMs = config.timeoutMs || 600000;
321
+ let idleTimer, killTimer;
322
+ const terminate = (reason = 'CLAUDE_TIMEOUT_OR_INTERRUPTED') => {
323
+ if (stopReason) return;
324
+ stopReason = reason; child.kill('SIGTERM');
325
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
326
+ };
327
+ const active = () => { clearTimeout(idleTimer); idleTimer = setTimeout(terminate, idleMs); };
328
+ active();
329
+ // Activity keeps the idle timer alive, but cannot extend a native turn
330
+ // indefinitely. A stopped turn stays recoverable in the same Session.
331
+ const hardTimer = setTimeout(() => terminate('CLAUDE_TURN_LIMIT'), Math.max(idleMs, config.maxTurnMs || 1800000));
332
+ const interrupted = () => terminate();
333
+ process.once('SIGTERM', interrupted); process.once('SIGINT', interrupted);
334
+ child.stdout.on('data', chunk => {
335
+ bytes += chunk.length;
336
+ if (bytes > (config.outputLimitBytes || 64 * 1024 * 1024)) { terminate('CLAUDE_OUTPUT_LIMIT'); return; }
337
+ active();
338
+ writes = writes.then(() => output.write(chunk));
339
+ buffer += decoder.write(chunk);
340
+ for (;;) {
341
+ const index = buffer.indexOf('\n'); if (index < 0) break;
342
+ const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
343
+ try {
344
+ const event = JSON.parse(line);
345
+ if (event.session_id && event.session_id !== session.sessionId) { terminate('CLAUDE_SESSION_MISMATCH'); break; }
346
+ if (event.type === 'system' && event.subtype === 'init') initialized = true;
347
+ if (event.type === 'result') result = event;
348
+ } catch { /* Native diagnostic text is not a protocol receipt. */ }
349
+ }
350
+ });
351
+ child.stderr.on('data', active); // Activity only; never publish provider stderr or credentials.
352
+ child.stdin.on('error', () => {});
353
+ child.stdin.end(job.message);
354
+ try {
355
+ await update({ state: 'running', childPid: child.pid });
356
+ const code = await exit;
357
+ await writes; await output.sync();
358
+ await update({ state: stopReason || !result ? 'interrupted' : code === 0 && !result.is_error ? 'finished' : 'failed',
359
+ error: stopReason || (!result ? 'CLAUDE_NO_RESULT' : result.is_error ? 'CLAUDE_FAILED' : null) });
360
+ await withFileLock(file + '.lock', async () => {
361
+ const latest = await readJSON(file);
362
+ await atomicWrite(file, encode({ ...latest, initialized: latest.initialized || initialized,
363
+ ...(['finished', 'failed'].includes(job.state) ? { active: null } : {}), updatedAt: job.updatedAt }));
364
+ });
365
+ } finally { clearTimeout(idleTimer); clearTimeout(hardTimer); clearTimeout(killTimer); await output.close(); }
366
+ } catch (error) { await update({ state: 'interrupted', error: String(error.code || 'CLAUDE_LAUNCH_FAILED').slice(0, 100) }); }
367
+ }
368
+
369
+ if (process.argv[1] && path.resolve(process.argv[1]) === ownFile && process.argv[2] === '--worker') {
370
+ const [file, jobFile] = process.argv.slice(3);
371
+ withFileLock(jobFile + '.worker.lock', () => runWorker(file, jobFile)).catch(() => { process.exitCode = 1; });
372
+ }