@awak-app/simy-cli 0.6.22 → 0.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +71 -0
  2. package/package.json +3 -3
  3. package/src/backend-executable.js +75 -7
  4. package/src/cli-contract.js +9 -1
  5. package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +5 -0
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +533 -74
  7. package/src/codex-plugins/simy/hooks/dev-work.mjs +23 -1
  8. package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +63 -0
  9. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +154 -0
  10. package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +34 -0
  11. package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +65 -0
  12. package/src/codex-plugins/simy/hooks/purpose-search.mjs +123 -0
  13. package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +97 -0
  14. package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +19 -5
  15. package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +19 -4
  16. package/src/delivery-hooks/command.js +48 -0
  17. package/src/delivery-hooks/consistency-monitor.js +126 -0
  18. package/src/delivery-hooks/desktop-runtime.js +70 -0
  19. package/src/delivery-hooks/desktop.js +13 -3
  20. package/src/delivery-hooks/legacy-hook-routing.js +61 -0
  21. package/src/delivery-hooks/local-runtime.js +123 -0
  22. package/src/delivery-hooks/local-supervisor.js +25 -0
  23. package/src/delivery-hooks/runtime-watchdog.js +101 -0
  24. package/src/delivery-hooks/supervisor.js +90 -22
  25. package/src/desktop-host.js +45 -4
  26. package/src/desktop-status.js +4 -2
  27. package/src/dev-work-events.js +32 -3
  28. package/src/durable-local-task-worker.js +57 -12
  29. package/src/fp-narrative-image.js +180 -0
  30. package/src/index.js +1 -1
  31. package/src/local-task.js +4 -0
  32. package/src/orchestrator/audit.js +1 -1
  33. package/src/orchestrator/charter-repair.js +93 -0
  34. package/src/orchestrator/charter-validation.js +14 -6
  35. package/src/orchestrator/checklist-continuation.js +1 -1
  36. package/src/orchestrator/contract.js +1 -3
  37. package/src/orchestrator/delivery/WORKFLOW.md +33 -60
  38. package/src/orchestrator/delivery/controls/harness-controls.json +1326 -0
  39. package/src/orchestrator/delivery/references/delivery-levels.md +17 -44
  40. package/src/orchestrator/delivery/references/pr-closure.md +2 -3
  41. package/src/orchestrator/delivery/references/state-and-receipts.md +14 -5
  42. package/src/orchestrator/delivery/references/ux-specification.md +1 -2
  43. package/src/orchestrator/delivery-level.js +4 -5
  44. package/src/orchestrator/independent-audit.js +9 -5
  45. package/src/orchestrator/instruction.js +1 -0
  46. package/src/orchestrator/loop.js +55 -18
  47. package/src/orchestrator/risk.js +1 -1
  48. package/src/orchestrator/ux-specification.js +2 -2
  49. package/src/runner.js +22 -1
  50. package/src/session-supervision/analysis-manager.js +2 -2
  51. package/src/session-supervision/automatic-continuation.js +6 -5
  52. package/src/session-supervision/autonomous-upgrade.js +55 -0
  53. package/src/session-supervision/codex-app-server.js +200 -24
  54. package/src/session-supervision/consistency-auditor.js +113 -0
  55. package/src/session-supervision/index.js +38 -11
  56. package/src/session-supervision/purpose-candidate.js +44 -0
  57. package/src/session-supervision/purpose-instruction.js +47 -0
  58. package/src/session-supervision/purpose-observer.js +172 -0
  59. package/src/session-supervision/purpose-recovery.js +264 -0
  60. package/src/session-supervision/purpose-verification.js +154 -0
  61. package/src/sqm/agent-skill.js +6 -3
  62. package/src/sqm/artifact-paths.js +39 -0
  63. package/src/sqm/claude-plugin.js +7 -0
  64. package/src/sqm/command.js +95 -27
  65. package/src/sqm/history-store.js +3 -3
  66. package/src/sqm/repository.js +19 -5
  67. package/src/sqm/workflow-update.js +14 -0
  68. package/src/workflow-reporting.js +2 -0
@@ -0,0 +1,25 @@
1
+ import { dirname } from 'node:path';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
4
+ import { createManagedSupervisor } from './supervisor.js';
5
+
6
+ export async function superviseLocalDelivery({ root, once = false, signal,
7
+ createSupervisor = createManagedSupervisor, checkOwner = assertNoDesktopHostForOrigin, wait = delay } = {}) {
8
+ // Delivery records share this local root across origins. A staging desktop
9
+ // can own the same provider session, so fence every live owner in this root.
10
+ const check = () => checkOwner(dirname(root), null);
11
+ await check();
12
+ const supervisor = createSupervisor({ root });
13
+ const stop = () => { void supervisor.close(); };
14
+ signal?.addEventListener('abort', stop, { once: true });
15
+ try {
16
+ do {
17
+ if (signal?.aborted) break;
18
+ await check();
19
+ await supervisor.tick();
20
+ if (once) break;
21
+ try { await wait(3_000, undefined, { signal }); }
22
+ catch (error) { if (!signal?.aborted) throw error; }
23
+ } while (!signal?.aborted);
24
+ } finally { signal?.removeEventListener('abort', stop); await supervisor.close(); }
25
+ }
@@ -0,0 +1,101 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
9
+ import { pinnedWorkflowSource, localRuntimeStatus } from './local-runtime.js';
10
+ import { desktopRuntimeStatus } from './desktop-runtime.js';
11
+ import { consistencyMonitorStatus } from './consistency-monitor.js';
12
+ const exec = promisify(execFile);
13
+ async function read(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }
14
+ async function save(file, value) {
15
+ const temp = `${file}.${randomUUID()}.tmp`;
16
+ try { await writeFile(temp, JSON.stringify(value), { mode: 0o600 }); await rename(temp, file); }
17
+ finally { const { rm } = await import('node:fs/promises'); await rm(temp, { force: true }); }
18
+ }
19
+ async function candidate(root) {
20
+ await pinnedWorkflowSource({ root });
21
+ const pin = await read(join(root, 'delivery-runtime/current.json'));
22
+ const value = await read(join(root, 'desktop-runtime/candidate.json'));
23
+ if (!pin || value?.digest !== pin.digest || value?.version !== pin.version || value.app !== join(root, 'desktop-runtime', `SIMY Local-${pin.digest}.app`)) throw Error('Local desktop candidate identity mismatch.');
24
+ const actual = await localRuntimeStatus({ root, source: join(value.app, 'Contents/Resources/cli') });
25
+ if (actual.sourceDigest !== pin.digest || actual.version !== pin.version) throw Error('Local desktop candidate integrity mismatch.');
26
+ return value;
27
+ }
28
+
29
+ export async function suspendDesktopWatchdog(root) {
30
+ const file = join(root, 'desktop-runtime/active.json'), active = await read(file);
31
+ if (active) await save(file, { ...active, suspendedBy: 'user_quit', suspendedAt: Date.now() });
32
+ }
33
+
34
+ export async function activateLocalDesktop({ root, checkOwner = assertNoDesktopHostForOrigin, run = exec } = {}) {
35
+ const value = await candidate(root);
36
+ await checkOwner(root, null); // A live owner is never killed by activation.
37
+ await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', value.app]);
38
+ await run('/usr/bin/open', ['-n', '-a', value.app]);
39
+ await save(join(root, 'desktop-runtime/active.json'), { ...value, activatedAt: Date.now(), scope: 'previously_opted_in_tasks' });
40
+ return { ...value, activation: 'launch_requested' };
41
+ }
42
+
43
+ // External deterministic observer survives host exits and sleep/wake. Only an
44
+ // explicitly activated candidate may be relaunched, with the normal owner lock.
45
+ // A live mismatched/unhealthy host is reported, never forcibly replaced.
46
+ export async function tickRuntimeWatchdog({ root, now = Date.now(), run = exec,
47
+ checkOwner = assertNoDesktopHostForOrigin, observeHost = desktopRuntimeStatus,
48
+ observeMonitor = consistencyMonitorStatus } = {}) {
49
+ await mkdir(join(root, 'desktop-runtime'), { recursive: true, mode: 0o700 });
50
+ const file = join(root, 'desktop-runtime/watchdog.json');
51
+ const previous = await read(file) || {};
52
+ const state = { lastAttemptAt: now, lastSuccessAt: previous.lastSuccessAt || null, ownerPid: process.pid, launches: previous.launches || 0, lastLaunchAt: previous.lastLaunchAt || null,
53
+ host: null, monitor: null, error: null, action: 'observe' };
54
+ try {
55
+ state.host = await observeHost({ root });
56
+ state.monitor = await observeMonitor(join(root, 'delivery-hooks'), now);
57
+ const active = await read(join(root, 'desktop-runtime/active.json'));
58
+ if (active?.suspendedBy === 'user_quit') state.action = 'user_quit_preserved';
59
+ if (active && !active.suspendedBy && state.host.state !== 'matched' && now - (previous.lastLaunchAt || 0) >= 60_000) {
60
+ try { await checkOwner(root, null); }
61
+ catch (error) { if (error.code !== 'process_conflict') throw error; state.action = 'live_owner_preserved'; }
62
+ if (state.action !== 'live_owner_preserved') {
63
+ const value = await candidate(root);
64
+ // Installing another candidate does not authorize its automatic activation.
65
+ if (value.digest !== active.digest) throw Error('Active desktop pin changed; activate the prepared candidate explicitly.');
66
+ await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', value.app]);
67
+ await run('/usr/bin/open', ['-n', '-a', value.app]);
68
+ state.action = 'relaunch_requested'; state.lastLaunchAt = now; state.launches++;
69
+ }
70
+ }
71
+ state.lastSuccessAt = now;
72
+ } catch { state.error = { code: 'runtime_watchdog_failed', at: now }; }
73
+ await save(file, state);
74
+ return state;
75
+ }
76
+ export async function watchLocalRuntime({ root, signal, once = false } = {}) {
77
+ do {
78
+ if (signal?.aborted) break;
79
+ await tickRuntimeWatchdog({ root });
80
+ if (once) break;
81
+ try { await delay(30_000, undefined, { signal }); } catch (error) { if (!signal?.aborted) throw error; }
82
+ } while (!signal?.aborted);
83
+ }
84
+
85
+ export async function installRuntimeWatchdog({ root, home = homedir(), run = exec, platform = process.platform } = {}) {
86
+ if (platform !== 'darwin') throw Error('Desktop watchdog installation currently requires macOS.');
87
+ const active = await read(join(root, 'desktop-runtime/active.json'));
88
+ const value = await candidate(root);
89
+ if (!active || active.digest !== value.digest) throw Error('Activate the verified desktop before installing its watchdog.');
90
+ const folder = join(home, 'Library/LaunchAgents'); await mkdir(folder, { recursive: true });
91
+ const label = 'com.awak.simy.local-runtime-watchdog';
92
+ const plist = join(folder, `${label}.plist`);
93
+ const xml = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
94
+ const content = `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array><string>${xml(join(root, 'bin/simy-delivery'))}</string><string>runtime</string><string>watch</string></array><key>EnvironmentVariables</key><dict><key>SIMY_HOME</key><string>${xml(root)}</string></dict><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer></dict></plist>`;
95
+ await writeFile(plist, content, { mode: 0o600 });
96
+ await run('/usr/bin/plutil', ['-lint', plist]);
97
+ const domain = `gui/${process.getuid()}`;
98
+ try { await run('/bin/launchctl', ['bootout', `${domain}/${label}`]); } catch { /* First install has no job. */ }
99
+ await run('/bin/launchctl', ['bootstrap', domain, plist]);
100
+ return { label, plist, scope: 'activated_local_desktop_only' };
101
+ }
@@ -1,16 +1,23 @@
1
+ import { retainControllerEvidence } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
2
+ import { workspaceIdentity, reviewWorkspaceIdentity } from '../session-supervision/purpose-verification.js';
3
+ import { searchEnabled, failureLimitReached } from '../codex-plugins/simy/hooks/purpose-search.mjs';
4
+ import { reconcilePurposeTasks } from '../session-supervision/purpose-recovery.js';
5
+ import { runConsistencyMonitor } from './consistency-monitor.js';
6
+ import { desktopRuntimeStatus } from './desktop-runtime.js';
7
+ import { auditConsistencyTask } from '../session-supervision/consistency-auditor.js';
1
8
  import { minimalSufficientChangeInstruction } from '../codex-plugins/simy/hooks/minimal-sufficient.mjs';
2
9
  import { requireProviderLink } from '../provider-links.js';
3
10
  import { spawn } from 'node:child_process';
4
11
  import { randomUUID } from 'node:crypto';
5
12
  import { dirname } from 'node:path';
6
- import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
13
+ import { canExecuteAutonomousExternal, automaticContinuationAllowed, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
7
14
  import { resolveBackendExecutable } from '../backend-executable.js';
8
15
  import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
9
16
  import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
10
17
 
11
18
  const MAX_OUTPUT = 64 * 1024;
12
- const SAFE_PROVIDER_CODES = new Set(['identity', 'permission_narrowed', 'permission_restore', 'turn_failed', 'checkpoint',
13
- 'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval']);
19
+ const SAFE_PROVIDER_CODES = new Set(['authentication_required', 'identity', 'permission_narrowed', 'permission_restore', 'turn_failed', 'checkpoint',
20
+ 'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval', 'provider_question', 'usage_unavailable']);
14
21
  function safeProviderCode(code) { return SAFE_PROVIDER_CODES.has(code) ? code : 'provider_error'; }
15
22
  function providerFailureReason(code) {
16
23
  if (code === 'permission_restore') return 'Codex sandbox restoration could not be confirmed; inspect the original conversation before resuming.';
@@ -133,18 +140,21 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
133
140
  });
134
141
  }
135
142
 
136
- export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords } = {}) {
143
+ export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, observeTurn, observeAuthentication } = {}) {
137
144
  const tickWallStart = Date.now();
138
145
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
139
146
  if (signal?.aborted) return;
140
- const records = await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}),
141
- ...(readRecords ? { records: await readRecords() } : {}) });
147
+ const readCandidates = readRecords || (() => listRecords(root));
148
+ await reconcilePurposeTasks({ root, signal, now, observeTurn, observeAuthentication, readRecords: readCandidates });
149
+ await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}), records: await readCandidates() });
142
150
  if (signal?.aborted) return;
151
+ const records = await readCandidates();
143
152
  for (let record of records) {
144
153
  if (signal?.aborted) break;
145
154
  const claimNow = currentTime();
146
155
  let run = record.run;
147
156
  if (!run?.managed) continue;
157
+ if (run.managed.maxTokens && run.managed.outcomeAudit?.lease) continue;
148
158
  if (run.state === 'auto_resuming') {
149
159
  const owner = run.managed.resumeLease?.owner;
150
160
  const ownerDead = await leaseOwnerDead(owner, inspectProcess);
@@ -152,9 +162,10 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
152
162
  if (!ownerDead && (owner || claimNow - run.managed.resumeLease?.claimedAt <= 5 * 60_000)) continue;
153
163
  await updateRecord(root, record.provider, record.session, current => {
154
164
  if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
155
- current.run.state = 'recovery_required'; current.run.managed.autoRecovery = false;
165
+ current.run.state = 'recovery_required'; current.run.recoveryCause = 'interrupted_owner'; current.run.managed.autoRecovery = false;
156
166
  current.run.reason = 'Managed recovery was interrupted; inspect the original session before manual resume.';
157
167
  const attempt = current.run.managed.executionAttempt;
168
+ if (attempt?.budgetReservation && attempt.phase === 'dispatching') { attempt.budgetReservation.state = 'unknown'; current.run.managed.unknownConsumption = true; if (current.run.managed.maxTokens) current.run.managed.usageBlocked = true; }
158
169
  if (attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') current.run.managed.executionUncertain = {
159
170
  attemptId: attempt.id, decisionId: attempt.decisionId, action: attempt.action, at: claimNow };
160
171
  current.run.updatedAt = claimNow; notifyState(current.run, claimNow); return current;
@@ -165,7 +176,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
165
176
  try { await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
166
177
  const appServer = run.managed.resumeChannel === 'codex_app_server';
167
178
  const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
168
- const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
179
+ const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test', 'external'].includes(run.managed.decision.action?.kind) &&
169
180
  run.managed.approvedAction?.id === run.managed.decision.id && !run.managed.approvedAction.dispatchedAt;
170
181
  if (run.managed.executionUncertain && run.managed.recoveryDiagnosisDecisionId !== run.managed.decision?.id) continue;
171
182
  if (['pending', 'transferred', 'pending_received'].includes(run.managed.handoff?.status)) continue;
@@ -196,17 +207,22 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
196
207
  if (!automaticContinuationAllowed(current)) return current;
197
208
  const appChannel = live?.managed?.resumeChannel === 'codex_app_server';
198
209
  const allowedCause = live?.recoveryCause === 'process_exit' || appChannel && ['session_supervision', 'approved_action'].includes(live?.recoveryCause);
199
- const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
210
+ const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test', 'external'].includes(live.managed.decision.action?.kind) &&
200
211
  live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
201
212
  if (!live?.managed?.autoRecovery || live.state !== 'recovery_required' || !allowedCause || live.managed.pendingApproval || live.managed.autonomyPolicy && (!live.managed.decision || live.managed.decision.outcome === 'wait' && !approved) || appChannel && live.managed.nextAttemptAt > currentTime()) return current;
202
213
  const stop = deliveryStop(live, currentTime());
203
214
  if (stop) {
204
- live.state = stop.code === 'failure_limit' ? 'failed' : 'paused'; live.reason = stop.reason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
215
+ live.state = stop.code === 'purpose_budget' ? 'budget_exhausted' : stop.code === 'failure_limit' ? 'failed' : 'paused'; live.reason = stop.reason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
205
216
  }
206
217
  live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
207
218
  owner: { pid: process.pid, identity: ownerIdentity } };
208
219
  live.managed.executionAttempt = { id: leaseId, decisionId: live.managed.decision?.id || null,
209
- action: live.managed.decision?.action || live.managed.proposedAction, phase: 'claimed', claimedAt: currentTime(), lastActivityAt: currentTime() };
220
+ action: live.managed.decision?.action || live.managed.proposedAction,
221
+ budgetReservation: { rootTaskId: live.managed.taskId, tokens: live.managed.maxTokens ? Math.max(0, live.managed.maxTokens - (live.managed.tokensUsed || 0)) : null, state: 'reserved', epoch: leaseId }, phase: 'claimed', claimedAt: currentTime(), lastActivityAt: currentTime() };
222
+ for (const request of live.managed.consistencyOutbox || []) if (['pending', 'received', 'inspected', 'inspection_failed'].includes(request.stage) && request.targetTask === live.managed.taskId) {
223
+ request.stage = 'received'; request.receivedAt = currentTime(); request.decisionId = live.managed.decision?.id;
224
+ request.leaseId = leaseId;
225
+ }
210
226
  live.managed.autoAttempts = (live.managed.autoAttempts || 0) + 1;
211
227
  live.updatedAt = currentTime(); return current;
212
228
  }); } catch (error) {
@@ -220,6 +236,15 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
220
236
  let result;
221
237
  let resource;
222
238
  let lastActivityAt = currentTime();
239
+ let usage = null, usageQueue = Promise.resolve(), usageError = null;
240
+ // Tool and token notifications can arrive together. Share one in-process
241
+ // write queue so our own receipts do not contend for the record lock.
242
+ let receiptQueue = Promise.resolve();
243
+ const persistReceipt = mutate => {
244
+ const pending = receiptQueue.then(() => updateRecord(root, record.provider, record.session, mutate));
245
+ receiptQueue = pending.catch(() => {});
246
+ return pending;
247
+ };
223
248
  let dispatched = false, waitingForTool = false;
224
249
  const controller = new AbortController();
225
250
  const abort = () => controller.abort();
@@ -263,6 +288,14 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
263
288
  await authorizeClaim();
264
289
  const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
265
290
  result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime, leaseMonitored: true,
291
+ onEvidence: async evidence => {
292
+ await persistReceipt(current => {
293
+ if (current?.run?.managed?.resumeLease?.id !== leaseId || current.run.managed.decision?.id !== evidence.actionId) throw new Error('Evidence capture lease changed.');
294
+ if (searchEnabled(current.run.managed)) retainControllerEvidence(current.run.managed, evidence, currentTime());
295
+ return current;
296
+ });
297
+ },
298
+ onUsage: observed => { usage = observed; usageQueue = persistReceipt(current => { if (current?.run?.managed?.resumeLease?.id === leaseId) current.run.managed.executionAttempt.observedUsage = observed; return current; }).catch(error=>{usageError=error;}); if (claimed.run.managed.maxTokens && (claimed.run.managed.tokensUsed || 0) + observed.tokens >= claimed.run.managed.maxTokens) controller.abort(); },
266
299
  onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
267
300
  beforeDispatch: async providerSandbox => {
268
301
  await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
@@ -272,9 +305,13 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
272
305
  if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
273
306
  if (appServer && !live.managed.providerSandbox) {
274
307
  if (!providerSandbox || !['readOnly', 'workspaceWrite', 'dangerFullAccess', 'externalSandbox'].includes(providerSandbox.type)) throw new Error('Original Codex sandbox policy is unavailable.');
275
- live.managed.providerSandbox = { type: providerSandbox.type,
308
+ live.managed.providerSandbox = { type: providerSandbox.type, networkAccess: providerSandbox.networkAccess === true,
276
309
  ...(providerSandbox.type === 'workspaceWrite' ? { writableRoots: Array.isArray(providerSandbox.writableRoots) ? providerSandbox.writableRoots.filter(value => typeof value === 'string').slice(0, 20) : [] } : {}) };
277
310
  }
311
+ const selected=live.managed.plan?.alternatives?.find(row=>row.id===live.managed.decision?.selectedOptionId);
312
+ const ledger=live.managed.purpose?.evidence;
313
+ if(selected?.dependsOn?.some(id=>!ledger?.units?.some(unit=>unit.id===id && unit.status==='verified' && unit.purposeRevision===ledger.purposeRevision))) throw new Error('Required dependency is not independently verified.');
314
+ if (live.managed.decision?.autonomousExternal && !canExecuteAutonomousExternal(live.managed)) throw new Error('Autonomous external authority changed before dispatch.');
278
315
  const approved = live.managed.approvedAction;
279
316
  live.managed.executionAttempt.phase = 'dispatching';
280
317
  live.managed.executionAttempt.dispatchedAt = currentTime();
@@ -295,13 +332,17 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
295
332
  : error?.code === 'desktop_access_required'
296
333
  ? { code: -3, busy: true, accessDenied: true }
297
334
  : { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
298
- errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
299
- finally { stopWatch(); signal?.removeEventListener('abort', abort); }
335
+ providerTurnId: error?.providerTurnId, question: error?.question, errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
336
+ try {
337
+ await usageQueue;
338
+ if (usageError) result = { ...result, code: -1, errorCode: 'usage_unavailable' };
339
+ usage ||= result?.usage || null;
300
340
  if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
301
341
  if (result.code === 0) {
302
342
  try {
303
- await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
304
- checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, now: Date.now() });
343
+ if (result.verification) await settlePurposeVerification({ root, provider: record.provider, session: record.session, expectedLease: leaseId, review: result.verification, verifySnapshot: current => reviewWorkspaceIdentity(current, root, result.verification, { signal: controller.signal }), now: Date.now() });
344
+ else await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
345
+ checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, toolEvidence: result.toolEvidence || [], sourceContext: result.sourceContext, controllerEvidence: result.controllerEvidence, now: Date.now() });
305
346
  await updateRecord(root, record.provider, record.session, current => {
306
347
  if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
307
348
  current.run.managed.executionAttempt.phase = 'checkpointed';
@@ -318,16 +359,27 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
318
359
  }
319
360
  return current;
320
361
  });
321
- } catch { result = { code: -1, errorCode: 'checkpoint_rejected' }; }
362
+ } catch (error) { result = { ...result, code: -1, errorCode: 'checkpoint_rejected' }; }
322
363
  }
323
364
  await updateRecord(root, record.provider, record.session, async current => {
324
365
  const live = current?.run;
325
366
  if (live?.managed?.resumeLease?.id !== leaseId) return current;
326
367
  delete live.managed.resumeLease;
368
+ if (searchEnabled(live.managed) && !result.busy && (dispatched || result.code === 0)) live.continuations += 1;
369
+ if (live.managed.executionAttempt?.budgetReservation) live.managed.executionAttempt.budgetReservation.state = usage && result.code===0 ? 'settled' : dispatched ? 'unknown' : 'released';
370
+ if (dispatched && (!usage || result.code!==0)) {live.managed.unknownConsumption = true;if(live.managed.maxTokens) live.managed.usageBlocked=true;}
371
+ if (usage) {
372
+ live.managed.tokensUsed = (live.managed.tokensUsed || 0) + usage.tokens;
373
+ live.managed.usageAvailable = true;
374
+ live.managed.lastUsage = { ...usage, attemptId: leaseId };
375
+ } else if (!result.busy && live.managed.maxTokens && dispatched) {
376
+ live.managed.usageAvailable = false; live.managed.usageBlocked = true;
377
+ }
327
378
  const attempt = live.managed.executionAttempt;
328
379
  if (!result.busy && attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') live.managed.executionUncertain = {
329
380
  attemptId: leaseId, decisionId: attempt.decisionId, action: attempt.action, at: Date.now() };
330
381
  live.managed.attempts ||= [];
382
+ if (live.managed.purpose?.enabled && result.providerTurnId) live.managed.purpose.lastOwnedTurnId = result.providerTurnId;
331
383
  live.managed.attempts.push({ id: leaseId, startedAt: claimed.run.managed.resumeLease.claimedAt, endedAt: Date.now(),
332
384
  checkpointRevision: live.revision, outcome: result.busy ? 'busy' : result.cancelled ? 'cancelled' : result.code === 0 ? 'checkpoint' : 'failed',
333
385
  errorCode: result.code === 0 || result.busy || result.cancelled ? null : safeProviderCode(result.errorCode),
@@ -354,15 +406,24 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
354
406
  live.state = 'recovery_required'; live.recoveryCause = 'session_supervision';
355
407
  live.reason = result.accessDenied ? 'Sign in to a SIMY organization with SQM and Developer access before continuing.' : 'Original Codex conversation is in use; SIMY will retry after the writer lock becomes available.';
356
408
  live.managed.nextAttemptAt = Date.now() + 15_000;
409
+ } else if (result.errorCode === 'authentication_required') {
410
+ live.state = 'blocked'; live.blocker = 'external_dependency'; live.blockerSource = 'connection'; live.managed.autoRecovery = false;
411
+ live.managed.authenticationWait = { since: Date.now(), nextCheckAt: Date.now() + 60_000 };
412
+ live.reason = 'The configured provider is not logged in. SIMY will recheck account availability and resume diagnosis when restored within the existing budget.';
413
+ } else if (result.errorCode === 'provider_question') {
414
+ live.state = 'blocked'; live.blocker = 'missing_information'; live.blockerSource = 'question'; live.managed.autoRecovery = false;
415
+ live.managed.userQuestion = result.question || 'Inspect the original conversation for the unanswered provider question.';
416
+ live.reason = 'Investigate the provider question; use existing evidence for routine decisions and preserve genuine user choices.';
357
417
  } else if (result.errorCode === 'provider_approval') {
358
- live.state = 'blocked'; live.blocker = 'authorization'; live.managed.autoRecovery = false;
418
+ live.state = 'blocked'; live.blocker = 'authorization'; live.blockerSource = 'provider'; live.managed.autoRecovery = false;
419
+ live.managed.nativeWait = { turnId: result.providerTurnId || null, observedAt: Date.now() };
359
420
  live.reason = 'Codex requires provider approval or user input in the original conversation.';
360
421
  } else if (result.cancelled) {
361
422
  live.managed.autoRecovery = false; live.state = 'recovery_required'; live.recoveryCause = 'shutdown_cancelled';
362
423
  live.reason = 'Managed recovery was cancelled; inspect the original session before manual resume.';
363
424
  } else if (result.code !== 0) {
364
425
  live.failures += 1; live.managed.autoRecovery = false;
365
- live.state = live.failures >= live.maxFailures ? 'failed' : 'recovery_required';
426
+ live.state = failureLimitReached(live) ? 'failed' : 'recovery_required';
366
427
  live.recoveryCause = 'provider_error';
367
428
  live.reason = appServer ? providerFailureReason(safeProviderCode(result.errorCode)) +
368
429
  (result.restorationUnverified ? ' Original provider permissions also require inspection.' : '') :
@@ -370,7 +431,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
370
431
  } else if (live.revision <= run.revision) {
371
432
  live.failures += 1;
372
433
  live.reason = result.code === 0 ? 'Codex recovery returned without a checkpoint.' : 'Codex recovery failed; inspect the original session.';
373
- live.state = live.failures >= live.maxFailures ? 'failed' : 'recovery_required';
434
+ live.state = failureLimitReached(live) ? 'failed' : 'recovery_required';
374
435
  live.recoveryCause = 'process_exit';
375
436
  } else {
376
437
  if (live.checkpoint?.status === 'completed') {
@@ -393,18 +454,25 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
393
454
  // Settling uncertainty must precede unlocking: otherwise another session
394
455
  // could start a write in the effect/checkpoint persistence gap.
395
456
  await resource?.release({ unconfirmed: result.restorationUnverified === true });
457
+ } finally { stopWatch(); signal?.removeEventListener('abort', abort); }
396
458
  }
397
459
  }
398
460
 
399
461
  export function createManagedSupervisor(options) {
400
- let running = null, closed = false;
462
+ let running = null, auditing = null, closed = false;
401
463
  const controller = new AbortController();
402
464
  return {
403
465
  tick() {
404
466
  if (closed) return Promise.resolve();
467
+ if (!auditing) auditing = (async () => {
468
+ const runtime = await desktopRuntimeStatus({ root: dirname(options.root) }).catch(() => ({ state: 'runtime_observation_failed' }));
469
+ await runConsistencyMonitor({ root: options.root, runtime, readRecords: options.readRecords || (() => listRecords(options.root)),
470
+ onAudit: (record, findings, request) => auditConsistencyTask(record, findings, request, {
471
+ root: options.root, signal: controller.signal, authorize: options.authorizeDispatch || (async () => {}) }) });
472
+ })().catch(() => {}).finally(() => { auditing = null; });
405
473
  if (!running) running = tickManagedTasks({ ...options, signal: controller.signal }).finally(() => { running = null; });
406
474
  return running;
407
475
  },
408
- async close() { closed = true; controller.abort(); await running; },
476
+ async close() { closed = true; controller.abort(); await Promise.allSettled([running, auditing]); },
409
477
  };
410
478
  }
@@ -23,12 +23,16 @@ import { spawn, execFile } from "node:child_process";
23
23
  import { promisify, isDeepStrictEqual } from "node:util";
24
24
  import { createInterface } from "node:readline";
25
25
  import { fileURLToPath } from "node:url";
26
+ import { pathToFileURL } from "node:url";
26
27
  import { realpathSync } from "node:fs";
27
28
  import { dirname, join } from "node:path";
28
29
  import { homedir } from "node:os";
29
30
  import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
30
31
  import { startAgent } from "./agent.js";
31
32
  import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
33
+ import { suspendDesktopWatchdog } from './delivery-hooks/runtime-watchdog.js';
34
+ import { attestDesktopRuntime } from './delivery-hooks/desktop-runtime.js';
35
+ import { pinnedWorkflowSource } from './delivery-hooks/local-runtime.js';
32
36
  import { applyLoginShellPath } from "./login-shell-path.js";
33
37
  import { createCliAutoUpdater, readCliPackageInfo } from "./auto-update.js";
34
38
  import { createAgentShutdown } from "./shutdown.js";
@@ -67,6 +71,11 @@ function desktopActionError(action, error) {
67
71
  if (error?.code === "provider_link_changed") return "SIMY connection changed. Refresh and select the conversation again.";
68
72
  if (error?.code === "provider_link_disconnected") return "Reconnect this provider to SIMY before using it.";
69
73
  if (["provider_connect", "provider_disconnect"].includes(action)) {
74
+ if (error?.code === 'desktop_stopping') {
75
+ if (error.stoppingReason === 'update') return "SIMY is preparing an update. Finish or retry the update; if it failed, quit SIMY completely, reopen it, then reconnect.";
76
+ if (error.stoppingReason === 'organization_change') return "SIMY is switching organizations. Complete or cancel the organization selection, then reconnect.";
77
+ return "SIMY is pausing new work. Quit SIMY completely, reopen it, then reconnect.";
78
+ }
70
79
  if (error?.code === 'desktop_access_required') return "Sign in to your SIMY workspace, then try connecting again.";
71
80
  if (['EACCES', 'EPERM', 'EROFS'].includes(error?.code)) return "SIMY could not save the connection settings. Check access to its local settings folder, then retry.";
72
81
  if (error?.code === 'ENOSPC') return "SIMY could not save the connection settings. Free disk space, then retry.";
@@ -134,6 +143,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
134
143
  throw new Error("Desktop update supervisor is no longer running.");
135
144
  }
136
145
  const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
146
+ if (lock && !dependencies.startAgent) {
147
+ try { await attestDesktopRuntime({ root }); } catch (error) { await lock.release(); throw error; }
148
+ }
137
149
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
138
150
  const deliveryRoot = join(root, "delivery-hooks");
139
151
  const recordReader = createRecordReader(deliveryRoot);
@@ -301,11 +313,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
301
313
  let organizationAnalysesSuspended = false;
302
314
  let organizationAnalysisDrain = Promise.resolve();
303
315
  let organizationAnalysisEpoch = 0;
316
+ const requireWorkAdmission = () => {
317
+ const stoppingReason = closed ? 'shutdown'
318
+ : stoppingForUpdate || updater?.acceptsNewWork?.() === false ? 'update'
319
+ : organizationAnalysesSuspended ? 'organization_change' : null;
320
+ if (stoppingReason) throw Object.assign(new Error("SIMY is stopping. Reopen SIMY before starting new work."), { code: "desktop_stopping", stoppingReason });
321
+ };
304
322
  const authorizeWork = async expectedScope => {
305
- if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw Object.assign(new Error("SIMY is stopping. Reopen SIMY before starting new work."), { code: "desktop_stopping" });
323
+ requireWorkAdmission();
306
324
  if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
307
325
  const access = await accessSnapshot();
308
- if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw Object.assign(new Error("SIMY is stopping. Reopen SIMY before starting new work."), { code: "desktop_stopping" });
326
+ requireWorkAdmission();
309
327
  requireDesktopAccess(access);
310
328
  const scope = accessScope(access);
311
329
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
@@ -959,6 +977,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
959
977
  const status = await agent.getDesktopStatus();
960
978
  if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0) throw new Error("Tasks are still running. Confirm before quitting.");
961
979
  }
980
+ if (command.forUpdate !== true) await suspendDesktopWatchdog(root);
962
981
  // Cancel first, then acknowledge. The native app waits for the host
963
982
  // exit; this deadline also covers a stuck remote flush/discovery.
964
983
  const deadline = setTimeout(async () => {
@@ -1327,7 +1346,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
1327
1346
  const provider = PROVIDER_LINKS.includes(command.provider) ? command.provider : 'unsupported';
1328
1347
  const kind = ['Error', 'TypeError', 'RangeError', 'SyntaxError'].includes(error?.name) ? error.name : 'Error';
1329
1348
  const locations = [...String(error?.stack || '').matchAll(/(?:desktop-host|agent|provider-links|delivery-loop|analysis-manager)\.m?js:\d{1,6}:\d{1,6}/g)].slice(0, 3).map(match => match[0]).join(',') || 'unknown';
1330
- diagnostics.write(`provider connection failed provider=${provider} operation=${command.action} code=${code} kind=${kind} locations=${locations}`);
1349
+ const reason = code === 'desktop_stopping' && ['shutdown', 'update', 'organization_change'].includes(error?.stoppingReason) ? error.stoppingReason : 'unknown';
1350
+ diagnostics.write(`provider connection failed provider=${provider} operation=${command.action} code=${code} reason=${reason} kind=${kind} locations=${locations}`);
1351
+ // Admission can reject before changeProviderLink runs. Retain that
1352
+ // recovery guidance across the next heartbeat, without overwriting
1353
+ // an in-flight link change on the same provider.
1354
+ if (provider !== 'unsupported' && code === 'desktop_stopping' && !providerLinkBusy.has(provider)) {
1355
+ providerLinkFeedback[provider] = { state: 'error', message: desktopActionError(command.action, error) };
1356
+ }
1331
1357
  }
1332
1358
  emit({ type: "result", id: command.id, ok: false, ...(error?.code === 'cancelled' || statisticsRequest?.controller.signal.aborted ? { code: 'cancelled' } : {}), message: ["update", "prepare_update_restart"].includes(command.action) && updateProgress ? updateProgress.message : desktopActionError(command.action, error) });
1333
1359
  } finally {
@@ -1437,7 +1463,7 @@ export function isDesktopHostEntry(entry = process.argv[1]) {
1437
1463
  catch { return false; }
1438
1464
  }
1439
1465
 
1440
- if (isDesktopHostEntry()) {
1466
+ export function startDesktopEntry() {
1441
1467
  let host;
1442
1468
  const stop = () => { void host?.close().finally(() => process.exit(0)); };
1443
1469
  process.once("SIGTERM", stop); process.once("SIGINT", stop);
@@ -1458,3 +1484,18 @@ if (isDesktopHostEntry()) {
1458
1484
  process.exitCode = 1;
1459
1485
  });
1460
1486
  }
1487
+
1488
+ if (isDesktopHostEntry()) {
1489
+ try {
1490
+ const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
1491
+ const selected = await pinnedWorkflowSource({ root });
1492
+ if (selected) {
1493
+ process.env.SIMY_AUTO_UPDATE = '0';
1494
+ const selectedHost = await import(pathToFileURL(join(selected, 'src/desktop-host.js')).href);
1495
+ selectedHost.startDesktopEntry();
1496
+ } else startDesktopEntry();
1497
+ } catch {
1498
+ process.stdout.write(JSON.stringify({ type: 'error', message: 'Local desktop runtime integrity could not be verified. Inspect delivery runtime status; the previous task state was retained.' }) + '\n');
1499
+ process.exitCode = 1;
1500
+ }
1501
+ }
@@ -1,6 +1,6 @@
1
1
  import spawn from "cross-spawn";
2
2
 
3
- import { BACKEND_VERSION_POLICIES, inspectBackendExecutable } from "./backend-executable.js";
3
+ import { BACKEND_VERSION_POLICIES, CODEX_WRITER_LOCK_MINIMUM_VERSION, inspectBackendExecutable } from "./backend-executable.js";
4
4
  import { isSessionValid } from "./session-store.js";
5
5
 
6
6
  const PROVIDER_STATUS_CACHE_MS = 30_000;
@@ -170,7 +170,9 @@ export async function inspectProviderStatus(provider, {
170
170
  const checkedAt = isoNow(now);
171
171
  let executable;
172
172
  try {
173
- executable = await inspectExecutable(provider, { spawnImpl, timeoutMs });
173
+ executable = await inspectExecutable(provider, { spawnImpl, timeoutMs,
174
+ ...(provider === "codex" ? { minimumVersion: CODEX_WRITER_LOCK_MINIMUM_VERSION } : {}),
175
+ });
174
176
  } catch {
175
177
  return providerStatus("unknown", null, checkedAt, `${label} availability could not be checked.`);
176
178
  }
@@ -13,7 +13,10 @@ export const DEV_WORK_EVENT_MAX_BYTES = 4096;
13
13
  const REQUEST_TIMEOUT_MS = 5_000;
14
14
 
15
15
  const REQUIRED_KEYS = ["repo", "branch", "head_sha", "session_id", "source"];
16
- const OPTIONAL_KEYS = ["pr_url", "project_id"];
16
+ // W-02: the independent WorkFlow run that started this coding session. Both
17
+ // or neither; Web/Crew keep it only when it is the device owner's own run.
18
+ const OPTIONAL_KEYS = ["pr_url", "project_id", "workflow_id", "workflow_run_id"];
19
+ const UUID_KEYS = ["project_id", "workflow_id", "workflow_run_id"];
17
20
  const ALLOWED_KEYS = new Set([...REQUIRED_KEYS, ...OPTIONAL_KEYS]);
18
21
  const SOURCES = new Set(["claude_code", "codex", "other"]);
19
22
  const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
@@ -52,14 +55,40 @@ export function normalizeDevWorkEvent(value) {
52
55
  !SESSION_ID.test(event.session_id) ||
53
56
  !SOURCES.has(event.source) ||
54
57
  (event.pr_url !== undefined && !PR_URL.test(event.pr_url)) ||
55
- (event.project_id !== undefined && !UUID.test(event.project_id))
58
+ UUID_KEYS.some((key) => event[key] !== undefined && !UUID.test(event[key])) ||
59
+ (event.workflow_id === undefined) !== (event.workflow_run_id === undefined)
56
60
  ) {
57
61
  throw invalid();
58
62
  }
59
- if (event.project_id !== undefined) event.project_id = event.project_id.toLowerCase();
63
+ for (const key of UUID_KEYS) {
64
+ if (event[key] !== undefined) event[key] = event[key].toLowerCase();
65
+ }
60
66
  return event;
61
67
  }
62
68
 
69
+ /** Environment names the SIMY plugin dev-work hook reads (hooks/dev-work.mjs). */
70
+ export const WORKFLOW_ID_ENV = "SIMY_WORKFLOW_ID";
71
+ export const WORKFLOW_RUN_ID_ENV = "SIMY_WORKFLOW_RUN_ID";
72
+
73
+ /**
74
+ * Provider child environment naming the independent WorkFlow run that started
75
+ * it, so the plugin hook can report which run the coding session belongs to.
76
+ * Without a complete run reference the keys are set to undefined, which
77
+ * child_process omits, so a value inherited from this agent's own environment
78
+ * can never attribute an unrelated session to a run.
79
+ */
80
+ export function workflowRunEnv(reference) {
81
+ const workflowId = String(reference?.workflow_id || "");
82
+ const runId = String(reference?.workflow_run_id || "");
83
+ if (!UUID.test(workflowId) || !UUID.test(runId)) {
84
+ return { [WORKFLOW_ID_ENV]: undefined, [WORKFLOW_RUN_ID_ENV]: undefined };
85
+ }
86
+ return {
87
+ [WORKFLOW_ID_ENV]: workflowId.toLowerCase(),
88
+ [WORKFLOW_RUN_ID_ENV]: runId.toLowerCase(),
89
+ };
90
+ }
91
+
63
92
  /**
64
93
  * Posts one normalized report to Web. Resolves with the minimal receipt; a
65
94
  * disabled gateway (404) or any refusal rejects with a status the local