@awak-app/simy-cli 0.6.16 → 0.6.18

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 (42) hide show
  1. package/README.md +99 -6
  2. package/package.json +1 -1
  3. package/src/agent.js +123 -28
  4. package/src/auto-update.js +15 -3
  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 +4 -3
  16. package/src/desktop-host.js +669 -73
  17. package/src/doctor.js +2 -1
  18. package/src/host-exit-diagnostics.js +28 -0
  19. package/src/index.js +1 -0
  20. package/src/native-development-report-completion.js +143 -0
  21. package/src/native-development-reports.js +137 -0
  22. package/src/orchestrator/checklist-continuation.js +5 -0
  23. package/src/orchestrator/delivery/WORKFLOW.md +4 -0
  24. package/src/orchestrator/delivery/references/state-model.md +187 -0
  25. package/src/orchestrator/loop.js +119 -34
  26. package/src/provider-links.js +74 -0
  27. package/src/runner.js +31 -2
  28. package/src/session-supervision/automatic-continuation.js +31 -2
  29. package/src/session-supervision/claude-sessions.js +25 -15
  30. package/src/session-supervision/codex-app-server.js +27 -4
  31. package/src/session-supervision/index.js +163 -42
  32. package/src/session-supervision/statistics.js +76 -15
  33. package/src/sqm/command.js +58 -7
  34. package/src/sqm/evidence-client.js +11 -1
  35. package/src/sqm/failure-circuit.js +28 -6
  36. package/src/sqm/history-store.js +49 -18
  37. package/src/sqm/provider-diagnostics.js +1 -0
  38. package/src/sqm/review-worker.js +70 -21
  39. package/src/sqm/workflow-update.js +12 -3
  40. package/src/windows-bootstrap.js +2 -1
  41. package/src/windows-desktop-updater.js +6 -1
  42. package/src/windows-inbound.js +9 -0
@@ -28,6 +28,10 @@ async function runCodingLoopOwned({
28
28
  shouldStop = () => false,
29
29
  consumeHumanGuidance = () => "",
30
30
  }) {
31
+ if (resume && snapshot.attempts.at(-1)?.ci_review_refresh_pending) {
32
+ return recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm, executeIndependentAudit,
33
+ shouldStop, onUpdate, retryCiReview: true });
34
+ }
31
35
  if (!resume && snapshot.attempts.length > 0 && !snapshot.metadata?.completed_worker_result) {
32
36
  return recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm, onUpdate });
33
37
  }
@@ -263,39 +267,7 @@ async function runCodingLoopOwned({
263
267
  });
264
268
  await publish(snapshot, "independent_auditing", onUpdate);
265
269
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
266
- const auditInstruction = buildIndependentAuditInstruction(snapshot.charter, attempt);
267
- const reviewedMatrix = attempt.observed_evidence?.minimum_completion?.matrix_sha256;
268
- const reviewedEvidenceIdentity = independentAuditEvidenceIdentity(attempt);
269
- const reviewedObjectiveDigest = snapshot.charter.thread_state?.objective_contract?.current_digest;
270
- const reviewedObjectiveVersion = snapshot.charter.thread_state?.objective_contract?.version;
271
- const auditExecution = await safeExecution(() =>
272
- executeIndependentAudit({
273
- attemptNumber,
274
- backend: snapshot.charter.audit_backend,
275
- instruction: auditInstruction,
276
- charter: snapshot.charter,
277
- attempt,
278
- }),
279
- );
280
- if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
281
- attempt.independent_audit = buildIndependentAudit(
282
- auditExecution,
283
- snapshot.charter,
284
- attempt,
285
- );
286
- attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
287
- attempt.budget = refreshBudgetState(snapshot);
288
- recordBudgetWarnings(snapshot, attempt);
289
- if (attempt.budget.token_exhausted) {
290
- await stopForBudget({ snapshot, attempt, reason: "token_budget_exhausted", onUpdate });
291
- return snapshot;
292
- }
293
-
294
- attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
295
- attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
296
- attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
297
- attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
298
- attempt.independent_audit.objective_version = reviewedObjectiveVersion;
270
+ if (await reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate })) return snapshot;
299
271
 
300
272
  // Re-collect after the read-only auditor to catch any mutated HEAD or working tree.
301
273
  attempt.observed_evidence = await safeEvidence(collectEvidence, attempt, { retries: snapshot.metadata?.completed_worker_result ? 2 : 0, shouldStop });
@@ -369,7 +341,63 @@ async function stopCodingLoop(snapshot, onUpdate) {
369
341
  return snapshot;
370
342
  }
371
343
 
372
- async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = null, onUpdate }) {
344
+ async function reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate }) {
345
+ const auditInstruction = buildIndependentAuditInstruction(snapshot.charter, attempt);
346
+ const reviewedMatrix = attempt.observed_evidence?.minimum_completion?.matrix_sha256;
347
+ const reviewedEvidenceIdentity = independentAuditEvidenceIdentity(attempt);
348
+ const reviewedChecks = structuredClone(attempt.observed_evidence?.github?.checks || []);
349
+ const reviewedObjectiveDigest = snapshot.charter.thread_state?.objective_contract?.current_digest;
350
+ const reviewedObjectiveVersion = snapshot.charter.thread_state?.objective_contract?.version;
351
+ const auditExecution = await safeExecution(() =>
352
+ executeIndependentAudit({
353
+ attemptNumber: attempt.attempt_number,
354
+ backend: snapshot.charter.audit_backend,
355
+ instruction: auditInstruction,
356
+ charter: snapshot.charter,
357
+ attempt,
358
+ }),
359
+ );
360
+ if (shouldStop()) { await stopCodingLoop(snapshot, onUpdate); return true; }
361
+ attempt.independent_audit = buildIndependentAudit(
362
+ auditExecution,
363
+ snapshot.charter,
364
+ attempt,
365
+ );
366
+ attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
367
+ attempt.budget = refreshBudgetState(snapshot);
368
+ recordBudgetWarnings(snapshot, attempt);
369
+ if (attempt.budget.token_exhausted) {
370
+ await stopForBudget({ snapshot, attempt, reason: "token_budget_exhausted", onUpdate });
371
+ return true;
372
+ }
373
+
374
+ attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
375
+ attempt.independent_audit.reviewed_checks = reviewedChecks;
376
+ attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
377
+ attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
378
+ attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
379
+ attempt.independent_audit.objective_version = reviewedObjectiveVersion;
380
+ return false;
381
+ }
382
+
383
+ // Only a CI-only change to the already reviewed candidate may automatically
384
+ // renew its review. Head/base/preparation/acceptance changes need explicit work.
385
+ function ciReviewRefreshIdentity(attempt, previousEvidence) {
386
+ const reviewed = attempt.independent_audit;
387
+ const github = attempt.observed_evidence?.github;
388
+ if (!reviewed?.passed || !reviewed.evidence_identity_sha256 || !github?.available ||
389
+ !attempt.observed_evidence?.local?.available || !attempt.observed_evidence.local.working_tree_clean ||
390
+ attempt.observed_evidence.local.errors?.length || github.error || !github.required_checks_present || !github.checks_passing ||
391
+ !github.checks?.length || github.checks.some(check => check.state !== 'success' && check.state !== 'skipped')) return null;
392
+ const identity = independentAuditEvidenceIdentity(attempt);
393
+ if (identity === reviewed.evidence_identity_sha256) return null;
394
+ const prior = { ...attempt, observed_evidence: { ...attempt.observed_evidence,
395
+ github: { ...github, checks: reviewed.reviewed_checks || previousEvidence?.github?.checks } } };
396
+ return independentAuditEvidenceIdentity(prior) === reviewed.evidence_identity_sha256 ? identity : null;
397
+ }
398
+
399
+ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = null, executeIndependentAudit, shouldStop = () => false, onUpdate, retryCiReview = false }) {
400
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
373
401
  if (!verifyObjective(snapshot.charter).valid) {
374
402
  snapshot.implementation_gate_passed = false;
375
403
  snapshot.done_gate_passed = false;
@@ -391,6 +419,7 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
391
419
  code: "OBJECTIVE_EVIDENCE_STALE", severity: "blocker", target: "objective_contract",
392
420
  explanation: "Make a new attempt and independently review evidence against the current objective.", repairability: "manual",
393
421
  }]);
422
+ if (retryCiReview) attempt.ci_review_refresh_pending = false;
394
423
  await publish(snapshot, "waiting_human", onUpdate);
395
424
  return snapshot;
396
425
  }
@@ -398,16 +427,72 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
398
427
  attempt_number: attempt.attempt_number,
399
428
  });
400
429
  await publish(snapshot, "checking_pr", onUpdate);
430
+ const previousEvidence = attempt.observed_evidence;
431
+ // Recover an old review's CI basis only while the saved full identity still
432
+ // proves it. Later monitoring observations cannot reconstruct that history.
433
+ if (!attempt.independent_audit?.reviewed_checks &&
434
+ attempt.independent_audit?.evidence_identity_sha256 === independentAuditEvidenceIdentity(attempt) &&
435
+ Array.isArray(previousEvidence?.github?.checks)) {
436
+ attempt.independent_audit.reviewed_checks = structuredClone(previousEvidence.github.checks);
437
+ }
401
438
  attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
439
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
402
440
  attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm, "full");
441
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
442
+ if (retryCiReview && attempt.observed_evidence?.local?.available && attempt.observed_evidence?.github?.available &&
443
+ independentAuditEvidenceIdentity(attempt) !== attempt.ci_review_refresh_identity) {
444
+ attempt.ci_review_refresh_pending = false;
445
+ snapshot.final_audit = humanGate("The candidate changed while its CI review was interrupted.", [{
446
+ code: "CI_REVIEW_CANDIDATE_CHANGED", severity: "blocker", target: "independent_audit",
447
+ explanation: "Reconcile the changed HEAD, base or evidence, then explicitly continue work; the prior review cannot certify this candidate.", repairability: "manual",
448
+ }]);
449
+ await publish(snapshot, "waiting_human", onUpdate);
450
+ return snapshot;
451
+ }
452
+ const retryIdentity = retryCiReview && attempt.ci_review_refresh_pending &&
453
+ independentAuditEvidenceIdentity(attempt) === attempt.ci_review_refresh_identity &&
454
+ attempt.observed_evidence?.local?.available && attempt.observed_evidence.local.working_tree_clean &&
455
+ !attempt.observed_evidence.local.errors?.length && attempt.observed_evidence?.github?.available &&
456
+ !attempt.observed_evidence.github.error && attempt.observed_evidence.github.checks_passing &&
457
+ attempt.observed_evidence.github.required_checks_present ? attempt.ci_review_refresh_identity : null;
458
+ const refreshIdentity = retryIdentity || ciReviewRefreshIdentity(attempt, previousEvidence);
459
+ if (executeIndependentAudit && refreshIdentity && (retryIdentity || attempt.ci_review_refresh_identity !== refreshIdentity)) {
460
+ const budget = canStartProvider(snapshot, { attemptNumber: attempt.attempt_number, phase: "reviewer" });
461
+ if (!budget.allowed) { await stopForBudget({ snapshot, attempt, reason: budget.reason, onUpdate }); return snapshot; }
462
+ // Persist before dispatch so an interrupted/failed review cannot repeat on
463
+ // every monitor tick. Explicit resume retries only this bound review.
464
+ attempt.ci_review_refresh_identity = refreshIdentity;
465
+ attempt.ci_review_refresh_pending = true;
466
+ appendEvent(snapshot, "independent_auditing", "CI completed; refreshing independent review of the same candidate.", {
467
+ attempt_number: attempt.attempt_number, evidence_identity: refreshIdentity,
468
+ });
469
+ await publish(snapshot, "independent_auditing", onUpdate);
470
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
471
+ if (await reviewAttempt({ snapshot, attempt, executeIndependentAudit, shouldStop, onUpdate })) return snapshot;
472
+ if (!attempt.independent_audit.passed &&
473
+ !attempt.independent_audit.findings.some(finding => finding.code === "INDEPENDENT_AUDIT_UNAVAILABLE")) {
474
+ // A completed adverse review belongs to the normal repair path. Only
475
+ // unavailable/interrupted reviews need a reviewer-only retry.
476
+ attempt.ci_review_refresh_pending = false;
477
+ }
478
+ attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
479
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
480
+ attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm, "full");
481
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
482
+ }
403
483
  attempt.audit = await auditAttempt(snapshot.charter, attempt, {
404
484
  previousAttempt: snapshot.attempts.at(-2) ?? null,
405
485
  sqm: attempt.sqm,
406
486
  });
487
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
407
488
  attempt.outcome_progress = assessOutcomeProgress(
408
489
  snapshot.charter, attempt, snapshot.attempts.at(-2) ?? null,
409
490
  outcomeOptions(snapshot),
410
491
  );
492
+ if (attempt.ci_review_refresh_pending && attempt.audit.passed && attempt.independent_audit?.passed &&
493
+ attempt.independent_audit.evidence_identity_sha256 === independentAuditEvidenceIdentity(attempt)) {
494
+ attempt.ci_review_refresh_pending = false;
495
+ }
411
496
  snapshot.implementation_gate_passed = attempt.audit.passed;
412
497
  attempt.pr_readiness = evaluatePrReadiness(snapshot.charter, attempt);
413
498
  attempt.pr_gate = attempt.pr_readiness;
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+
6
+ export const PROVIDER_LINKS = Object.freeze(['codex', 'claude']);
7
+ const defaultRoot = () => process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
8
+ const writeQueues = new Map();
9
+ const defaults = () => Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, { connected: true, epoch: null, requiresConsent: false }]));
10
+ const disconnected = () => Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, { connected: false, epoch: null, requiresConsent: true, settingsError: true }]));
11
+ const validateProvider = provider => { if (!PROVIDER_LINKS.includes(provider)) throw new Error('Unsupported SIMY provider connection.'); };
12
+
13
+ async function readSettings(root) {
14
+ let payload;
15
+ try { payload = JSON.parse(await readFile(join(root, 'provider-links.json'), 'utf8')); }
16
+ catch (error) { if (error.code === 'ENOENT') return defaults(); throw error; }
17
+ if (payload?.schemaVersion !== 1 || !payload.providers || PROVIDER_LINKS.some(provider => {
18
+ const value = payload.providers[provider];
19
+ return !value || typeof value.connected !== 'boolean' || typeof value.requiresConsent !== 'boolean' ||
20
+ value.epoch !== null && (typeof value.epoch !== 'string' || !value.epoch || value.epoch.length > 128);
21
+ })) throw new Error('Invalid SIMY provider connection settings.');
22
+ return Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, {
23
+ connected: payload.providers[provider].connected,
24
+ epoch: payload.providers[provider].epoch,
25
+ requiresConsent: payload.providers[provider].requiresConsent,
26
+ }]));
27
+ }
28
+
29
+ // Existing installations stay linked until the user explicitly disconnects.
30
+ // An unreadable saved preference fails closed instead of restoring revoked consent.
31
+ export async function readProviderLinks(root = defaultRoot()) {
32
+ try { return await readSettings(root); } catch { return disconnected(); }
33
+ }
34
+
35
+ export async function providerLinkConnected(root = defaultRoot(), provider) {
36
+ validateProvider(provider);
37
+ return (await readProviderLinks(root))[provider].connected;
38
+ }
39
+
40
+ // Session APIs use a delivery-hooks root, while desktop/agent policy uses SIMY_HOME.
41
+ export async function requireProviderLink({ root = join(defaultRoot(), 'delivery-hooks'), provider, expectedEpoch } = {}) {
42
+ validateProvider(provider);
43
+ const link = (await readProviderLinks(dirname(resolve(root))))[provider];
44
+ if (!link.connected) {
45
+ throw Object.assign(new Error('Reconnect this provider to SIMY before using it.'), { code: 'provider_link_disconnected' });
46
+ }
47
+ if (expectedEpoch !== undefined && expectedEpoch !== link.epoch) {
48
+ throw Object.assign(new Error('SIMY provider connection changed. Select the conversation again.'), { code: 'provider_link_changed' });
49
+ }
50
+ return link;
51
+ }
52
+
53
+ export function writeProviderLink(root = defaultRoot(), provider, connected) {
54
+ validateProvider(provider);
55
+ if (typeof connected !== 'boolean') throw new Error('Choose whether this provider is connected to SIMY.');
56
+ const key = resolve(root);
57
+ const previous = writeQueues.get(key) || Promise.resolve();
58
+ const pending = previous.catch(() => {}).then(async () => {
59
+ const providers = await readSettings(root);
60
+ // Reconnection restores discovery only. Session execution consent is cleared
61
+ // separately and must be selected again, including after a host restart.
62
+ providers[provider] = { connected, epoch: randomUUID(), requiresConsent: true };
63
+ await mkdir(root, { recursive: true, mode: 0o700 });
64
+ const target = join(root, 'provider-links.json'), temporary = `${target}.${randomUUID()}.tmp`;
65
+ try {
66
+ await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, providers })}\n`, { mode: 0o600 });
67
+ await rename(temporary, target);
68
+ } finally { await rm(temporary, { force: true }); }
69
+ return providers;
70
+ });
71
+ writeQueues.set(key, pending);
72
+ void pending.finally(() => { if (writeQueues.get(key) === pending) writeQueues.delete(key); }).catch(() => {});
73
+ return pending;
74
+ }
package/src/runner.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  normalizeWorkflowStartIntent,
52
52
  } from "./workflow-observation-sender.js";
53
53
  import { saveCodingRunSnapshot } from "./coding-run-store.js";
54
- import { acquireDesktopProcessLock } from "./desktop-process-lock.js";
54
+ import { acquireDesktopProcessLock, PROCESS_CONFLICT_CODE } from "./desktop-process-lock.js";
55
55
  import {
56
56
  assertRecoveryActor, completedWorkerResult, recoveryActorBinding, refreshRecoveryAudit, refreshRecoveryBudgetAudit,
57
57
  } from "./coding-run-recovery.js";
@@ -924,7 +924,24 @@ async function cleanupLocalAttachments(run) {
924
924
  }
925
925
  }
926
926
 
927
- export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
927
+ export async function recheckLocalCodingRun(run, options = {}) {
928
+ if (!run?.snapshot?.attempts?.length) return run?.snapshot || null;
929
+ if (!["pr_ready_for_review", "merge_ready"].includes(run.status)) return run.snapshot;
930
+ if (run.operation || run.child || run.stopRequested) return run.snapshot;
931
+ const operation = (async () => {
932
+ assertRecoveryActor(run);
933
+ const lease = run.localStateRoot ? await acquireDesktopProcessLock(
934
+ path.join(run.localStateRoot, ".controllers", executionTextSha256(run.id)),
935
+ ) : null;
936
+ try { return await recheckLocalCodingRunOwned(run, options); }
937
+ finally { await lease?.release(); }
938
+ })();
939
+ run.operation = operation;
940
+ try { return await operation; }
941
+ finally { if (run.operation === operation) run.operation = null; }
942
+ }
943
+
944
+ async function recheckLocalCodingRunOwned(run, { collectEvidence, executeIndependentAudit, authorizeReview } = {}) {
928
945
  if (!run?.snapshot?.attempts?.length) return run?.snapshot || null;
929
946
  if (!["pr_ready_for_review", "merge_ready"].includes(run.status)) return run.snapshot;
930
947
  const repositoryPath = await resolveRepositoryPath(run.request);
@@ -956,6 +973,15 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
956
973
  await recheckPrReadiness({
957
974
  snapshot: run.snapshot,
958
975
  collectEvidence: evidenceCollector,
976
+ executeIndependentAudit: async context => {
977
+ await authorizeReview?.();
978
+ assertRecoveryActor(run);
979
+ if (run.stopRequested) throw new Error("Review cancelled by the local operator.");
980
+ return executeIndependentAudit ? executeIndependentAudit(context) : executeProcessAttempt({
981
+ ...context, repositoryPath, run, marker: "SIMY_AUDIT_JSON:",
982
+ });
983
+ },
984
+ shouldStop: () => run.stopRequested,
959
985
  checkSqm: sqmChecker,
960
986
  onUpdate: async (snapshot) => {
961
987
  run.snapshot = snapshot;
@@ -966,6 +992,8 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
966
992
  }
967
993
 
968
994
  export async function markCodingRunMonitorFailure(run, error) {
995
+ // A losing controller never owns the right to persist its stale snapshot.
996
+ if (run.stopRequested || run.status === "stopped" || error?.code === PROCESS_CONFLICT_CODE) return run.snapshot;
969
997
  const message = error instanceof Error ? error.message : String(error);
970
998
  run.snapshot.done_gate_passed = false;
971
999
  run.snapshot.final_audit = {
@@ -1026,6 +1054,7 @@ export async function executeProcessAttempt({
1026
1054
  model: phase === "reviewer" ? charter?.audit_model : charter?.model,
1027
1055
  reasoningEffort: phase === "reviewer" ? charter?.audit_reasoning_effort : charter?.reasoning_effort,
1028
1056
  });
1057
+ if (run.stopRequested) throw new Error("Execution cancelled by the local operator.");
1029
1058
  const startedAt = new Date().toISOString();
1030
1059
  const stdout = [];
1031
1060
  const stderr = [];
@@ -1,10 +1,27 @@
1
1
  import { continueSession, readSessionAutoContinue } from './index.js';
2
2
  import { readContinuationContext } from './continuation-analysis.js';
3
+ import { requireProviderLink } from '../provider-links.js';
4
+
5
+ function continuationFailureReason(error) {
6
+ // Provider exceptions can contain private paths/output. Only known codes
7
+ // select actionable UI copy; unrecognized failures retain a safe fallback.
8
+ const reasons = {
9
+ desktop_access_required: 'Sign in to an organization with SQM and Developer access before auto-continue can run.',
10
+ provider_sign_in_required: 'Sign in to the original AI provider, then retry auto-continue.',
11
+ unavailable: 'The local provider is unavailable. Open or configure it, then retry auto-continue.',
12
+ unsupported: 'Update the original AI provider to a compatible version, then retry auto-continue.',
13
+ process_exited: 'The original Claude process has exited. Return to the original conversation, then refresh.',
14
+ session_ended: 'This Claude session has ended. Return to the original conversation, then refresh.',
15
+ hook_missing: 'SIMY has not observed the original Claude session-start hook. Check the plugin and hook execution, then refresh.',
16
+ };
17
+ return Object.hasOwn(reasons, error?.code) ? reasons[error.code] : 'Could not start auto-continue. Check the original conversation and retry.';
18
+ }
3
19
 
4
20
  // Shared by explicit Continue and automatic enrollment. Analysis alone never
5
21
  // dispatches work: the exact current provider context is checked again here.
6
22
  export async function continueAnalyzedSession({ candidate, manager, root, settings = {}, signal,
7
23
  readContext = readContinuationContext, enroll = continueSession, beforeEnroll = () => {} }) {
24
+ const link = await requireProviderLink({ root, provider: candidate.provider, expectedEpoch: settings.expectedProviderEpoch });
8
25
  const recommendation = manager.requireRecommendation(candidate);
9
26
  if (candidate.isRunning || candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
10
27
  const context = await readContext(candidate, { signal });
@@ -14,8 +31,10 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
14
31
  }
15
32
  manager.requireRecommendation(candidate);
16
33
  await beforeEnroll(candidate);
34
+ await requireProviderLink({ root, provider: candidate.provider, expectedEpoch: link.epoch });
35
+ manager.requireRecommendation(candidate);
17
36
  if (signal?.aborted) throw new Error('Automatic continuation was cancelled before enrollment.');
18
- return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings });
37
+ return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings, expectedProviderEpoch: link.epoch });
19
38
  }
20
39
 
21
40
  // One pending enrollment per identity. A failed attempt is retried only after
@@ -54,6 +73,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
54
73
  errors.delete(key);
55
74
  const controller = new AbortController();
56
75
  const guard = async () => {
76
+ await requireProviderLink({ root, provider: candidate.provider });
57
77
  const setting = await readSetting({ root, provider: candidate.provider, session: candidate.session });
58
78
  if (closed || controller.signal.aborted || !setting.enabled || setting.epoch !== candidate.autoContinueEpoch || setting.hasRun) throw new Error('Automatic continuation was disabled or already enrolled.');
59
79
  return setting;
@@ -67,7 +87,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
67
87
  settings: { ...setting.settings, autoContinueEpoch: candidate.autoContinueEpoch } });
68
88
  }).catch(error => {
69
89
  if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
70
- errors.set(key, { epoch: candidate.autoContinueEpoch, reason: 'Could not start auto-continue. Check the original conversation and retry.' });
90
+ errors.set(key, { epoch: candidate.autoContinueEpoch, reason: continuationFailureReason(error) });
71
91
  }
72
92
  }).finally(() => { pending.delete(key); if (!closed) onChange(); });
73
93
  pending.set(key, { job, controller });
@@ -79,11 +99,20 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
79
99
  if (!closed && !pending.has(key)) { attempted.delete(key); errors.delete(key); }
80
100
  },
81
101
  project(candidate) {
102
+ if (candidate.autoContinue === true && candidate.isRunning !== true &&
103
+ ['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged') && candidate.analysis?.state === 'needs_user') return {
104
+ ...candidate, autoContinueStatus: 'needs_user', autoContinueReason: candidate.analysis.reason || 'Your decision is needed before automatic continuation can proceed.',
105
+ };
82
106
  const error = errors.get(`${candidate.provider}:${candidate.session}`);
83
107
  if (candidate.autoContinue === true && candidate.autoContinueEpoch === error?.epoch && candidate.capabilities?.canContinue === true &&
84
108
  !['no_action', 'needs_user', 'waiting', 'uncertain'].includes(candidate.analysis?.state)) return {
85
109
  ...candidate, autoContinueStatus: 'error', autoContinueReason: error.reason,
86
110
  };
111
+ if (candidate.autoContinue === true && !candidate.isRunning && candidate.capabilities?.canContinue === true) {
112
+ const statuses = { no_action: 'finished', needs_user: 'needs_user', waiting: 'waiting', uncertain: 'error', failed: 'error' };
113
+ const status = statuses[candidate.analysis?.state];
114
+ if (status && candidate.analysis?.reason) return { ...candidate, autoContinueStatus: status, autoContinueReason: candidate.analysis.reason };
115
+ }
87
116
  return candidate;
88
117
  },
89
118
  revoke(provider, session) { pending.get(`${provider}:${session}`)?.controller.abort(); },
@@ -12,7 +12,6 @@ const MAX_PROJECTS = 256;
12
12
  const MAX_TRANSCRIPTS = 4096;
13
13
  const MAX_PREFIX_BYTES = 256 * 1024;
14
14
  const MAX_LINES = 256;
15
- const MAX_FILE_BYTES = 64 * 1024 * 1024;
16
15
  // Metadata is observation only. Exact-session/context readers always open the
17
16
  // transcript again; cached values contain no conversation message bodies.
18
17
  const metadataCache = new Map();
@@ -99,7 +98,8 @@ async function latestCustomTitle(handle, size, id) {
99
98
  return title;
100
99
  }
101
100
 
102
- async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false) {
101
+ async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal) {
102
+ signal?.throwIfAborted();
103
103
  const id = fileName.slice(0, -'.jsonl'.length);
104
104
  if (!UUID.test(id)) return null;
105
105
  const project = join(root, projectName);
@@ -108,7 +108,7 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
108
108
  let handle;
109
109
  try {
110
110
  const before = await lstat(path);
111
- if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_FILE_BYTES) { metadataCache.delete(path); return null; }
111
+ if (!before.isFile() || before.isSymbolicLink()) { metadataCache.delete(path); return null; }
112
112
  if (!within(root, await realpath(path))) { metadataCache.delete(path); return null; }
113
113
  let key;
114
114
  if (cacheMetadata) {
@@ -141,10 +141,11 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
141
141
  }
142
142
  if (!metadata) return null;
143
143
  const candidate = candidateMetadata(id, metadata, opened.mtimeMs, observedAt);
144
- if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id);
144
+ signal?.throwIfAborted();
145
+ if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id, { signal });
145
146
  if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
146
147
  return candidate;
147
- } catch { metadataCache.delete(path); return null; }
148
+ } catch (error) { metadataCache.delete(path); if (signal?.aborted) throw error; return null; }
148
149
  finally { await handle?.close(); }
149
150
  }
150
151
 
@@ -174,7 +175,7 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
174
175
  const file = candidates[next++];
175
176
  try {
176
177
  const info = await lstat(join(project, file.name));
177
- if (info.isFile() && !info.isSymbolicLink() && info.size <= MAX_FILE_BYTES)
178
+ if (info.isFile() && !info.isSymbolicLink())
178
179
  descriptors.push({ projectName: entry.name, fileName: file.name,
179
180
  session: file.name.slice(0, -'.jsonl'.length), lastActivityAt: info.mtimeMs });
180
181
  } catch { /* A disappearing transcript does not stop discovery. */ }
@@ -185,13 +186,20 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
185
186
  const selected = session ? descriptors : recentSessionWindow(descriptors, {
186
187
  limit: maximum, priority: item => priorities.has(item.session),
187
188
  });
188
- for (const file of selected.slice(0, MAX_TRANSCRIPTS)) {
189
- if (signal?.aborted) return [];
190
- const candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics);
191
- if (!candidate) continue;
192
- const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
193
- if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
194
- }
189
+ const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
190
+ let nextFile = 0;
191
+ // Catalog prefixes are bounded independently of full transcript size. Use a
192
+ // small I/O pool; exact-context/statistics reads stay serial and identity-safe.
193
+ await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics ? 1 : 8, filesToInspect.length) }, async () => {
194
+ while (nextFile < filesToInspect.length && !signal?.aborted) {
195
+ const file = filesToInspect[nextFile++];
196
+ const candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal);
197
+ if (!candidate) continue;
198
+ const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
199
+ if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
200
+ }
201
+ }));
202
+ if (signal?.aborted) return [];
195
203
  const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
196
204
  return session ? sorted : recentSessionWindow(sorted, { limit: maximum, priority: item => priorities.has(item.session) });
197
205
  }
@@ -249,8 +257,10 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
249
257
  return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
250
258
  }
251
259
 
252
- export async function readClaudeStatistics(candidate, { home } = {}) {
260
+ export async function readClaudeStatistics(candidate, { home, signal } = {}) {
261
+ signal?.throwIfAborted();
253
262
  if (!UUID.test(candidate?.session || '')) return unavailableStatistics();
254
- const matches = (await collect({ home, session: candidate.session, includeStatistics: true })).filter(item => item.cwd === candidate.cwd);
263
+ const matches = (await collect({ home, session: candidate.session, includeStatistics: true, signal })).filter(item => item.cwd === candidate.cwd);
264
+ signal?.throwIfAborted();
255
265
  return matches.length === 1 ? matches[0].statistics : unavailableStatistics();
256
266
  }
@@ -15,7 +15,9 @@ const ANALYSIS_MAX_LINE = 1024 * 1024;
15
15
  const ANALYSIS_MAX_TURNS = 16;
16
16
  const ANALYSIS_PAGE_SIZE = 6;
17
17
  const ANALYSIS_MAX_WIRE_LINE = 8 * 1024 * 1024;
18
- const SOURCE_KINDS = ['cli', 'vscode', 'exec', 'appServer', 'unknown'];
18
+ // Non-interactive exec and unknown origins are not evidence of a human-created
19
+ // conversation. Explicit bindings remain visible through discoverSessions.
20
+ const SOURCE_KINDS = ['cli', 'vscode', 'appServer'];
19
21
 
20
22
  export class CodexAppServerError extends Error {
21
23
  constructor(code, message) {
@@ -190,7 +192,7 @@ export function codexThreadMetadata(thread) {
190
192
  };
191
193
  }
192
194
 
193
- export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
195
+ export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
194
196
  const sessions = [];
195
197
  const windowSize = Math.min(maxSessions, maxPages * pageSize);
196
198
  let cursor = null, listed = 0, pages = 0, batchSize = pageSize;
@@ -210,18 +212,39 @@ export async function listCodexSessions({ client, maxPages = 40, pageSize = 25,
210
212
  listed += threads.length; pages++;
211
213
  for (const thread of threads) {
212
214
  const metadata = codexThreadMetadata(thread);
213
- if (metadata) sessions.push(metadata);
215
+ if (metadata && SOURCE_KINDS.includes(metadata.source)) sessions.push(metadata);
214
216
  }
215
217
  cursor = response?.nextCursor || null;
216
218
  if (!cursor) break;
217
219
  }
220
+ const deadline = Date.now() + Math.max(1, Math.min(observationBudgetMs, 5000));
221
+ // A recent page can omit an explicitly supervised conversation, including
222
+ // a newly created desktop chat. Read its exact metadata without loading or
223
+ // resuming it; only an active rollout path proves that it is not archived.
224
+ if (!archived && prioritySessions.length) {
225
+ const recoveryDeadline = Math.min(deadline, Date.now() + Math.max(1, (deadline - Date.now()) / 3));
226
+ const listedIds = new Set(sessions.map(item => item.session));
227
+ const missing = [...new Set(prioritySessions)].filter(id => !listedIds.has(id));
228
+ let next = 0;
229
+ await Promise.all(Array.from({ length: Math.min(3, missing.length) }, async () => {
230
+ while (next < missing.length && Date.now() < recoveryDeadline) {
231
+ const id = missing[next++];
232
+ try {
233
+ const response = await client.request('thread/read', { threadId: id, includeTurns: false },
234
+ Math.max(1, Math.min(1000, recoveryDeadline - Date.now())));
235
+ const metadata = codexThreadMetadata(response?.thread);
236
+ if (metadata?.session === id && SOURCE_KINDS.includes(metadata.source) &&
237
+ codexArchivePathState(response.thread.path, id, codexHome) === false) sessions.push(metadata);
238
+ } catch { /* Missing or unverifiable metadata remains a retained hook record. */ }
239
+ }
240
+ }));
241
+ }
218
242
  if (inspectState) {
219
243
  // Status-only pages have no conversation text. A separate app-server often
220
244
  // reports notLoaded rather than the original writer's actual turn state.
221
245
  // Bound this metadata fan-out independently of the analysis worker pool.
222
246
  const priorities = new Set(prioritySessions);
223
247
  const ordered = [...sessions].sort((a, b) => Number(priorities.has(b.session)) - Number(priorities.has(a.session)) || (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
224
- const deadline = Date.now() + Math.max(1, Math.min(observationBudgetMs, 5000));
225
248
  let next = 0, unavailable = false;
226
249
  for (const metadata of sessions) metadata.goalReadState = 'unavailable';
227
250
  await Promise.all(Array.from({ length: Math.min(3, sessions.length) }, async () => {