@awak-app/simy-cli 0.7.29 → 0.7.32

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 (67) hide show
  1. package/README.md +88 -3
  2. package/package.json +2 -2
  3. package/src/agent.js +8 -2
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +1 -1
  6. package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
  7. package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +389 -51
  9. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
  10. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
  11. package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
  12. package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
  13. package/src/delivery-hooks/claude-workflow.md +3 -3
  14. package/src/delivery-hooks/command.js +8 -0
  15. package/src/delivery-hooks/desktop.js +31 -9
  16. package/src/delivery-hooks/supervisor.js +3 -2
  17. package/src/desktop-access.js +4 -1
  18. package/src/desktop-host.js +494 -72
  19. package/src/native-development-report-completion.js +26 -0
  20. package/src/native-development-reports.js +15 -5
  21. package/src/orchestrator/completion-contract.js +8 -2
  22. package/src/orchestrator/contract.js +3 -1
  23. package/src/orchestrator/instruction.js +1 -1
  24. package/src/runner.js +2 -1
  25. package/src/session-supervision/action-deadline.js +18 -1
  26. package/src/session-supervision/autonomous-upgrade.js +4 -2
  27. package/src/session-supervision/claude-desktop-archive.js +57 -0
  28. package/src/session-supervision/claude-desktop-native.js +50 -0
  29. package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
  30. package/src/session-supervision/claude-desktop-target.js +87 -0
  31. package/src/session-supervision/claude-owner-renewal.js +197 -0
  32. package/src/session-supervision/claude-sessions.js +143 -34
  33. package/src/session-supervision/codex-app-server.js +20 -2
  34. package/src/session-supervision/continuation-analysis.js +7 -7
  35. package/src/session-supervision/continuation-command.js +45 -0
  36. package/src/session-supervision/controller-store.js +48 -0
  37. package/src/session-supervision/conversation-presentation.js +241 -0
  38. package/src/session-supervision/decision-presentation.js +96 -35
  39. package/src/session-supervision/execution-context.js +39 -0
  40. package/src/session-supervision/index.js +36 -7
  41. package/src/session-supervision/manual-acceptance-command.js +30 -0
  42. package/src/session-supervision/notification-authority.js +9 -0
  43. package/src/session-supervision/notification-journal.js +40 -0
  44. package/src/session-supervision/operation-journal.js +42 -0
  45. package/src/session-supervision/owner-transport.js +146 -0
  46. package/src/session-supervision/purpose-recovery.js +10 -2
  47. package/src/session-supervision/remote-control.js +1 -1
  48. package/src/session-supervision/remote-entry.js +2 -0
  49. package/src/session-supervision/remote-errors.js +15 -0
  50. package/src/session-supervision/remote-hosts.js +344 -36
  51. package/src/session-supervision/remote-metadata.js +103 -0
  52. package/src/session-supervision/remote-session-list.js +2 -4
  53. package/src/session-supervision/session-archive-command.js +45 -0
  54. package/src/session-supervision/session-list.js +40 -4
  55. package/src/session-supervision/sqm-presentation.js +46 -3
  56. package/src/session-supervision/stop-notifications.js +42 -11
  57. package/src/session-supervision/work-state.js +106 -6
  58. package/src/sqm/agent-skill.js +3 -3
  59. package/src/sqm/claude-plugin.js +5 -2
  60. package/src/sqm/history-store.js +6 -0
  61. package/src/team-threads.js +4 -7
  62. package/src/update-detachment/coordinator.js +7 -1
  63. package/src/update-detachment/history-recovery.js +18 -0
  64. package/src/update-detachment/ledger-adapter.js +1 -0
  65. package/src/update-detachment/retained-paused.js +290 -0
  66. package/src/update-detachment/runtime.js +78 -15
  67. package/src/web-api.js +9 -0
@@ -21,7 +21,10 @@ export async function claudePluginFiles() {
21
21
  if (!entry.isDirectory()) continue;
22
22
  const relative = `skills/${entry.name}/SKILL.md`;
23
23
  const content = await readFile(path.join(SOURCE, relative), "utf8");
24
- files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
24
+ files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation")
25
+ .replace('For this Codex conversation, use its existing `CODEX_THREAD_ID`:',
26
+ 'For this Claude Code conversation, use the provider-substituted `${CLAUDE_SESSION_ID}` below:')
27
+ .replace('--provider codex --session "$CODEX_THREAD_ID"', '--provider claude --session "${CLAUDE_SESSION_ID}"'));
25
28
  }
26
29
  // Codex's local delivery skill is retired. Keep the separately maintained
27
30
  // Claude workflow without shipping it back into Codex's skill inventory.
@@ -94,7 +97,7 @@ export async function claudePluginFiles() {
94
97
  for (const [name, contents] of [...files].sort(([a], [b]) => a.localeCompare(b))) hash.update(name).update("\0").update(contents);
95
98
  const manifest = {
96
99
  name: "simy", version: `${sourceManifest.version.split("+")[0]}+claude.${hash.digest("hex").slice(0, 20)}`,
97
- description: "SIMY quality checks, pull request review, and connection reminders for Claude Code.",
100
+ description: "SIMY Auto Continue through the original conversation controller, quality checks, and PR review for Claude Code.",
98
101
  author: sourceManifest.author, repository: sourceManifest.repository,
99
102
  license: sourceManifest.license, keywords: sourceManifest.keywords,
100
103
  };
@@ -112,6 +112,9 @@ function metadata(entry) {
112
112
  if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
113
113
  if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
114
114
  if (typeof entry.noApplicableChecks === 'boolean') item.noApplicableChecks = entry.noApplicableChecks;
115
+ if (Number.isSafeInteger(entry.checkCount) && entry.checkCount >= 0) item.checkCount = entry.checkCount;
116
+ if (['verified', 'unmet', 'unconfirmed'].includes(entry.inspectionStatus)) item.inspectionStatus = entry.inspectionStatus;
117
+ if (Number.isSafeInteger(entry.passedCheckCount) && entry.passedCheckCount >= 0 && entry.passedCheckCount <= item.checkCount) item.passedCheckCount = entry.passedCheckCount;
115
118
  if (Array.isArray(entry.checked)) item.checked = entry.checked.filter(value => typeof value === 'string').slice(0, 10).map(value => value.slice(0, 160));
116
119
  if (Number.isSafeInteger(entry.findings) && entry.findings >= 0) item.findings = entry.findings;
117
120
  const providerDiagnostic = safeProviderDiagnostic(entry.providerDiagnostic);
@@ -156,6 +159,9 @@ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, pr
156
159
  proofId: proof?.proof_id, proofDigest: proof?.integrity?.digest, evidenceId: result.signed_evidence?.evidence_id,
157
160
  checked: result.checked_descriptions || presentation.checked || [],
158
161
  noApplicableChecks: (proof?.outcome.reasons || minCheck.coverage_reasons || []).includes('no_applicable_rules'),
162
+ checkCount: proof?.inspection_scope?.checked_count,
163
+ inspectionStatus: proof?.inspection_scope?.status,
164
+ passedCheckCount: Array.isArray(proof?.inspection_scope?.checks) ? proof.inspection_scope.checks.filter(check => check.status === 'passed').length : undefined,
159
165
  findings: (proof?.findings || result.preliminary_findings || minCheck.preliminary_findings || []).length, finishedAt: new Date().toISOString(), repository,
160
166
  label: `${phase === "min" ? "Min-check" : "Check"} · ${repository || basename(cwd)}`,
161
167
  instruction: [repository || basename(cwd), `Base: ${identity?.base_ref || baseBranch}`, identity?.head_commit ? `Head: ${identity.head_commit}` : ""].filter(Boolean).join("\n"),
@@ -1,10 +1,7 @@
1
+ import { publicImageUrl } from "./web-api.js";
1
2
  function profileAvatar(value) {
2
- if (typeof value !== "string" || value.length > 2048) return {};
3
- try {
4
- const url = new URL(value);
5
- if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return {};
6
- return { avatarUrl: url.toString() };
7
- } catch { return {}; }
3
+ const url = publicImageUrl(value);
4
+ return url ? { avatarUrl: url } : {};
8
5
  }
9
6
  import { createHash } from "node:crypto";
10
7
  import { sanitizedText } from "./session-supervision/conversation-context.js";
@@ -26,7 +23,7 @@ const invalid = () => {
26
23
  };
27
24
  export const teamThreadKey = (scope, candidate) =>
28
25
  digest(
29
- JSON.stringify([scope.fenceKey, candidate.provider, candidate.session]),
26
+ JSON.stringify(candidate.hostId && candidate.hostId !== 'local' ? [scope.fenceKey, candidate.hostId, candidate.provider, candidate.session] : [scope.fenceKey, candidate.provider, candidate.session]),
30
27
  );
31
28
  function projectSummary(row) {
32
29
  if (
@@ -1,3 +1,4 @@
1
+ import { verifyTransferredPreservationHealth } from './retained-paused.js';
1
2
  // PR3 owns sequencing only. Persistence/fencing and provider identity checks are
2
3
  // supplied by PR1/PR2; absence of either must never become permission to update.
3
4
  export function createUpdateCoordinator({ store, gate, boundary, reconcile, now = Date.now,
@@ -91,7 +92,12 @@ export function createUpdateCoordinator({ store, gate, boundary, reconcile, now
91
92
  // verification; it owns rollback. An ambiguous outcome stays fenced.
92
93
  await save('detached', 'updating');
93
94
  const result = await update();
94
- if (result?.connectionTransferred === true && result?.healthy === true) { state = 'connected'; phase = 'complete'; return snapshot(); }
95
+ if (result?.connectionTransferred === true && result?.healthy === true) {
96
+ const checkpoint=await store.read();
97
+ if(checkpoint?.schemaVersion===2 && !verifyTransferredPreservationHealth(checkpoint,result.preservationAcknowledgement))
98
+ throw new Error('Updated runtime did not acknowledge the retained checkpoint.');
99
+ state = 'connected'; phase = 'complete'; return snapshot();
100
+ }
95
101
  if (result?.healthy !== true) throw new Error('Updated runtime health was not verified.');
96
102
  return await reconcileConnection();
97
103
  } catch (error) {
@@ -31,6 +31,24 @@ export async function planDispatchHistoryRecovery({root}) {
31
31
  const auditRef=join(directory,randomUUID()+'.json'),bytes=JSON.stringify(audit,null,2)+'\n';await writeFile(auditRef,bytes,{flag:'wx',mode:0o600});
32
32
  return {auditRef,auditDigest:sha(bytes),revision:state.revision,generation:state.generation,results:items.filter(i=>i.disposition==='saved_terminal_result').length,blocked:items.filter(i=>i.disposition==='retain_blocked_unknown').length,replayAttempts:false};
33
33
  }
34
+ // The desktop host already owns the desktop process lock. Its in-app recovery
35
+ // runs the same audited plan and revision/generation CAS apply: saved results
36
+ // settle, unknown attempts stay quarantined per task, nothing is replayed.
37
+ export async function recoverDispatchHistoryInProcess({root}) {
38
+ root=await realpath(root);const plan=await planDispatchHistoryRecovery({root});
39
+ const result=reconcileDispatchHistory({root,auditRef:plan.auditRef,auditDigest:plan.auditDigest,expectedRevision:plan.revision,expectedGeneration:plan.generation});
40
+ return {...result,auditRef:plan.auditRef,auditDigest:plan.auditDigest};
41
+ }
42
+ // In-app Try again: reconnect first; only when unknown historical attempts are
43
+ // the remaining blocker, quarantine them through the audited recovery and
44
+ // reconnect once more. Any evidence change fails closed in recovery_required.
45
+ export async function reconnectWithHistoryRecovery({root,coordinator,eligible,recover=recoverDispatchHistoryInProcess}) {
46
+ await coordinator.reconnect();
47
+ if(coordinator.snapshot().state!=='recovery_required' || !eligible())return {historyRecovery:null,snapshot:coordinator.snapshot()};
48
+ const historyRecovery=await recover({root});
49
+ await coordinator.reconnect();
50
+ return {historyRecovery,snapshot:coordinator.snapshot()};
51
+ }
34
52
  export async function applyDispatchHistoryRecovery({root,auditRef,auditDigest}) {
35
53
  root=await realpath(root);const lock=await acquireDesktopProcessLock(root);
36
54
  try {const audit=JSON.parse(await readFile(auditRef,'utf8'));return reconcileDispatchHistory({root,auditRef,auditDigest,expectedRevision:audit.expectedRevision,expectedGeneration:audit.expectedGeneration});}
@@ -56,6 +56,7 @@ export function createLedgerCoordinatorAdapters({ root = detachmentRoot(), bound
56
56
  },
57
57
  },
58
58
  handoff: release,
59
+ ownsLease: () => releaseOwner !== null,
59
60
  boundary,
60
61
  reconcile,
61
62
  };
@@ -0,0 +1,290 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { acquireLock, digest } from './state-store.js';
5
+ import { listDispatchRegistrations } from './provenance.js';
6
+ import { readDispatchGate, unresolvedDispatchAttempts, DISPATCH_PROTOCOL } from './dispatch-gate.js';
7
+ import { requireProviderLink } from '../provider-links.js';
8
+ import { readClaudeSession, readClaudeBoundary, snapshotClaudeHumanCursor } from '../session-supervision/claude-sessions.js';
9
+ import { recordPath, updateRecord, processIdentity, stopMarkerPath, updateInvalidationPath,
10
+ hasPendingUserStop, hasPendingUpdateInvalidation } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
11
+
12
+ export const RETAINED_PAUSED_CAPABILITY = 'retained-paused-update-v1';
13
+ const terminal = new Set(['completed', 'reported_done', 'failed', 'succeeded']);
14
+ const fail = code => { throw Object.assign(new Error(`Retained update: ${code}`), { code }); };
15
+ const sha = bytes => createHash('sha256').update(bytes).digest('hex');
16
+ const key = row => `${row.provider}:${row.session}`;
17
+ const actorIdentity = actor => ({ account: actor?.account, device: actor?.device, origin: actor?.origin });
18
+ const fingerprint = info => [info.dev, info.ino, info.birthtimeNs, info.size, info.mtimeNs, info.ctimeNs].map(String).join(':');
19
+ function fileSnapshot(file, optional = false) {
20
+ try {
21
+ const before = lstatSync(file, { bigint: true });
22
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 4n * 1024n * 1024n || realpathSync(file) !== file) fail('source_unavailable');
23
+ const bytes = readFileSync(file), after = lstatSync(file, { bigint: true });
24
+ if (fingerprint(before) !== fingerprint(after)) fail('source_changed');
25
+ return { ref: file, sha256: sha(bytes), value: JSON.parse(bytes) };
26
+ } catch (error) { if (optional && error.code === 'ENOENT') return null; throw error; }
27
+ }
28
+ function marker(file) {
29
+ const snapshot = fileSnapshot(file, true);
30
+ return snapshot && { ref: snapshot.ref, sha256: snapshot.sha256 };
31
+ }
32
+ function stableTranscript(cursor) {
33
+ if (cursor?.version !== 2) fail('transcript_unavailable');
34
+ const info = lstatSync(cursor.path, { bigint: true });
35
+ if (!info.isFile() || info.isSymbolicLink() || realpathSync(cursor.path) !== cursor.path || info.birthtimeNs <= 0n ||
36
+ String(info.dev) !== cursor.dev || String(info.ino) !== cursor.ino || String(info.birthtimeNs) !== cursor.birthtimeNs ||
37
+ info.size !== BigInt(cursor.size)) fail('transcript_changed');
38
+ return { ref: cursor.path, metadata: fingerprint(info) };
39
+ }
40
+ function gateEvidence(gate) {
41
+ return digest([gate.generation, gate.attempts, gate.receipts, gate.recoveryFences, gate.writerCapabilities,
42
+ gate.hookOwners, gate.ownerRenewals, gate.taskFences]);
43
+ }
44
+ function requireEmptyIntake(root) {
45
+ try { if (readdirSync(join(root, 'update-detachment', 'intake')).length) fail('receipt_intake_pending'); }
46
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
47
+ }
48
+ function contextEvidence(context) {
49
+ for (const row of [...(context.runs || []), ...(context.localTasks || []), ...(context.directTasks || [])]) {
50
+ if (!['completed', 'failed', 'cancelled', 'stopped', 'succeeded', 'reported_done', 'paused', 'paused_by_user'].includes(row.status || row.state)) fail('execution_not_quiescent');
51
+ if (row.child || row.operation || row.pendingLedgerUpdate || row.ledgerUpdateRunning || row.worker_handle ||
52
+ ['queued', 'running', 'dispatching', 'coding', 'stopping'].includes(row.status)) fail('execution_not_quiescent');
53
+ }
54
+ return digest([actorIdentity(context.actor), context.runs || [], context.localTasks || [], context.directTasks || []]);
55
+ }
56
+ function eligible(record) {
57
+ const run = record?.run, m = run?.managed;
58
+ if (record?.provider !== 'claude' || record.schemaVersion !== 1 || record.autoContinue !== false || run?.state !== 'paused' ||
59
+ !run.id || !m?.taskId || !m.liveHookOwner?.identity || !Number.isSafeInteger(m.liveHookOwner.pid) || m.liveHookOwner.pid < 2 ||
60
+ record.providerArchived || m.executionUncertain || m.executionAttempt && m.executionAttempt.phase !== 'settled' ||
61
+ run.blocker || m.pendingApproval || m.nativeWait || run.providerRequest || m.resumeLease || m.liveWorkspaceLease ||
62
+ m.outcomeAudit?.lease || ['awaiting_human', 'prepared', 'gate_committed'].includes(m.ownerRenewal?.status) ||
63
+ ['pending', 'pending_received', 'transferred'].includes(m.handoff?.status)) fail('record_not_retained_paused');
64
+ if (realpathSync(record.cwd) !== record.cwd) fail('cwd_changed');
65
+ }
66
+ function retainedFence(root, gate, record) {
67
+ const attempts = Object.entries(gate.attempts).filter(([, row]) =>
68
+ row.taskHash === digest(record.run.id) || row.taskHash === digest(record.run.managed.taskId));
69
+ const unknown = attempts.filter(([, row]) => row.phase !== 'confirmed');
70
+ // This narrow mode preserves an already audited historical quarantine. It
71
+ // cannot invent registration or create a new dispatch exception/fence.
72
+ if (!unknown.length || unknown.some(([id, row]) => gate.recoveryFences?.items?.[id]?.attemptDigest !== digest(row)))
73
+ fail('history_fence_required');
74
+ const auditRef = gate.recoveryFences.auditRef;
75
+ if (typeof auditRef !== 'string' || !auditRef.startsWith(join(root, 'update-detachment', 'recovery') + '/')) fail('history_audit_changed');
76
+ const audit = fileSnapshot(auditRef);
77
+ if (audit.sha256 !== gate.recoveryFences.auditDigest || audit.value.version !== 1 || !Array.isArray(audit.value.items) ||
78
+ unknown.some(([, row]) => !audit.value.items.some(item => item.attemptId === row.id &&
79
+ item.attemptDigest === digest(row) && item.disposition === 'retain_blocked_unknown'))) fail('history_audit_changed');
80
+ return { auditRef, auditDigest: gate.recoveryFences.auditDigest,
81
+ items: unknown.map(([id, row]) => ({ key: id, attemptDigest: digest(row) })) };
82
+ }
83
+ function validCheckpoint(checkpoint) {
84
+ return checkpoint?.capability === RETAINED_PAUSED_CAPABILITY && typeof checkpoint.operationId === 'string' &&
85
+ checkpoint.operationId.length > 0 && checkpoint.payload !== null && typeof checkpoint.payload === 'object' &&
86
+ !Array.isArray(checkpoint.payload) && Array.isArray(checkpoint.payload.inventory) &&
87
+ Array.isArray(checkpoint.payload.records) && Number.isSafeInteger(checkpoint.payload.generation) &&
88
+ checkpoint.payload.generation >= 0 && checkpoint.digest === digest(checkpoint.payload);
89
+ }
90
+ export function retainedCheckpointChallenge(transaction) {
91
+ const checkpoint = transaction?.retainedCheckpoint;
92
+ if (transaction?.schemaVersion !== 2 || !validCheckpoint(checkpoint) ||
93
+ checkpoint.operationId !== transaction.operationId) fail('checkpoint_invalid');
94
+ return { kind: 'retained_checkpoint_challenge', capability: RETAINED_PAUSED_CAPABILITY, schemaVersion: 2,
95
+ operationId: transaction.operationId, checkpointDigest: checkpoint.digest,
96
+ inventoryDigest: digest(checkpoint.payload.inventory), generation: checkpoint.payload.generation };
97
+ }
98
+ function exactDescriptor(expected, actual) {
99
+ return actual !== null && typeof actual === 'object' && !Array.isArray(actual) &&
100
+ Object.keys(actual).length === Object.keys(expected).length &&
101
+ Object.keys(expected).every(name => actual[name] === expected[name]);
102
+ }
103
+ export function matchesRetainedCheckpointChallenge(transaction, challenge) {
104
+ return exactDescriptor(retainedCheckpointChallenge(transaction), challenge);
105
+ }
106
+ function validVerifier(value) {
107
+ return Number.isSafeInteger(value?.pid) && value.pid > 1 && typeof value.identity === 'string' && value.identity.length > 0;
108
+ }
109
+ export function createPreservationVerification(transaction, gate, verifier) {
110
+ const challenge = retainedCheckpointChallenge(transaction), transfer = transaction.consumedRetainedTransfer;
111
+ if (gate.connection !== 'reconciling' || gate.generation !== challenge.generation || !validVerifier(verifier) ||
112
+ !Number.isSafeInteger(transaction.revision) || transaction.transferToken) fail('preservation_verification_unavailable');
113
+ if (transfer && (transfer.kind !== 'retained_transfer_consumed' || transfer.version !== 1 ||
114
+ !exactDescriptor(challenge, transfer.challenge) || !validVerifier(transfer.source) ||
115
+ transfer.source.pid === verifier.pid ||
116
+ !/^[a-f0-9]{64}$/.test(transfer.tokenDigest || '') || !validVerifier(transfer.candidate) ||
117
+ transfer.candidate.pid !== verifier.pid || transfer.candidate.identity !== verifier.identity ||
118
+ transaction.ownerPid !== verifier.pid || transaction.ownerIdentity !== verifier.identity)) fail('preservation_candidate_changed');
119
+ // This is preservation metadata, never a provider terminal/execution receipt.
120
+ return { kind: 'retained_preservation_verification', version: 1, id: randomUUID(), challenge,
121
+ verifier: { ...verifier }, transferDigest: transfer ? digest(transfer) : null,
122
+ committedRevision: transaction.revision + 1 };
123
+ }
124
+ export function retainedPreservationHealth(transaction, gate) {
125
+ const challenge = retainedCheckpointChallenge(transaction), receipt = transaction.preservationVerification;
126
+ if (transaction.connectionState !== 'connected' || transaction.phase !== 'complete' || gate?.connection !== 'connected' ||
127
+ gate.generation !== challenge.generation || transaction.transferToken ||
128
+ receipt?.kind !== 'retained_preservation_verification' || receipt.version !== 1 ||
129
+ typeof receipt.id !== 'string' || !receipt.id || !exactDescriptor(challenge, receipt.challenge) ||
130
+ !validVerifier(receipt.verifier) || receipt.verifier.pid !== transaction.ownerPid ||
131
+ receipt.verifier.identity !== transaction.ownerIdentity || !Number.isSafeInteger(receipt.committedRevision) ||
132
+ receipt.committedRevision < 1 || receipt.committedRevision > transaction.revision) fail('preservation_health_unverified');
133
+ const transfer = transaction.consumedRetainedTransfer;
134
+ if (transfer ? transfer.kind !== 'retained_transfer_consumed' || transfer.version !== 1 ||
135
+ !exactDescriptor(challenge, transfer.challenge) || !validVerifier(transfer.source) ||
136
+ transfer.source.pid === receipt.verifier.pid ||
137
+ !/^[a-f0-9]{64}$/.test(transfer.tokenDigest || '') || transfer.candidate?.pid !== receipt.verifier.pid ||
138
+ transfer.candidate.identity !== receipt.verifier.identity || receipt.transferDigest !== digest(transfer)
139
+ : receipt.transferDigest !== null) fail('preservation_health_unverified');
140
+ return { ...challenge, kind: 'retained_preservation_health', verificationId: receipt.id,
141
+ verificationDigest: digest(receipt), verifierPid: receipt.verifier.pid, verifierIdentity: receipt.verifier.identity,
142
+ transferDigest: receipt.transferDigest };
143
+ }
144
+ export function verifyTransferredPreservationHealth(view, acknowledgement) {
145
+ try {
146
+ // These two fields are supplied only by runtime.store.read from its fresh
147
+ // durable transaction and actual ledger, not by a command/callback echo.
148
+ if (!view?.preservationGate || view.preservationTransactionConnection !== 'connected' ||
149
+ view.transferToken !== null || !view.consumedRetainedTransfer || view.updateState !== 'succeeded') return false;
150
+ const durable = { ...view, connectionState: view.preservationTransactionConnection };
151
+ return exactDescriptor(retainedPreservationHealth(durable, view.preservationGate), acknowledgement);
152
+ } catch { return false; }
153
+ }
154
+
155
+ // Host-owned maintenance dependencies only; no public command or bootstrap is
156
+ // wired here. Explicit mode never creates a registration, writer or execution.
157
+ export function createRetainedPausedPreserver({ root, origin, configuration, readContext }) {
158
+ const { authorize, inspectProcess = processIdentity, transcriptHome, expectedProviderEpoch } = configuration;
159
+ const expectedActor = structuredClone(configuration.expectedActor), targets = structuredClone(configuration.targets);
160
+ if (configuration.hostId !== 'local' || expectedActor?.origin !== origin || typeof authorize !== 'function' || !Object.hasOwn(configuration, 'expectedProviderEpoch') ||
161
+ !(expectedProviderEpoch === null || typeof expectedProviderEpoch === 'string' && expectedProviderEpoch.length > 0 && expectedProviderEpoch.length <= 128) ||
162
+ !expectedActor || !['account', 'device', 'origin'].every(name => typeof expectedActor[name] === 'string' && expectedActor[name]) ||
163
+ !Array.isArray(targets) || targets.length !== 1 || targets.some(row => row.provider !== 'claude' ||
164
+ !row.session || !row.runId || !row.taskId)) fail('maintenance_authority_required');
165
+ const deliveryRoot = join(root, 'delivery-hooks');
166
+ const hasTarget = record => targets.some(row => key(row) === key(record) && row.runId === record.run?.id && row.taskId === record.run?.managed?.taskId);
167
+ function inventory(ordinaryTasks = []) {
168
+ if (ordinaryTasks.length) fail('mixed_retention_unsupported');
169
+ let names;
170
+ try { names = readdirSync(deliveryRoot).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).sort(); }
171
+ catch (error) { if (error.code !== 'ENOENT') throw error; names = []; }
172
+ if (names.length > 512) fail('inventory_too_large');
173
+ let bytes = 0;
174
+ const values = names.map(name => {
175
+ const snapshot = fileSnapshot(join(deliveryRoot, name)), record = snapshot.value;
176
+ bytes += lstatSync(snapshot.ref).size;
177
+ if (bytes > 64 * 1024 * 1024 || record.schemaVersion !== 1 || !['codex', 'claude'].includes(record.provider) ||
178
+ typeof record.session !== 'string' || recordPath(deliveryRoot, record.provider, record.session) !== snapshot.ref) fail('inventory_invalid');
179
+ const classification = hasTarget(record) ? 'retained_paused' : !record.run ? 'observed' : terminal.has(record.run.state) ? 'terminal' : 'registered';
180
+ if (classification === 'registered') fail('mixed_retention_unsupported');
181
+ return { ref: snapshot.ref, sha256: snapshot.sha256, provider: record.provider, session: record.session,
182
+ runId: record.run?.id || null, taskId: record.run?.managed?.taskId || record.run?.id || null, classification };
183
+ });
184
+ if (targets.some(target => !values.some(row => key(row) === key(target) && row.runId === target.runId &&
185
+ row.taskId === target.taskId && row.classification === 'retained_paused'))) fail('target_changed');
186
+ return values;
187
+ }
188
+ async function authority() {
189
+ await authorize();
190
+ if (digest(actorIdentity(readContext().actor)) !== digest(expectedActor)) fail('actor_changed');
191
+ await requireProviderLink({ root: deliveryRoot, provider: 'claude', expectedEpoch: expectedProviderEpoch });
192
+ }
193
+ async function dead(owner) {
194
+ if (!Number.isSafeInteger(owner?.pid) || owner.pid < 2 || !owner.identity) fail('writer_unknown');
195
+ const actual = await inspectProcess(owner.pid);
196
+ if (actual === null || actual === undefined) fail('writer_unknown');
197
+ if (actual !== '') fail(actual === owner.identity ? 'writer_active' : 'writer_pid_reused');
198
+ }
199
+ async function writers(gate, records) {
200
+ for (const record of records) await dead(record.run.managed.liveHookOwner);
201
+ for (const writer of Object.values(gate.writerCapabilities || {})) {
202
+ if (writer.protocol !== DISPATCH_PROTOCOL || !writer.moduleDigest) fail('writer_unknown');
203
+ await dead({ pid: writer.pid, identity: writer.processIdentity });
204
+ }
205
+ }
206
+ function targetRecords(rows) {
207
+ return rows.filter(row => row.classification === 'retained_paused').map(row => {
208
+ const record = fileSnapshot(row.ref).value; eligible(record);
209
+ if (listDispatchRegistrations(root, { retained: true }).some(registration =>
210
+ registration.recordRef === row.ref && registration.taskId === record.run.managed.taskId)) fail('retained_registered_task');
211
+ if (hasPendingUserStop(deliveryRoot, record) || hasPendingUpdateInvalidation(deliveryRoot, record)) fail('native_boundary_changed');
212
+ return record;
213
+ });
214
+ }
215
+ function syncCheck(payload, ordinaryTasks) {
216
+ requireEmptyIntake(root);
217
+ const targets = payload.inventory.filter(row => row.classification === 'retained_paused');
218
+ if (payload.records.length !== targets.length || new Set(payload.records.map(row => row.recordRef)).size !== targets.length ||
219
+ targets.some(row => !payload.records.some(proof => proof.recordRef === row.ref && proof.recordDigest === row.sha256 &&
220
+ proof.runId === row.runId && proof.taskId === row.taskId && proof.canResume === false)) ||
221
+ digest(payload.actor) !== digest(expectedActor) || payload.providerEpoch !== expectedProviderEpoch ||
222
+ payload.generation !== readDispatchGate(root).generation) fail('checkpoint_invalid');
223
+ if (digest(inventory(ordinaryTasks)) !== digest(payload.inventory) || contextEvidence(readContext()) !== payload.contextEvidence ||
224
+ digest(actorIdentity(readContext().actor)) !== digest(expectedActor) ||
225
+ gateEvidence(readDispatchGate(root)) !== payload.gateEvidence ||
226
+ digest(marker(join(root, 'provider-links.json'))) !== digest(payload.providerLinkFile)) fail('checkpoint_changed');
227
+ for (const row of payload.records) {
228
+ const record = fileSnapshot(row.recordRef).value; eligible(record);
229
+ if (digest(retainedFence(root, readDispatchGate(root), record)) !== digest(row.historyFence) ||
230
+ hasPendingUserStop(deliveryRoot, record) || hasPendingUpdateInvalidation(deliveryRoot, record) ||
231
+ digest(marker(stopMarkerPath(deliveryRoot, record.provider, record.session))) !== digest(row.stopMarker) ||
232
+ digest(marker(updateInvalidationPath(deliveryRoot, record.provider, record.session))) !== digest(row.nativeMarker) ||
233
+ digest(stableTranscript(row.cursor)) !== digest(row.transcript)) fail('native_boundary_changed');
234
+ }
235
+ }
236
+ async function prepare(ordinaryTasks, operationId) {
237
+ await authority();
238
+ const gate = readDispatchGate(root);
239
+ if (!['detaching', 'reconciling'].includes(gate.connection) || unresolvedDispatchAttempts(gate).length) fail('history_unresolved');
240
+ requireEmptyIntake(root);
241
+ const rows = inventory(ordinaryTasks), records = targetRecords(rows);
242
+ const payload = { actor: expectedActor, providerEpoch: expectedProviderEpoch, generation: gate.generation, inventory: rows,
243
+ contextEvidence: contextEvidence(readContext()), providerLinkFile: marker(join(root, 'provider-links.json')),
244
+ gateEvidence: gateEvidence(gate), records: [] };
245
+ await writers(gate, records);
246
+ for (const record of records) {
247
+ const historyFence = retainedFence(root, gate, record);
248
+ const metadata = await readClaudeSession({ session: record.session, cwd: record.cwd }, { home: transcriptHome });
249
+ const cursor = await snapshotClaudeHumanCursor({ session: record.session, cwd: record.cwd }, { home: transcriptHome });
250
+ if (!metadata || !cursor) fail('transcript_unavailable');
251
+ const transcript = stableTranscript(cursor), boundary = await readClaudeBoundary(metadata, { home: transcriptHome });
252
+ if (!boundary?.complete || boundary.digest !== cursor.prefixSHA256 || boundary.byteLength !== cursor.size ||
253
+ digest(stableTranscript(cursor)) !== digest(transcript)) fail('transcript_changed');
254
+ payload.records.push({ title: record.title || record.provider, recordRef: recordPath(deliveryRoot, record.provider, record.session),
255
+ runId: record.run.id, taskId: record.run.managed.taskId, owner: record.run.managed.liveHookOwner,
256
+ recordDigest: rows.find(row => key(row) === key(record)).sha256,
257
+ budgetDigest: digest([record.run.continuations, record.run.failures, record.run.deadline, record.run.managed.usage]),
258
+ grantsDigest: digest([record.run.managed.grants, record.run.managed.approvalScopes, record.run.managed.acceptanceCriteria]),
259
+ historyFence, boundary, cursor, transcript, stopMarker: marker(stopMarkerPath(deliveryRoot, record.provider, record.session)),
260
+ nativeMarker: marker(updateInvalidationPath(deliveryRoot, record.provider, record.session)), canResume: false });
261
+ }
262
+ return { capability: RETAINED_PAUSED_CAPABILITY, operationId, payload, digest: digest(payload) };
263
+ }
264
+ async function withVerified(checkpoint, ordinaryTasks, commit) {
265
+ if (!validCheckpoint(checkpoint)) fail('checkpoint_invalid');
266
+ const gateRelease = acquireLock(join(root, 'update-detachment', 'gate.json'));
267
+ try {
268
+ const lock = async index => {
269
+ if (index === checkpoint.payload.inventory.length) {
270
+ await authority(); await writers(readDispatchGate(root), targetRecords(checkpoint.payload.inventory));
271
+ // All async authorization/link/process and record-lock awaits precede
272
+ // this final synchronous source/marker/inventory check and commit.
273
+ syncCheck(checkpoint.payload, ordinaryTasks);
274
+ return commit();
275
+ }
276
+ const row = checkpoint.payload.inventory[index];
277
+ let result;
278
+ await updateRecord(deliveryRoot, row.provider, row.session, async () => { result = await lock(index + 1); return null; }, { inspectProcess });
279
+ return result;
280
+ };
281
+ return await lock(0);
282
+ } finally { gateRelease(); }
283
+ }
284
+ return { hasTarget, prepare, withVerified, assertSupported: tasks => {
285
+ requireEmptyIntake(root); inventory(tasks); contextEvidence(readContext());
286
+ }, assertUnchanged: (checkpoint, tasks) => {
287
+ if (!validCheckpoint(checkpoint)) fail('checkpoint_invalid');
288
+ syncCheck(checkpoint.payload, tasks);
289
+ } };
290
+ }
@@ -1,3 +1,5 @@
1
+ import { createRetainedPausedPreserver, RETAINED_PAUSED_CAPABILITY, retainedCheckpointChallenge,
2
+ matchesRetainedCheckpointChallenge, createPreservationVerification, retainedPreservationHealth } from './retained-paused.js';
1
3
  import { readCodexPolicyContext } from './codex-context.js';
2
4
  import { mkdirSync, realpathSync } from 'node:fs';
3
5
  import { randomUUID } from 'node:crypto';
@@ -72,20 +74,31 @@ export async function observeOriginalProvider(record, { openClient = openCodexAp
72
74
  }
73
75
 
74
76
  export function createUpdateRuntime({ root, origin, readContext, listRecords, observe = observeOriginalProvider,
75
- worker = null, now=Date.now } = {}) {
77
+ worker = null, now=Date.now, retainedPaused = null } = {}) {
76
78
  mkdirSync(root,{recursive:true,mode:0o700});root=realpathSync(root); const file=join(root,'update-detachment','transaction.json');
77
- let transaction=readState(file), ownerTransferred=false;
79
+ const readTransaction=()=> {
80
+ const value=readState(file,{versions:retainedPaused?[1,2]:[1]});
81
+ if(value?.schemaVersion===2 && (value.preservationCapability!==RETAINED_PAUSED_CAPABILITY ||
82
+ typeof value.operationId!=='string' || !value.operationId || !Array.isArray(value.tasks)))throw failure('preservation_capability_required');
83
+ return value;
84
+ };
85
+ let transaction=readTransaction(), ownerTransferred=false, retainedVerified=false;
86
+ const retainer=retainedPaused?createRetainedPausedPreserver({root,origin,configuration:retainedPaused,readContext:()=>readContext?.() || {}}):null;
78
87
  const workerFor=identity=>worker || createLedgerExecutionWorker({identity});
79
88
  const context=()=>readContext?.() || {runs:[],directTasks:[],localTasks:[]};
80
89
  const write=patch=>withLock(file,()=> {
81
- const current=readState(file);
90
+ const current=readTransaction();
82
91
  if (transaction && current?.revision!==transaction.revision) throw failure('transaction_revision_changed');
83
- transaction={schemaVersion:1,operationId:randomUUID(),tasks:[],...current,...patch,revision:(current?.revision || 0)+1};
92
+ if (retainer && (current?.operationId!==transaction?.operationId || digest(current)!==digest(transaction)))
93
+ throw failure('transaction_revision_changed');
94
+ transaction={schemaVersion:1,operationId:randomUUID(),tasks:[],...current,...patch,...(retainer?{schemaVersion:2,preservationCapability:RETAINED_PAUSED_CAPABILITY}:{}),revision:(current?.revision || 0)+1};
84
95
  atomicSave(file,transaction);
85
96
  });
86
97
  let presented=null;
87
98
  const uiState = task => task.stop_requested ? 'stopped' : ['stopped','cancelled'].includes(task.status || task.state) ? 'stopped' : ['paused','paused_by_user'].includes(task.status || task.state) ? 'paused' : task.managed?.pendingApproval ? 'approval_wait' : ['running','queued','active','dispatching','coding'].includes(task.status || task.state) ? 'running' : 'needs_user';
88
- const rows=()=> (transaction?.connectionState==='detaching' ? transaction?.pendingTasks || [] : transaction?.tasks || []).map(task=>({title:task.title || 'Provider task',taskState:task.canResume===false && !['paused','paused_by_user','cancelled','stopped','approval_wait'].includes(task.uiState)?'needs_user':['paused_by_user','cancelled'].includes(task.uiState)?'paused':task.uiState || 'needs_user',
99
+ const rows=()=> (transaction?.connectionState==='detaching' ? transaction?.pendingTasks || [] : [
100
+ ...(transaction?.tasks || []),...(transaction?.retainedCheckpoint?.payload?.records || []).map(record=>({
101
+ title:record.title,uiState:'paused',canResume:false}))]).map(task=>({title:task.title || 'Provider task',taskState:task.canResume===false && !['paused','paused_by_user','cancelled','stopped','approval_wait'].includes(task.uiState)?'needs_user':['paused_by_user','cancelled'].includes(task.uiState)?'paused':task.uiState || 'needs_user',
89
102
  ...(task.canResume===false?{canResume:false,reason:'Open the original conversation to review and continue.'}:{}),
90
103
  disposition:task.workerPreserved?'continuing':transaction.connectionState==='detaching'?'waiting_for_boundary':'saved',executionPreserved:task.workerPreserved===true}));
91
104
  const pendingTasks=async()=> {
@@ -109,7 +122,13 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
109
122
  }
110
123
  return writers;
111
124
  }});
112
- const store={read:async()=>({...await adapters.store.read(),...transaction,connectionState:readDispatchGate(root).connection}),
125
+ const store={read:async()=> {
126
+ if(ownerTransferred)transaction=readTransaction();
127
+ const ledger=await adapters.store.read();
128
+ return {...ledger,...transaction,connectionState:ledger.connection,
129
+ ...(retainer?{preservationGate:{connection:ledger.connection,generation:ledger.generation},
130
+ preservationTransactionConnection:transaction?.connectionState}:{})};
131
+ },
113
132
  write:async patch=>{write({...patch,...(patch.connectionState==='detaching'?{pendingTasks:await pendingTasks()}:{}),...(patch.connectionState==='connected' && transaction?.updateState==='applying'?{updateState:'succeeded'}:patch.connectionState==='recovery_required' && transaction?.updateState==='applying'?{updateState:'failed'}:{})});await adapters.store.write(patch);}};
114
133
  const records=async()=> (await listRecords()).filter(record=>record.run);
115
134
  const currentRecord=async record=>(await records()).find(item=>item.provider===record.provider && item.session===record.session);
@@ -131,7 +150,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
131
150
  // Closed records remain in their original durable storage. They have no
132
151
  // live writer to reconnect and may legitimately outlive their worktree.
133
152
  // The global receipt check below still rejects every unresolved attempt.
134
- if(terminal.has(record.run.state))continue;
153
+ if(terminal.has(record.run.state) || retainer?.hasTarget(record))continue;
135
154
  const binding=await bind(record), provider=await observe(record,{readCurrentRecord:()=>currentRecord(record),readInvalidation:current=>hasPendingUpdateInvalidation(join(root,'delivery-hooks'),current)});
136
155
  if(provider.identity.session!==binding.session || provider.identity.worktree!==await realpath(record.cwd)) throw failure('provider_identity_changed');
137
156
  if(provider.running) return null;
@@ -146,6 +165,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
146
165
  const boundary=async()=> {
147
166
  const startingGate=readDispatchGate(root);
148
167
  if(startingGate.connection!=='detaching')return {safe:false,liveExecutions:0,pendingWrites:0,unsettledPermissions:0};
168
+ retainer?.assertSupported([]);
149
169
  const c=context(), execution=[...(c.runs||[]),...(c.localTasks||[])];
150
170
  const unowned=execution.filter(run=>run.child || run.operation || run.pendingLedgerUpdate || run.ledgerUpdateRunning || ['queued','running','dispatching','coding'].includes(run.status));
151
171
  const workers=[];
@@ -166,6 +186,9 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
166
186
  const pendingWrites=execution.filter(run=>run.pendingLedgerUpdate||run.ledgerUpdateRunning).length;
167
187
  if(unowned.length || pendingWrites) return {safe:false,liveExecutions:unowned.length,pendingWrites,unsettledPermissions:0};
168
188
  const tasks=await capture(); if(!tasks) return {safe:false,liveExecutions:1,pendingWrites:0,unsettledPermissions:0};
189
+ // Capture contains awaits. Recheck the conservative subset before any
190
+ // ordinary receipt confirmation, policy fence, or checkpoint side effect.
191
+ retainer?.assertSupported(tasks);
169
192
  flushDispatchIntake(root);
170
193
  let ledger=readDispatchGate(root);
171
194
  for(const attempt of Object.values(ledger.attempts)) {
@@ -188,10 +211,17 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
188
211
  if(currentGate.connection!=='detaching' || currentGate.generation!==startingGate.generation)throw failure('boundary_owner_changed');
189
212
  for(const task of tasks)if(task.canResume===false)await adapters.gate.fenceTask({identity:task.identity,taskId:task.taskId,
190
213
  reason:{code:task.policy.model?'provider_policy_unknown':'provider_model_unknown',evidenceRef:task.recordRef}});
191
- write({tasks:[...tasks,...workers]});
214
+ if(retainer) {
215
+ if(!adapters.ownsLease())throw failure('coordinator_ownership_required');
216
+ const retainedCheckpoint=await retainer.prepare(tasks,transaction.operationId);
217
+ await retainer.withVerified(retainedCheckpoint,tasks,()=>write({tasks:[...tasks,...workers],retainedCheckpoint,
218
+ preservationVerification:null,consumedRetainedTransfer:null,transferToken:null}));
219
+ retainedVerified=false;
220
+ } else write({tasks:[...tasks,...workers]});
192
221
  return {safe:true,liveExecutions:workers.filter(task=>task.workerPreserved).length,workerHandoffVerified:true,pendingWrites:0,unsettledPermissions:0};
193
222
  };
194
223
  const reconcile=async()=> {
224
+ retainer?.assertSupported(transaction?.tasks || []);
195
225
  const actor=context().actor, assessments=[];
196
226
  const workers=(transaction?.tasks || []).filter(task=>task.handle);
197
227
  if(unresolvedDispatchAttempts(readDispatchGate(root)).some(attempt=> !workers.some(task=>task.handle.attemptId===attempt.id)))return {verified:false,unresolvedAttempts:1};
@@ -255,15 +285,37 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
255
285
  // Verified original hook lifetime, no cold process takeover.
256
286
  adoptHookOwner({root,provider:task.identity.provider,session:task.identity.session,expectedRevision:readDispatchGate(root).revision});
257
287
  }
288
+ if(retainer) {
289
+ if(!adapters.ownsLease() || !transaction?.retainedCheckpoint)throw failure('retained_checkpoint_required');
290
+ const verifier={pid:process.pid,identity:await processIdentity(process.pid)};
291
+ if(!verifier.identity)throw failure('preservation_verifier_unavailable');
292
+ await retainer.withVerified(transaction.retainedCheckpoint,transaction.tasks,()=>{
293
+ const preservationVerification=createPreservationVerification(transaction,readDispatchGate(root),verifier);
294
+ write({preservationVerification,ownerPid:verifier.pid,ownerIdentity:verifier.identity});
295
+ retainedVerified=true;
296
+ });
297
+ assessments.push({status:'retained_paused',canResume:false,reasons:['original_owner_unverified','historical_effects_quarantined']});
298
+ }
258
299
  return {verified:true,unresolvedAttempts:0,assessments};
259
300
  };
260
301
  return {bind,boundary,reconcile,store,gate:adapters.gate,
302
+ // Only unknown historical attempts block reconnect: live worker handoffs
303
+ // reconcile through their own proof and are never quarantined here.
304
+ historyRecoveryEligible() {
305
+ const gate=readDispatchGate(root);
306
+ return gate.connection==='recovery_required' && unresolvedDispatchAttempts(gate).length>0 && !(transaction?.tasks || []).some(task=>task.handle);
307
+ },
261
308
  async initialize() {if(!transaction)write({connectionState:readDispatchGate(root).connection,phase:'startup',updateState:'pending'});},
262
309
  snapshot(base,update,{accessReady=true}={}) {
263
- if(ownerTransferred)transaction=readState(file);
310
+ if(ownerTransferred)transaction=readTransaction();
264
311
  const action=base.state==='connected' && update?.state==='waiting_for_idle'?'detach_update':base.state==='detaching'?'cancel_detach_update':!base.busy && ['detached','recovery_required'].includes(base.state)?'update_reconnect':null;
265
312
  const frame={...base,schemaVersion:1,connectionState:base.state,
266
313
  updateState:transaction?.updateState || 'pending',updaterState:update?.state || 'unknown',tasks:rows(),actions:action?[action]:[]};
314
+ if(retainer && retainedVerified && base.state==='connected' && transaction?.connectionState==='connected' &&
315
+ readDispatchGate(root).connection==='connected') {
316
+ retainer.assertUnchanged(transaction.retainedCheckpoint,transaction.tasks);
317
+ frame.preservationAcknowledgement=retainedPreservationHealth(transaction,readDispatchGate(root));
318
+ }
267
319
  // Revision the exact frame the native client sees. Filtering after the
268
320
  // fingerprint makes an access change look like conflicting data at the
269
321
  // same revision, which correctly fences all native update actions.
@@ -283,7 +335,10 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
283
335
  if(gate.connection!=='detached' || transaction?.connectionState!=='detached')throw failure('handoff_not_detached');
284
336
  const token=randomUUID(),ownerIdentity=await processIdentity(process.pid);
285
337
  if(!ownerIdentity)throw failure('handoff_owner_unavailable');
286
- write({transferToken:token,ownerPid:process.pid,ownerIdentity,generation:gate.generation,updateState:'applying'});
338
+ const commit=()=>write({transferToken:token,ownerPid:process.pid,ownerIdentity,generation:gate.generation,updateState:'applying',
339
+ ...(retainer?{preservationVerification:null,consumedRetainedTransfer:null}: {})});
340
+ if(retainer)await retainer.withVerified(transaction.retainedCheckpoint,transaction.tasks,commit);
341
+ else commit();
287
342
  for(const task of context().directTasks || []) {
288
343
  if(transaction.tasks.some(saved=>saved.handle && saved.taskId===task.id)) {
289
344
  task.observation_suspended=true;task.update_owner_transferred=true;
@@ -291,25 +346,33 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
291
346
  }
292
347
  adapters.handoff();ownerTransferred=true;return token;
293
348
  },
294
- async acceptHandoff(token) {
295
- transaction=readState(file);
349
+ async acceptHandoff(token, preservation = null) {
350
+ transaction=readTransaction();
296
351
  const original=transaction;
352
+ if(retainer && !matchesRetainedCheckpointChallenge(transaction,preservation))throw failure('preservation_challenge_required');
353
+ if(retainer && original?.ownerPid===process.pid)throw failure('preservation_distinct_candidate_required');
297
354
  if(!original || original.transferToken!==token || original.generation!==readDispatchGate(root).generation || readDispatchGate(root).connection!=='detached' || original.connectionState!=='detached' || await processIdentity(original.ownerPid)!==original.ownerIdentity)throw failure('handoff_identity_changed');
298
355
  // Claim coordinator ownership before consuming the transfer token. Both
299
356
  // the durable revision and token are checked again after asynchronous IO.
300
357
  const ownerIdentity=await processIdentity(process.pid);
301
358
  if(!ownerIdentity)throw failure('handoff_owner_unavailable');
302
359
  await adapters.gate.close();
303
- write({ownerPid:process.pid,ownerIdentity,transferToken:null});
360
+ const commit=()=>write({ownerPid:process.pid,ownerIdentity,transferToken:null,...(retainer?{
361
+ preservationVerification:null,consumedRetainedTransfer:{kind:'retained_transfer_consumed',version:1,
362
+ tokenDigest:digest(token),challenge:retainedCheckpointChallenge(transaction),
363
+ source:{pid:original.ownerPid,identity:original.ownerIdentity},candidate:{pid:process.pid,identity:ownerIdentity}}}: {})});
364
+ if(retainer)await retainer.withVerified(transaction.retainedCheckpoint,transaction.tasks,commit);
365
+ else commit();
304
366
  },
305
367
  async reclaimFailedHandoff() {
306
- const current=readState(file);
368
+ const current=readTransaction();
307
369
  if(!ownerTransferred || !current)return;
308
370
  if(current.ownerPid!==process.pid && await processIdentity(current.ownerPid)===current.ownerIdentity)throw failure('candidate_owner_still_live');
309
371
  transaction=current;
310
372
  const ownerIdentity=await processIdentity(process.pid);
311
373
  if(!ownerIdentity)throw failure('handoff_owner_unavailable');
312
- write({transferToken:null,ownerPid:process.pid,ownerIdentity,updateState:'failed'});
374
+ write({transferToken:null,ownerPid:process.pid,ownerIdentity,updateState:'failed',
375
+ ...(retainer?{preservationVerification:null,consumedRetainedTransfer:null}: {})});
313
376
  for(const task of context().directTasks || []) {
314
377
  if(transaction.tasks.some(saved=>saved.handle && saved.taskId===task.id)) {
315
378
  task.observation_suspended=false;task.update_owner_transferred=false;
package/src/web-api.js CHANGED
@@ -6,6 +6,15 @@ export function webApiHeaders({ token } = {}, extraHeaders = {}) {
6
6
  return headers;
7
7
  }
8
8
 
9
+ // Public profile images never carry the CLI's application credentials.
10
+ export function publicImageUrl(value) {
11
+ if (typeof value !== "string" || value.length > 2048) return null;
12
+ try {
13
+ const url = new URL(value);
14
+ return url.protocol === "https:" && url.hostname && !url.username && !url.password ? url.toString() : null;
15
+ } catch { return null; }
16
+ }
17
+
9
18
  const APP_DEV_ORIGIN = "https://app-dev.simy.one";
10
19
  const APP_DEV_GATEWAY_BASE_URL = "https://app.simy.one/api/local-cli-gateway/";
11
20