@awak-app/simy-cli 0.6.13 → 0.6.17

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 (37) hide show
  1. package/README.md +91 -6
  2. package/package.json +1 -1
  3. package/src/agent.js +97 -23
  4. package/src/auto-update.js +55 -6
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
  7. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
  8. package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
  9. package/src/codex-plugins/simy/hooks/hooks.json +2 -2
  10. package/src/codex-work-pattern-sync.js +37 -9
  11. package/src/delivery-hooks/command.js +9 -2
  12. package/src/delivery-hooks/desktop.js +10 -12
  13. package/src/delivery-hooks/evidence.js +50 -0
  14. package/src/delivery-hooks/supervisor.js +8 -2
  15. package/src/desktop-access.js +5 -3
  16. package/src/desktop-host.js +511 -74
  17. package/src/desktop-update-stop.js +22 -0
  18. package/src/doctor.js +2 -1
  19. package/src/host-exit-diagnostics.js +28 -0
  20. package/src/index.js +1 -0
  21. package/src/provider-links.js +74 -0
  22. package/src/session-supervision/action-association-host.js +23 -10
  23. package/src/session-supervision/association-refresh.js +12 -0
  24. package/src/session-supervision/association-store.js +26 -10
  25. package/src/session-supervision/automatic-continuation.js +31 -2
  26. package/src/session-supervision/claude-sessions.js +25 -15
  27. package/src/session-supervision/codex-app-server.js +27 -4
  28. package/src/session-supervision/index.js +163 -42
  29. package/src/session-supervision/statistics.js +76 -15
  30. package/src/sqm/command.js +58 -7
  31. package/src/sqm/evidence-client.js +11 -1
  32. package/src/sqm/failure-circuit.js +28 -6
  33. package/src/sqm/history-store.js +49 -18
  34. package/src/sqm/provider-diagnostics.js +1 -0
  35. package/src/sqm/review-worker.js +70 -21
  36. package/src/sqm/workflow-update.js +12 -3
  37. package/src/windows-desktop-updater.js +6 -1
@@ -3,7 +3,7 @@
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
- import { isAbsolute, join, relative, resolve } from 'node:path';
6
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
7
  import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
9
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
@@ -27,6 +27,23 @@ export const taskDirectory = record => record.run?.executionCwd || record.cwd;
27
27
  export const taskWorkspaceDirectories = record => [...new Set([record.cwd, taskDirectory(record)])];
28
28
  export const sharesTaskWorkspace = (left, right) => taskWorkspaceDirectories(left).some(cwd => taskWorkspaceDirectories(right).includes(cwd));
29
29
  const checkpointDirectoryMatches = (record, cwd) => record.cwd === cwd || taskDirectory(record) === cwd;
30
+ // Plugins ship this standalone file without the CLI's provider-links module.
31
+ // Honor the same durable revocation file before accepting hook work; missing
32
+ // files retain legacy behavior, while malformed saved policy fails closed.
33
+ export async function providerLinkState(root, provider) {
34
+ try {
35
+ const settings = JSON.parse(await readFile(join(dirname(resolve(root)), 'provider-links.json'), 'utf8'));
36
+ const valid = settings?.schemaVersion === 1 && ['codex', 'claude'].every(key => {
37
+ const value = settings.providers?.[key];
38
+ return value && typeof value.connected === 'boolean' && typeof value.requiresConsent === 'boolean' &&
39
+ (value.epoch === null || typeof value.epoch === 'string' && value.epoch.length > 0 && value.epoch.length <= 128);
40
+ });
41
+ return valid ? settings.providers[provider] ?? { connected: false, epoch: null } : { connected: false, epoch: null };
42
+ } catch (error) { return { connected: error.code === 'ENOENT', epoch: null }; }
43
+ }
44
+ export async function linkedProvider(root, provider) {
45
+ return (await providerLinkState(root, provider)).connected === true;
46
+ }
30
47
  const digest = value => createHash('sha256').update(value).digest('hex');
31
48
  const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
32
49
  const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
@@ -472,7 +489,10 @@ export function deliveryAcceptanceContext(run) {
472
489
  ` Pending acceptance (last recorded checks): ${JSON.stringify(pendingAcceptance(run))}. Continue only within the original request and permissions; manual checks require the user. SQM is optional and 0 applicable checks do not establish quality acceptance.`;
473
490
  }
474
491
  export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, objective, nextStep, maxFailures, continuationPolicy, managed, admission,
475
- liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
492
+ liveHookAdmission, autoContinueEpoch, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
493
+ const link = await providerLinkState(root, provider);
494
+ if (providerLinkEpoch !== undefined && providerLinkEpoch !== link.epoch) throw Object.assign(new Error("SIMY provider connection changed before enrollment."), { code: "provider_link_changed" });
495
+ if (!link.connected) throw Object.assign(new Error("Reconnect this provider to SIMY before using it."), { code: "provider_link_disconnected" });
476
496
  cwd = await realpath(cwd);
477
497
  if (executionCwd !== undefined) {
478
498
  executionCwd = await realpath(executionCwd);
@@ -534,6 +554,8 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
534
554
  }
535
555
  if (managedState && !managedState.contextBootstrap) freezeEnrollment(run, now);
536
556
  if (waiting) escalate(run, run.reason);
557
+ const latest = await providerLinkState(root, provider);
558
+ if (!latest.connected || latest.epoch !== link.epoch) throw Object.assign(new Error("SIMY connection changed before enrollment."), { code: "provider_link_disconnected" });
537
559
  return { ...previous, run };
538
560
  });
539
561
  }
@@ -1052,12 +1074,16 @@ async function providerProcess(provider) {
1052
1074
  return null;
1053
1075
  }
1054
1076
  export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity }) {
1077
+ const link = await providerLinkState(root, provider);
1078
+ if (!link.connected) return {};
1055
1079
  if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
1056
1080
  let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
1057
1081
  const event = input.hook_event_name;
1058
1082
  const identity = observedProcess === undefined && (['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) ? await providerProcess(provider) : observedProcess;
1059
1083
  let output = {}, boundaryLease = null, claimedWorkspace = null;
1060
1084
  try { await updateRecord(root, provider, input.session_id, async old => {
1085
+ const currentLink = await providerLinkState(root, provider);
1086
+ if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
1061
1087
  if (old && old.cwd !== cwd) return old;
1062
1088
  const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
1063
1089
  const owner = record.run?.managed?.liveHookOwner;
@@ -1139,7 +1165,8 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1139
1165
  } else if (event === 'Stop') {
1140
1166
  // OFF suppresses future Stop blocking; it never interrupts the provider
1141
1167
  // turn. Lease settlement and runtime observation above still run.
1142
- if (!automaticContinuationAllowed(record)) return record;
1168
+ const latestLink = await providerLinkState(root, provider);
1169
+ if (!automaticContinuationAllowed(record) || !latestLink.connected || latestLink.epoch !== link.epoch) return record;
1143
1170
  // Codex turn IDs make re-delivered Stop events idempotent. Claude has no
1144
1171
  // stable event ID; never dedupe on text (identical text can be a new turn).
1145
1172
  const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
@@ -9,6 +9,7 @@
9
9
  // never a secret), and it always exits 0. The raw provider session id is never
10
10
  // sent or stored; only a provider-scoped SHA-256 of it leaves this process.
11
11
 
12
+ import { linkedProvider, providerLinkState } from "./delivery-loop.mjs";
12
13
  import { execFile } from "node:child_process";
13
14
  import { createHash } from "node:crypto";
14
15
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
@@ -177,12 +178,14 @@ export async function runDevWorkHook({
177
178
  fetchImpl = globalThis.fetch,
178
179
  runImpl = run,
179
180
  }) {
181
+ const sessionRoot = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
182
+ const link = await providerLinkState(join(sessionRoot, "delivery-hooks"), provider);
183
+ if (!link.connected) return { sent: false, reason: "provider_link_disconnected" };
180
184
  const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
181
185
  const sessionId = input.session_id || input.sessionId || env.CODEX_THREAD_ID;
182
186
  const event = await collectDevWorkEvent({ cwd, provider, sessionId, env, runImpl });
183
187
  if (!event) return { sent: false, reason: "not_applicable" };
184
188
 
185
- const sessionRoot = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
186
189
  const pluginData =
187
190
  env.PLUGIN_DATA?.trim() || env.CLAUDE_PLUGIN_DATA?.trim() || join(sessionRoot, "plugin-data", "simy");
188
191
  // The upstream observation is keyed by (Project, repo, HEAD, session): a
@@ -192,10 +195,13 @@ export async function runDevWorkHook({
192
195
  if ((await readState(statePath))?.fingerprint === fingerprint) {
193
196
  return { sent: false, reason: "unchanged" };
194
197
  }
198
+ if (!await linkedProvider(join(sessionRoot, "delivery-hooks"), provider)) return { sent: false, reason: "provider_link_disconnected" };
195
199
  const port = await findAgentPort({ env, sessionRoot });
196
200
  if (!port) return { sent: false, reason: "agent_unavailable" };
197
201
  const prUrl = await pullRequestUrl({ cwd, repo: event.repo, env, runImpl });
198
202
  if (prUrl) event.pr_url = prUrl;
203
+ const latest = await providerLinkState(join(sessionRoot, "delivery-hooks"), provider);
204
+ if (!latest.connected || latest.epoch !== link.epoch) return { sent: false, reason: "provider_link_disconnected" };
199
205
 
200
206
  const response = await fetchImpl(`http://127.0.0.1:${port}/v1/dev-work-events`, {
201
207
  method: "POST",
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main();\"",
10
+ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main('codex');\"",
11
11
  "timeout": 3,
12
12
  "statusMessage": "SIMY connection check at session start"
13
13
  }
@@ -29,7 +29,7 @@
29
29
  "hooks": [
30
30
  {
31
31
  "type": "command",
32
- "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main();\"",
32
+ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main('codex');\"",
33
33
  "timeout": 3,
34
34
  "statusMessage": "SIMY connection check before a prompt"
35
35
  }
@@ -6,6 +6,7 @@ import { basename, join, relative, resolve, sep } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
7
 
8
8
  import { webApiErrorMessage, webApiHeaders, webApiUrl } from "./web-api.js";
9
+ import { readProviderLinks } from "./provider-links.js";
9
10
 
10
11
  const MAX_THREADS = 10;
11
12
  const MAX_MESSAGES = 24;
@@ -76,9 +77,20 @@ export async function syncCodexWorkPatterns({
76
77
  onDirectoryVisited,
77
78
  onBeforeExtract,
78
79
  signal,
80
+ connectedProviders = [PROVIDER_CODEX, PROVIDER_CLAUDE],
79
81
  }) {
80
82
  if (!token || !deviceId || !sessionRoot) return { status: "not_connected", uploaded: 0 };
81
83
  signal?.throwIfAborted();
84
+ const links = await readProviderLinks(sessionRoot);
85
+ const providersToRead = connectedProviders.filter(provider => links[provider]?.connected === true);
86
+ if (providersToRead.length === 0) return { status: "provider_disconnected", uploaded: 0 };
87
+ const authorizeProviders = async () => {
88
+ signal?.throwIfAborted();
89
+ const current = await readProviderLinks(sessionRoot);
90
+ if (providersToRead.some(provider => !current[provider]?.connected || current[provider].epoch !== links[provider].epoch)) {
91
+ throw Object.assign(new Error("SIMY provider connection changed before history synchronization. Retry after reconnecting."), { code: "provider_link_changed" });
92
+ }
93
+ };
82
94
  // SIMY Web runs with trailingSlash=true; use the canonical slash form so the
83
95
  // POST body never depends on a 308 redirect being replayed.
84
96
  const endpoint = webApiUrl("work-pattern-observations/", {
@@ -114,7 +126,8 @@ export async function syncCodexWorkPatterns({
114
126
  });
115
127
  const persistedState = await readSyncState(statePath);
116
128
  const previousState = statusPayload.reset_cursor === true ? {} : persistedState;
117
- const state = previousState.lookback_days === lookbackDays ? previousState : {};
129
+ const state = { ...previousState, providers: Object.fromEntries(Object.entries(previousState.providers || {})
130
+ .filter(([, cursor]) => cursor?.reset_pending !== true && (cursor?.lookback_days ?? previousState.lookback_days) === lookbackDays)) };
118
131
  const lookbackCutoffMs = now - lookbackDays * 24 * 60 * 60 * 1_000;
119
132
  const providerSpecs = [
120
133
  {
@@ -131,11 +144,12 @@ export async function syncCodexWorkPatterns({
131
144
  // machine-to-machine work, not a user's conversation, so exclude them.
132
145
  includeRelativePath: (filePath) => !filePath.split(sep).includes("subagents"),
133
146
  },
134
- ];
147
+ ].filter(spec => providersToRead.includes(spec.provider));
135
148
  const inventories = new Map();
136
149
  let incompleteReplayRoot = false;
137
150
  let hasCompleteRoot = false;
138
151
  const files = (await Promise.all(providerSpecs.map(async (spec) => {
152
+ await authorizeProviders();
139
153
  const providerState = providerCursor(state, spec.provider);
140
154
  // Do not use the history window as a pre-read mtime filter: restored or
141
155
  // migrated files can have mtimes that disagree with their message dates.
@@ -170,6 +184,7 @@ export async function syncCodexWorkPatterns({
170
184
  for (const file of selectedFiles) {
171
185
  const spec = providerSpecs.find((item) => item.provider === file.provider);
172
186
  await onBeforeExtract?.(file);
187
+ await authorizeProviders();
173
188
  const extracted = await spec.extract(file.path, {
174
189
  deviceId,
175
190
  fallbackObservedAt: new Date(file.mtimeMs).toISOString(),
@@ -204,6 +219,7 @@ export async function syncCodexWorkPatterns({
204
219
  }
205
220
  const shouldAcknowledgeEmptyReset =
206
221
  statusPayload.reset_cursor === true &&
222
+ providerSpecs.length === 2 &&
207
223
  hasCompleteRoot &&
208
224
  !incompleteReplayRoot &&
209
225
  selectedFiles.length === successfullyReadFiles.length &&
@@ -213,10 +229,10 @@ export async function syncCodexWorkPatterns({
213
229
  }
214
230
 
215
231
  const uploadResult = threads.length > 0
216
- ? await uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal })
232
+ ? await uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders })
217
233
  : { uploaded: 0 };
218
234
  if (shouldAcknowledgeEmptyReset) {
219
- const { payload } = await postThreadBatch({ endpoint, token, threads: [], fetchImpl, signal });
235
+ const { payload } = await postThreadBatch({ endpoint, token, threads: [], fetchImpl, signal, authorizeProviders });
220
236
  validateThreadBatchAcknowledgement(payload, []);
221
237
  }
222
238
  if (selectedFiles.length === 0) return { status: "up_to_date", uploaded: 0 };
@@ -227,15 +243,21 @@ export async function syncCodexWorkPatterns({
227
243
  };
228
244
  }
229
245
 
230
- const providers = {};
246
+ // Keep disconnected providers' acknowledged history intact. Their own
247
+ // lookback records prevent a later reconnect from inheriting a changed window.
248
+ const providers = Object.fromEntries(Object.entries(persistedState.providers || {})
249
+ .filter(([provider]) => !providersToRead.includes(provider))
250
+ .map(([provider, cursor]) => [provider, { ...cursor, lookback_days: cursor?.lookback_days ?? persistedState.lookback_days,
251
+ ...(statusPayload.reset_cursor === true ? { reset_pending: true } : {}) }]));
231
252
  for (const spec of providerSpecs) {
232
- providers[spec.provider] = advanceProviderCursor(
253
+ providers[spec.provider] = { ...advanceProviderCursor(
233
254
  providerCursor(state, spec.provider),
234
255
  successfullyReadFiles.filter((file) => file.provider === spec.provider),
235
256
  inventories.get(spec.provider),
236
- );
257
+ ), lookback_days: lookbackDays };
237
258
  }
238
259
  signal?.throwIfAborted();
260
+ await authorizeProviders();
239
261
  await writeSyncState(statePath, {
240
262
  schema_version: STATE_SCHEMA_VERSION,
241
263
  lookback_days: lookbackDays,
@@ -257,7 +279,7 @@ export function workPatternSyncStatePath({ sessionRoot, apiOrigin, accountId, de
257
279
  return join(sessionRoot, "work-pattern-history-sync", `${scopeHash}.json`);
258
280
  }
259
281
 
260
- async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal }) {
282
+ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders }) {
261
283
  const batches = [];
262
284
  let batch = [];
263
285
  for (const thread of threads) {
@@ -284,13 +306,14 @@ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal
284
306
  threads: current,
285
307
  fetchImpl,
286
308
  signal,
309
+ authorizeProviders,
287
310
  });
288
311
  uploaded += result.uploaded;
289
312
  }
290
313
  return { uploaded };
291
314
  }
292
315
 
293
- async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal }) {
316
+ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal, authorizeProviders }) {
294
317
  const { response, payload } = await postThreadBatch({
295
318
  endpoint,
296
319
  token,
@@ -298,6 +321,7 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
298
321
  fetchImpl,
299
322
  signal,
300
323
  allow413: true,
324
+ authorizeProviders,
301
325
  });
302
326
  if (response.status !== 413) {
303
327
  validateThreadBatchAcknowledgement(payload, threads);
@@ -311,6 +335,7 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
311
335
  threads: threads.slice(0, middle),
312
336
  fetchImpl,
313
337
  signal,
338
+ authorizeProviders,
314
339
  });
315
340
  const right = await postBatchWithBounded413Recovery({
316
341
  endpoint,
@@ -318,6 +343,7 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
318
343
  threads: threads.slice(middle),
319
344
  fetchImpl,
320
345
  signal,
346
+ authorizeProviders,
321
347
  });
322
348
  return { uploaded: left.uploaded + right.uploaded };
323
349
  }
@@ -334,11 +360,13 @@ async function postThreadBatch({
334
360
  fetchImpl,
335
361
  signal,
336
362
  allow413 = false,
363
+ authorizeProviders,
337
364
  }) {
338
365
  const body = JSON.stringify({ threads });
339
366
  if (Buffer.byteLength(body, "utf8") > MAX_GATEWAY_BODY_BYTES) {
340
367
  throw new Error("Work-pattern observation payload exceeds the local CLI gateway limit.");
341
368
  }
369
+ await authorizeProviders();
342
370
  const response = await fetchImpl(endpoint, {
343
371
  method: "POST",
344
372
  headers: webApiHeaders({ token }, { "Content-Type": "application/json" }),
@@ -1,5 +1,6 @@
1
+ import { deliveryEvidence } from './evidence.js';
1
2
  import { readFile } from 'node:fs/promises';
2
- import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery, readRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
4
  import { continueSession } from '../session-supervision/index.js';
4
5
  import { cancelHandoffSession, handoffSession } from '../session-supervision/handoff.js';
5
6
  import { runActivityCommand } from './activity.js';
@@ -20,6 +21,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
20
21
  const [action, ...args] = argv;
21
22
  if (action === 'activity') return runActivityCommand(args, { root, output });
22
23
  if (!action || action === '--help') {
24
+ output('simy delivery evidence --provider codex|claude --session ID — read-only execution and acceptance receipts.');
23
25
  output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
24
26
  output('A task stops after repeated failures, and pauses for review when no progress is confirmed. Resume starts a new progress window.');
25
27
  output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
@@ -36,7 +38,12 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
36
38
  }
37
39
  const base = { root, cwd, provider: options.provider, session: options.session };
38
40
  let result;
39
- if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
41
+ if (action === 'evidence') {
42
+ const record = await readRecord(root, options.provider, options.session);
43
+ if (!record) throw new Error('Exact provider session record not found.');
44
+ result = deliveryEvidence(record, root);
45
+ }
46
+ else if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
40
47
  else if (action === 'continue') result = await continueSession({ ...base, executionCwd: options['execution-cwd'], autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
41
48
  ...Object.fromEntries([['max-failures', 'maxFailures'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
42
49
  .filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
@@ -4,7 +4,7 @@ import { canReplanDelivery, pendingAcceptance, deliveryStop, deliveryStopBlocksR
4
4
  import { originalSessionTarget } from '../session-supervision/original-session-target.js';
5
5
 
6
6
  export const HOOK_SETUP = Object.freeze({
7
- codex: { settingsUrl: 'codex://settings', docsUrl: 'https://learn.chatgpt.com/docs/hooks', command: '/hooks' },
7
+ codex: { settingsUrl: null, docsUrl: 'https://learn.chatgpt.com/docs/hooks', command: '/hooks' },
8
8
  claude: { settingsUrl: null, docsUrl: 'https://code.claude.com/docs/en/hooks-guide', command: '/hooks' },
9
9
  });
10
10
 
@@ -85,19 +85,17 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
85
85
  };
86
86
  }
87
87
 
88
- // Wait for the OS opener to exit. A successful spawn alone does not prove that
89
- // a custom URL scheme has a registered handler.
88
+ // Codex registering its URL scheme does not prove a settings route exists.
89
+ // Both providers use their official guide and leave hook trust to the user.
90
90
  export async function openHookSetup(provider, { platform = process.platform, run = promisify(execFile) } = {}) {
91
91
  const setup = HOOK_SETUP[provider];
92
92
  if (!setup) throw new Error("Unsupported provider.");
93
- const targets = platform === "darwin" && setup.settingsUrl ? [setup.settingsUrl, setup.docsUrl] : [setup.docsUrl];
94
- for (const url of targets) {
95
- try {
96
- const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
97
- const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
98
- await run(command, args, { timeout: 5000, maxBuffer: 4096 });
99
- return { opened: true, url };
100
- } catch {}
93
+ const command = platform === "darwin" ? "/usr/bin/open" : platform === "win32" ? "rundll32" : "xdg-open";
94
+ const args = platform === "win32" ? ["url.dll,FileProtocolHandler", setup.docsUrl] : [setup.docsUrl];
95
+ try {
96
+ await run(command, args, { timeout: 5000, maxBuffer: 4096 });
97
+ return { opened: true, url: setup.docsUrl };
98
+ } catch {
99
+ throw new Error("Could not open hook setup. Use /hooks inside your provider.");
101
100
  }
102
- throw new Error("Could not open hook setup. Use /hooks inside your provider.");
103
101
  }
@@ -0,0 +1,50 @@
1
+ import { pendingAcceptance, recordPath, taskDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+
3
+ // A read-only projection: provider reports and continuation instructions are
4
+ // deliberately separate from supervisor checkpoint receipts and acceptance.
5
+ export function deliveryEvidence(record, root) {
6
+ const run = record?.run;
7
+ const managed = run?.managed;
8
+ const attempts = managed?.attempts || [];
9
+ const counts = Object.fromEntries(['checkpoint', 'failed', 'cancelled', 'busy'].map(outcome =>
10
+ [outcome, attempts.filter(attempt => attempt.outcome === outcome).length]));
11
+ return {
12
+ schemaVersion: 1,
13
+ provider: record.provider, session: record.session,
14
+ recordPath: recordPath(root, record.provider, record.session),
15
+ providerCwd: record.cwd, executionCwd: taskDirectory(record),
16
+ runId: run?.id || null, taskId: managed?.taskId || null,
17
+ mode: !run ? 'not_started' : managed ? 'managed' : 'checkpoint_tracking',
18
+ state: run?.state || null,
19
+ controller: managed ? { capability: managed.capability, resumeChannel: managed.resumeChannel,
20
+ autoRecovery: managed.autoRecovery === true, deliveryLevel: managed.deliveryLevel || null } : null,
21
+ execution: {
22
+ supervisorAttemptsTotal: managed?.autoAttempts || 0,
23
+ retainedAttemptCount: attempts.length, retainedAttemptOutcomes: counts,
24
+ supervisorStructuredCheckpointCount: counts.checkpoint,
25
+ currentAttempt: managed?.executionAttempt ? {
26
+ id: managed.executionAttempt.id, phase: managed.executionAttempt.phase,
27
+ dispatchedAt: managed.executionAttempt.dispatchedAt || null,
28
+ checkpointRevision: managed.executionAttempt.checkpointRevision ?? null,
29
+ } : null,
30
+ continuationInstructions: run?.continuations || 0,
31
+ providerReportedWorkCount: managed?.workHistory?.length || 0,
32
+ historyScope: 'retained_history_only',
33
+ },
34
+ acceptance: {
35
+ status: !managed || !managed.acceptanceCriteria?.length ? 'not_independently_verified'
36
+ : pendingAcceptance(run).length ? 'pending' : 'recorded_checks_passed',
37
+ criteria: (managed?.acceptanceCriteria || []).map(criterion => ({
38
+ id: criterion.id, method: criterion.check.type,
39
+ receipt: managed.verified?.[criterion.id] || null,
40
+ })),
41
+ pending: managed ? pendingAcceptance(run).map(criterion => criterion.id) : [],
42
+ },
43
+ limitations: [
44
+ 'Enrollment, claimed attempts and continuation instructions do not prove provider execution.',
45
+ 'Structured checkpoints and provider-reported work do not certify user acceptance or DMG behavior.',
46
+ 'Attempt and work history are bounded; counts describe retained receipts, not lifetime executions.',
47
+ 'Recorded acceptance is a snapshot; this read-only command does not rerun checks.',
48
+ ],
49
+ };
50
+ }
@@ -1,3 +1,4 @@
1
+ import { requireProviderLink } from '../provider-links.js';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { dirname } from 'node:path';
@@ -36,6 +37,7 @@ function watchLease(root, provider, session, inspect, onFailure) {
36
37
  }
37
38
  async function assertActiveLease(root, provider, session, leaseId, signal, currentTime = Date.now) {
38
39
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
40
+ await requireProviderLink({ root, provider });
39
41
  const current = await readRecord(root, provider, session);
40
42
  if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
41
43
  throw new Error('Managed recovery lease changed before provider dispatch.');
@@ -159,6 +161,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
159
161
  continue;
160
162
  }
161
163
  if (!automaticContinuationAllowed(record)) continue;
164
+ try { await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
162
165
  const appServer = run.managed.resumeChannel === 'codex_app_server';
163
166
  const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
164
167
  const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
@@ -168,8 +171,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
168
171
  if (run.state !== 'recovery_required' || !causeAllowed || !run.managed.autoRecovery || run.managed.pendingApproval || run.managed.autonomyPolicy && run.managed.decision?.outcome === 'wait' && !approvedWait || appServer && run.managed.nextAttemptAt > claimNow) continue;
169
172
  let authorizationScope;
170
173
  const authorizeClaim = async () => {
174
+ await requireProviderLink({ root, provider: record.provider });
171
175
  if (!authorizeDispatch) return;
172
- const latest = await authorizeDispatch();
176
+ const latest = await authorizeDispatch(record);
173
177
  if (authorizationScope !== undefined && latest !== authorizationScope) throw Object.assign(new Error("Desktop authorization scope changed."), { code: "desktop_access_required" });
174
178
  authorizationScope = latest;
175
179
  };
@@ -285,7 +289,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
285
289
  } });
286
290
  }
287
291
  }
288
- } catch (error) { result = error?.code === 'desktop_access_required'
292
+ } catch (error) { result = error?.code === 'provider_link_disconnected'
293
+ ? { code: -2, cancelled: true }
294
+ : error?.code === 'desktop_access_required'
289
295
  ? { code: -3, busy: true, accessDenied: true }
290
296
  : { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
291
297
  errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
@@ -8,8 +8,8 @@ export function validDesktopAccessPath(path, slug) {
8
8
  }
9
9
  export const desktopAccessReady = value => value?.state === 'ready';
10
10
  const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
11
- 'check_update', 'update', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
- 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
11
+ 'check_update', 'update', 'prepare_update_restart', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
+ 'provider_disconnect', 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
13
  export function desktopActionAllowed(access, command) {
14
14
  return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
15
15
  command?.action === 'session_set_auto_continue' && command.enabled === false;
@@ -52,7 +52,9 @@ export function createDesktopAccessPolicy({ readSession, isValid, fetchAccess, i
52
52
  result = { state: payload.state,
53
53
  organizationId: payload.organizationId, organizationName: payload.organizationName.slice(0, 120), organizationSlug: payload.organizationSlug, settingsPath: payload.settingsPath, membersPath: payload.membersPath };
54
54
  } catch (error) {
55
- result = { ...base, state: error?.status === 401 ? 'signed_out' : 'unavailable' };
55
+ result = { ...base, state: error?.status === 401 ? 'signed_out' : 'unavailable',
56
+ reason: error?.status === 401 ? 'authentication_required' :
57
+ ['TimeoutError', 'AbortError'].includes(error?.name) ? 'timeout' : 'service_unavailable' };
56
58
  }
57
59
  // Late responses never authorize a new account or a switched organization.
58
60
  if (readSession() === session && basic().state === 'checking') {