@awak-app/simy-cli 0.7.10 → 0.7.12

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 +17 -1
  2. package/package.json +1 -1
  3. package/src/agent.js +47 -7
  4. package/src/auto-update.js +32 -9
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  7. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  8. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  9. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  10. package/src/delivery-hooks/desktop.js +1 -1
  11. package/src/delivery-hooks/supervisor.js +39 -10
  12. package/src/desktop-host.js +90 -15
  13. package/src/desktop-status.js +1 -1
  14. package/src/direct-executor.js +104 -4
  15. package/src/local-task.js +14 -3
  16. package/src/provider-stream.js +3 -0
  17. package/src/runner.js +19 -4
  18. package/src/session-supervision/claude-desktop-lineage.js +19 -5
  19. package/src/session-supervision/claude-sessions.js +73 -15
  20. package/src/session-supervision/codex-app-server.js +28 -9
  21. package/src/session-supervision/index.js +25 -14
  22. package/src/session-supervision/pull-requests.js +52 -0
  23. package/src/session-supervision/work-state.js +84 -0
  24. package/src/sqm/claude-plugin.js +5 -0
  25. package/src/sqm/review-worker.js +15 -6
  26. package/src/update-detachment/claude-observation.js +33 -0
  27. package/src/update-detachment/codex-context.js +27 -0
  28. package/src/update-detachment/coordinator.js +117 -0
  29. package/src/update-detachment/dispatch-gate.js +1 -0
  30. package/src/update-detachment/ledger-adapter.js +62 -0
  31. package/src/update-detachment/ledger-reader.js +42 -0
  32. package/src/update-detachment/provenance.js +1 -0
  33. package/src/update-detachment/provider-adapters.js +33 -0
  34. package/src/update-detachment/reconcile.js +105 -0
  35. package/src/update-detachment/reconciliation-adapter.js +13 -0
  36. package/src/update-detachment/runtime.js +319 -0
  37. package/src/update-detachment/state-store.js +1 -0
  38. package/src/update-detachment/worker-entry.js +145 -0
  39. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  40. package/src/update-detachment/worker-ledger.js +138 -0
  41. package/src/update-detachment/worker-storage.js +29 -0
  42. package/src/update-detachment/worker.js +167 -0
@@ -0,0 +1,52 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execute = promisify(execFile);
4
+ const PR_URL = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)\/?$/;
5
+ export function normalizePullRequests(value) {
6
+ const rows = Array.isArray(value) ? value : value ? [value] : [];
7
+ const unique = new Map();
8
+ for (const row of rows.slice(0, 32)) {
9
+ const url = typeof row === 'string' ? row : row?.url;
10
+ if (!PR_URL.test(url || '')) continue;
11
+ const state = String(row.state || row.status || '').toUpperCase();
12
+ unique.set(url, { url, state: row.merged === true || row.mergedAt ? 'MERGED' : ['OPEN', 'CLOSED', 'MERGED'].includes(state) ? state : 'UNKNOWN',
13
+ reviewDecision: ['CHANGES_REQUESTED', 'APPROVED', 'REVIEW_REQUIRED'].includes(row.reviewDecision) ? row.reviewDecision : null,
14
+ checkedAt: Number.isFinite(row.checkedAt) ? row.checkedAt : null });
15
+ }
16
+ return [...unique.values()];
17
+ }
18
+
19
+ // One cache per host, bounded concurrency, no network work on the 3s status tick.
20
+ export function createPullRequestObserver({ read = async url => {
21
+ const match = PR_URL.exec(url);
22
+ const { stdout } = await execute('gh', ['pr', 'view', match[3], '--repo', `${match[1]}/${match[2]}`, '--json', 'url,state,reviewDecision,mergedAt'], { timeout: 2000, maxBuffer: 64 * 1024, windowsHide: true });
23
+ return JSON.parse(stdout);
24
+ }, now = Date.now, ttlMs = 60_000, budgetMs = 3500 } = {}) {
25
+ const cache = new Map();
26
+ let cursor = 0;
27
+ return async (sessions, { signal } = {}) => {
28
+ const catalog = [...new Set(sessions.flatMap(item => (item.pullRequests || []).map(pr => pr.url)))];
29
+ // Continue after the last attempted URL, including when slow reads consume
30
+ // the budget. Restarting at row zero can starve the tail indefinitely.
31
+ const start = catalog.length ? cursor % catalog.length : 0;
32
+ const urls = [...catalog.slice(start), ...catalog.slice(0, start)].slice(0, 128);
33
+ let next = 0; const started = now();
34
+ await Promise.all(Array.from({ length: Math.min(4, urls.length) }, async () => {
35
+ while (next < urls.length && !signal?.aborted && now() - started < budgetMs) {
36
+ const url = urls[next++], previous = cache.get(url);
37
+ if (previous && now() - previous.at < ttlMs) continue;
38
+ try { const rows = normalizePullRequests(await read(url));
39
+ if (rows[0]?.url !== url) throw new Error('PR identity mismatch');
40
+ cache.set(url, { at: now(), value: { ...rows[0], checkedAt: now(), fetchFailed: false } });
41
+ } catch { cache.set(url, { at: now(), value: { ...(previous?.value || { url, state: 'UNKNOWN' }), fetchFailed: true } }); }
42
+ }
43
+ }));
44
+ cursor = catalog.length ? (start + next) % catalog.length : 0;
45
+ while (cache.size > 512) cache.delete(cache.keys().next().value);
46
+ return sessions.map(item => ({ ...item, pullRequests: (item.pullRequests || []).map(pr => {
47
+ const observed = cache.get(pr.url)?.value;
48
+ const value = observed ? { ...pr, ...observed, ...(observed.state === 'UNKNOWN' ? { state: pr.state } : {}) } : pr;
49
+ return { ...value, stale: !Number.isFinite(value.checkedAt) || now() - value.checkedAt > ttlMs };
50
+ }) }));
51
+ };
52
+ }
@@ -0,0 +1,84 @@
1
+ // Display-only state. Tracking/auto-continue consent is never evidence of execution.
2
+ export const WORK_STATE_TTL_MS = 45_000;
3
+ const freshObservation = (checkedAt, now) => Number.isFinite(checkedAt) && checkedAt <= now && now - checkedAt <= WORK_STATE_TTL_MS;
4
+ export function sessionWorkState(candidate, run, { now = Date.now(), provider } = {}) {
5
+ const checkedAt = candidate.observation?.observedAt ?? null;
6
+ const stale = !freshObservation(checkedAt, now);
7
+ const fetchFailed = provider?.discovery === 'unavailable';
8
+ const freshness = { checkedAt, stale, fetchFailed };
9
+ const result = (state, label, reason, nextAction = null, needsAttention = false) =>
10
+ ({ state, label, reason, nextAction, needsAttention, ...freshness });
11
+ if (candidate.archived || candidate.localHidden) return result('archived', 'アーカイブ済み', candidate.localHidden ? 'この一覧だけで非表示にしています。' : '元の会話がアーカイブされています。', 'restore');
12
+ let nativeRuntimeUnknown = false;
13
+ if (Object.hasOwn(candidate.observation || {}, 'activeFlags')) {
14
+ const flags = candidate.observation.activeFlags;
15
+ nativeRuntimeUnknown = stale || fetchFailed || !Array.isArray(flags) || flags.some(flag => !['waitingOnApproval', 'waitingOnUserInput'].includes(flag));
16
+ if (!nativeRuntimeUnknown && flags.includes('waitingOnApproval')) return result('approval', '承認待ち', '元のAIが操作の承認を待っています。', 'review_approval', true);
17
+ if (!nativeRuntimeUnknown && flags.includes('waitingOnUserInput')) return result('question', '回答待ち', '元のAIが回答を待っています。', 'answer', true);
18
+ }
19
+ // Never display a cached active flag as a live observation after a sleep/outage.
20
+ if (!nativeRuntimeUnknown && !stale && !fetchFailed && candidate.isRunning === true) return result('running', 'AIが対応中', '元のAIが実行中です。');
21
+ const approval = run?.pendingApproval || run?.managed?.pendingApproval;
22
+ const question = run?.understandingQuestion || run?.managed?.understandingQuestion || run?.managed?.userQuestion;
23
+ const phase = run?.phase || run?.managed?.phase;
24
+ const stopped = run?.state === 'stopped' || run?.stopReason?.actor === 'user' ||
25
+ run?.state === 'paused' && /^(Paused by user\.|Automatic continuation paused by user\.|User interrupted the provider\.|Stopped by user\.)$/.test(run.reason || '');
26
+ if (stopped) return result('stopped', '自分で停止', '停止の指示を維持しています。', 'resume');
27
+ if (approval && !approval.denied || candidate.decisionRequest?.kind === 'approval')
28
+ return result('approval', '承認待ち', candidate.decisionRequest?.context || '実行する操作の確認が必要です。', 'review_approval', true);
29
+ if (question || phase === 'needs_clarification' || candidate.analysis?.state === 'needs_user' || candidate.decisionRequest?.kind === 'question')
30
+ return result('question', '回答待ち', candidate.decisionRequest?.question || '続けるために回答が必要です。', 'answer', true);
31
+ if ((run?.autoRecovery || run?.managed?.autoRecovery) && ['recovery_required', 'auto_resuming'].includes(run.state)) return result('checking', 'AIが状況確認中', '自動復旧の結果を待っています。');
32
+ if (['failed', 'recovery_required', 'budget_exhausted'].includes(run?.state) || candidate.analysis?.state === 'failed' || candidate.autoContinueStatus === 'error')
33
+ return result('error', '対応が必要', run?.reason || '作業が中断されています。', run?.canResume || run?.canReplan ? 'resume' : 'open_original', true);
34
+ if (run?.dependencyWait || run?.managed?.dependencyWait) return result('waiting', '依存条件待ち', '条件が変わるまで待機します。');
35
+ if (['blocked', 'verification_pending', 'reported_done', 'paused'].includes(run?.state) || candidate.analysis?.state === 'continue' && candidate.autoContinue !== true || candidate.autoContinueStatus === 'needs_user')
36
+ return result('unfinished', '残りの作業あり', run?.reason || candidate.analysis?.reason || '未完了の作業を再開できます。', 'resume', true);
37
+ if (nativeRuntimeUnknown) return result('unknown', '実行状態を確認できません', '元のAIの最新の実行状態を確認できません。', 'refresh');
38
+ const prs = candidate.pullRequests || [];
39
+ if (prs.some(pr => pr.state === 'OPEN' && pr.reviewDecision === 'CHANGES_REQUESTED')) return result('changes_requested', 'PRの修正要求', '関連PRに修正要求があります。', 'open_pr', true);
40
+ if (prs.some(pr => pr.state === 'OPEN')) return result('review', 'PRレビュー待ち', '関連PRのレビュー・統合を待っています。', 'open_pr');
41
+ if (prs.some(pr => pr.state === 'UNKNOWN' || pr.fetchFailed || pr.stale)) return result('pr_unknown', 'PRの状態確認待ち', '関連PRの最新状態を確認できません。', 'refresh');
42
+ // A merge or no-follow-up recommendation is not task completion. no_action
43
+ // also covers casual chat, cancellation and optional future improvements.
44
+ if (run?.state === 'completed' || !run && candidate.providerGoal?.status === 'complete')
45
+ return result('completed', '完了', '会話の作業完了を確認しました。');
46
+ if (candidate.isRunning === true && (stale || fetchFailed)) return result('unknown', '実行状態を確認できません', '最終確認後の実行状態は不明です。', 'refresh');
47
+ if (run && ['active', 'auto_resuming'].includes(run.state) || ['queued', 'analyzing'].includes(candidate.analysis?.state)) return result('checking', 'AIの対応待ち', '次の処理・状態確認を待っています。');
48
+ return result('idle', '次の依頼待ち', '現在の実行や対応依頼は確認されていません。');
49
+ }
50
+
51
+ export function compareSessionWork(a, b) {
52
+ const priority = item => item.workState?.state === 'running' ? 0 : item.workState?.needsAttention ? 1 : 2;
53
+ return priority(a) - priority(b) || (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || `${a.provider}:${a.session}`.localeCompare(`${b.provider}:${b.session}`);
54
+ }
55
+
56
+ // Completion belongs to the whole known family. Idle history alone does not
57
+ // create a human task; positively unfinished descendants do prevent completion.
58
+ export function reconcileFamilyCompletion(sessions) {
59
+ const byKey = new Map(sessions.map(item => [`${item.provider}:${item.session}`, item]));
60
+ for (const child of sessions) {
61
+ if (['idle', 'completed', 'archived'].includes(child.workState?.state)) continue;
62
+ const seen = new Set(); let parent = child.parentSession;
63
+ while (parent) {
64
+ const key = `${parent.provider}:${parent.session}`;
65
+ if (seen.has(key)) break;
66
+ seen.add(key); const ancestor = byKey.get(key);
67
+ if (!ancestor) break;
68
+ if (ancestor.workState?.state === 'completed') ancestor.workState = { ...ancestor.workState,
69
+ state: 'family_unfinished', label: '子の作業が未完了', reason: '関連する子の作業が残っています。', nextAction: 'review_children', needsAttention: false };
70
+ parent = ancestor.parentSession;
71
+ }
72
+ }
73
+ return sessions;
74
+ }
75
+
76
+ // Fast connection heartbeats may carry the previous full projection while a
77
+ // reader is blocked. Expire live labels there too, without losing pending work.
78
+ export function expireSessionWorkState(candidate, now = Date.now()) {
79
+ const previous = candidate.workState;
80
+ if (!previous || freshObservation(previous.checkedAt, now)) return candidate;
81
+ if (previous.state !== 'running') return previous.stale ? candidate : { ...candidate, workState: { ...previous, stale: true } };
82
+ return { ...candidate, workState: { ...previous, state: 'unknown', label: '実行状態を確認できません',
83
+ reason: '最終確認後の実行状態は不明です。', nextAction: 'refresh', needsAttention: false, stale: true } };
84
+ }
@@ -45,11 +45,16 @@ export async function claudePluginFiles() {
45
45
  files.set("hooks/acceptance-policy.mjs", await readFile(path.join(SOURCE, "hooks/acceptance-policy.mjs"), "utf8"));
46
46
  files.set("hooks/purpose-rule-review.mjs", await readFile(path.join(SOURCE, "hooks/purpose-rule-review.mjs"), "utf8"));
47
47
  files.set("hooks/workspace-lease.mjs", await readFile(path.join(SOURCE, "hooks/workspace-lease.mjs"), "utf8"));
48
+ for (const name of ["update-state-store.mjs", "update-dispatch-gate.mjs", "update-dispatch-identity.mjs"]) files.set(`hooks/${name}`, await readFile(path.join(SOURCE, "hooks", name), "utf8"));
48
49
  files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
49
50
  files.set("hooks/dev-work.mjs", await readFile(path.join(SOURCE, "hooks/dev-work.mjs"), "utf8"));
50
51
  const hooks = JSON.parse(await readFile(path.join(SOURCE, "hooks/hooks.json"), "utf8"));
51
52
  delete hooks.hooks.Interrupt; // Codex-only event; Claude interruptions cannot be vetoed.
52
53
  hooks.hooks.StopFailure = structuredClone(hooks.hooks.Stop);
54
+ // Observe policy/model invalidation without emitting continuation decisions.
55
+ // Supported by the pinned 2.1.201 CLI. PostModelSwitch is rejected by its
56
+ // manifest validator; Stop observations therefore cannot retain an old model.
57
+ hooks.hooks.ConfigChange = structuredClone(hooks.hooks.SessionStart);
53
58
  hooks.hooks.Notification = structuredClone(hooks.hooks.PermissionRequest);
54
59
  for (const [event, groups] of Object.entries(hooks.hooks)) {
55
60
  for (const group of groups) {
@@ -431,6 +431,7 @@ async function runSingleProviderReview(
431
431
  request,
432
432
  {
433
433
  spawnImpl = spawn,
434
+ dispatchTransport = initiate => initiate(),
434
435
  inspectExecutor = inspectDesktopExecutor,
435
436
  executionTimeoutMs = DEFAULT_EXECUTION_TIMEOUT_MS,
436
437
  environment = process.env,
@@ -463,6 +464,10 @@ async function runSingleProviderReview(
463
464
  const workDir = await mkdtemp(join(tmpdir(), "simy-sqm-review-"));
464
465
  const schemaPath = join(workDir, "output-schema.json");
465
466
  const outputPath = join(workDir, "output.json");
467
+ let transportFailure;
468
+ const fencedTransport = initiate => {
469
+ try { return dispatchTransport(initiate); } catch (error) { transportFailure = error; throw error; }
470
+ };
466
471
  try {
467
472
  await writeFile(schemaPath, JSON.stringify(request.output_schema), { mode: 0o600 });
468
473
  const prompt = [
@@ -512,7 +517,7 @@ async function runSingleProviderReview(
512
517
  "-",
513
518
  ],
514
519
  prompt,
515
- { spawnImpl, executionTimeoutMs: remaining, stallTimeoutMs: provider === "codex" && readOnlyAnalysis && allowAutomaticRetry && !stallRetried && remaining > codexAttemptTimeoutMs ? codexAttemptTimeoutMs : null, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
520
+ { spawnImpl, dispatchTransport: fencedTransport, executionTimeoutMs: remaining, stallTimeoutMs: provider === "codex" && readOnlyAnalysis && allowAutomaticRetry && !stallRetried && remaining > codexAttemptTimeoutMs ? codexAttemptTimeoutMs : null, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
516
521
  terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000, onUsage, onContext },
517
522
  );
518
523
  selectedModel = model;
@@ -563,7 +568,7 @@ async function runSingleProviderReview(
563
568
  ...(processResult.diagnostic.retryAttempt === 1 ? { retryAttempt: 1 } : {}),
564
569
  };
565
570
  } catch (error) {
566
- if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required' || ['subscription_auth_required', 'subscription_auth_unknown'].includes(error?.code)) throw error;
571
+ if (error === transportFailure || error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required' || ['subscription_auth_required', 'subscription_auth_unknown'].includes(error?.code)) throw error;
567
572
  throw new SqmLocalReviewError(`Local ${label} review failed before producing a structured result.`, {
568
573
  code: `${provider}_local_review_failed`,
569
574
  });
@@ -585,15 +590,19 @@ function codexAnalysisArguments(system) {
585
590
  '-c', `developer_instructions=${JSON.stringify(system)}`];
586
591
  }
587
592
 
588
- function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, stallTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt, onUsage, onContext }) {
593
+ function spawnReview(command, args, prompt, { spawnImpl, dispatchTransport, executionTimeoutMs, stallTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt, onUsage, onContext }) {
589
594
  return new Promise((resolve, reject) => {
590
595
  if (signal?.aborted) return reject(providerFailure("analysis_cancelled"));
591
596
  let child;
592
597
  try {
593
- child = spawnImpl(command, args, {
594
- env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
598
+ child = dispatchTransport(() => {
599
+ try {
600
+ return spawnImpl(command, args, {
601
+ env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
602
+ });
603
+ } catch { throw providerFailure("provider_spawn_failed", { model }); }
595
604
  });
596
- } catch { reject(providerFailure("provider_spawn_failed", { model })); return; }
605
+ } catch (error) { reject(error); return; }
597
606
  let stdout = "", stderrPending = "", outputBytes = 0, phase = "before_turn";
598
607
  let settled = false, terminalError = null, killTimer = null;
599
608
  let reportedTokens = -1, reportedContext = null;
@@ -0,0 +1,33 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { readClaudeSession, readClaudeBoundary } from '../session-supervision/claude-sessions.js';
3
+ import { processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { digest } from './state-store.js';
5
+
6
+ const fail = code => { throw Object.assign(new Error(`Update connection: ${code}`), { code }); };
7
+
8
+ // This permits observation only. Unknown policy stays unknown, and callers must
9
+ // install a durable session/task dispatch fence before releasing the root gate.
10
+ export async function observeClaudeReadOnly(record, { inspectProcess = processIdentity,
11
+ readMetadata = readClaudeSession, readConversation = null, readBoundary = readClaudeBoundary } = {}) {
12
+ const owner = record.run?.managed?.liveHookOwner || record.process;
13
+ const hook = record.hookSessionProcess, runtime = record.runtime, boundary = record.updateObservation;
14
+ if (!owner?.identity || !record.hookStartedAt || record.hookEndedAt ||
15
+ hook?.pid !== owner.pid || hook.identity !== owner.identity || await inspectProcess(owner.pid) !== owner.identity)
16
+ fail('original_claude_owner_unavailable');
17
+ if (runtime?.process?.pid !== owner.pid || runtime.process.identity !== owner.identity || typeof runtime.isRunning !== 'boolean')
18
+ fail('claude_turn_boundary_unobserved');
19
+ if (runtime.isRunning) fail('claude_turn_running');
20
+ if (boundary?.process?.pid !== owner.pid || boundary.process.identity !== owner.identity || boundary.source !== 'Stop' ||
21
+ boundary.observedAt !== runtime.observedAt || boundary.observedAt < record.hookStartedAt || boundary.backgroundIdle !== true)
22
+ fail('claude_background_boundary_unobserved');
23
+ const metadata = await readMetadata(record.session);
24
+ if (metadata?.session !== record.session || await realpath(metadata.cwd) !== await realpath(record.cwd)) fail('provider_identity_changed');
25
+ const context = readConversation ? await readConversation(metadata) : await readBoundary(metadata);
26
+ if (readConversation ? !context || context.incomplete !== false || context.truncated !== false || !Array.isArray(context.messages) : !context || context.complete !== true || !/^[a-f0-9]{64}$/.test(context.digest)) fail('claude_history_coverage_unknown');
27
+ if (await inspectProcess(owner.pid) !== owner.identity) fail('original_claude_owner_unavailable');
28
+ return { identity: { session: metadata.session, worktree: await realpath(metadata.cwd) }, model: boundary.model || null,
29
+ permissions: null, permissionMode: boundary.permissionMode || null, canResume: false, readOnlyReconnect: true,
30
+ cursor: digest([context, metadata.lastActivityAt, boundary]), historyCoverage: true, running: false,
31
+ writer: { id: owner.identity, verified: false, state: 'idle', source: 'read_only_live_hook', acquisitionRequired: true },
32
+ liveHook: { pid: owner.pid, identity: owner.identity, startedAt: record.hookStartedAt, verified: true, source: 'original_live_hook' } };
33
+ }
@@ -0,0 +1,27 @@
1
+ import { open, realpath } from 'node:fs/promises';
2
+ import { resolve, relative, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { digest } from './state-store.js';
5
+
6
+ // Read only session metadata and the newest turn_context. Never read auth.json,
7
+ // copy prompts/tool output, write provider configuration or acquire a writer.
8
+ export async function readCodexPolicyContext(file, session, worktree, { codexHome = process.env.CODEX_HOME || join(homedir(),'.codex') } = {}) {
9
+ if(typeof file!=='string')throw new Error('Codex rollout reference unavailable.');
10
+ const [base,target]=await Promise.all([realpath(codexHome),realpath(file)]);
11
+ const rel=relative(base,target); if(rel.startsWith('..') || resolve(base,rel)!==target || !/^(sessions|archived_sessions)[/\\]/.test(rel))throw new Error('Codex rollout outside existing account.');
12
+ const handle=await open(target,'r');
13
+ try {
14
+ const before=await handle.stat(); const head=Buffer.alloc(Math.min(32768,before.size));
15
+ await handle.read(head,0,head.length,0);
16
+ const size=Math.min(2*1024*1024,before.size), tail=Buffer.alloc(size);
17
+ await handle.read(tail,0,size,before.size-size);
18
+ const parse=line=>{try{return JSON.parse(line);}catch{return null;}};
19
+ const meta=head.toString().split('\n').map(parse).find(item=>item?.type==='session_meta');
20
+ const contexts=tail.toString().split('\n').map(parse).filter(item=>item?.type==='turn_context');
21
+ const context=contexts.at(-1)?.payload;
22
+ const after=await handle.stat();
23
+ if(before.size!==after.size || before.mtimeMs!==after.mtimeMs || meta?.payload?.id!==session || !context?.model ||
24
+ !context.sandbox_policy || context.approval_policy===undefined || await realpath(context.cwd)!==await realpath(worktree))throw new Error('Codex turn context is incomplete or changed.');
25
+ return {model:context.model,permissions:digest([context.sandbox_policy,context.approval_policy]),revision:digest(context)};
26
+ } finally {await handle.close();}
27
+ }
@@ -0,0 +1,117 @@
1
+ // PR3 owns sequencing only. Persistence/fencing and provider identity checks are
2
+ // supplied by PR1/PR2; absence of either must never become permission to update.
3
+ export function createUpdateCoordinator({ store, gate, boundary, reconcile, now = Date.now,
4
+ preserveConnectedOnStartup = false, initialState = 'connected', onChange = () => {}, pollMs = 50, timeoutMs = 30_000, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
5
+ let state = initialState, phase = 'unavailable', operation = null, cancelled = false;
6
+ const available = Boolean(store?.read && store?.write && gate?.close && gate?.open && boundary && reconcile);
7
+ const snapshot = () => ({ state, phase, available, busy: Boolean(operation),
8
+ canCancel: state === 'detaching', canReconnect: !operation && ['detached', 'recovery_required'].includes(state) });
9
+ const save = async (next, nextPhase, extra = {}) => {
10
+ // Await the durable commit before advertising or acting on a transition.
11
+ await store.write({ connectionState: next, phase: nextPhase, ...extra });
12
+ state = next; phase = nextPhase; onChange();
13
+ };
14
+ const requireAvailable = () => {
15
+ if (!available) throw Object.assign(new Error('Update detachment dependencies are unavailable.'), { code: 'detachment_unavailable' });
16
+ };
17
+ const exclusive = fn => {
18
+ if (operation) return Promise.reject(new Error('An update connection operation is already in progress.'));
19
+ operation = Promise.resolve().then(fn).finally(() => { operation = null; });
20
+ return operation;
21
+ };
22
+ const recover = async reason => {
23
+ state = 'recovery_required'; phase = reason;
24
+ await gate.close();
25
+ await save(state, phase);
26
+ };
27
+ const reconcileConnection = async () => {
28
+ await save('reconciling', 'identity_check');
29
+ const result = await reconcile(await store.read());
30
+ if (result?.verified !== true || result?.unresolvedAttempts !== 0) {
31
+ await recover('reconciliation_required');
32
+ return snapshot();
33
+ }
34
+ await save('ready', 'reconciled');
35
+ // Gate owns per-task eligibility: ready is not permission to resume a
36
+ // paused/blocked/waiting-human task, reset budgets, or replay an attempt.
37
+ await gate.open({ resumeTasks: false });
38
+ await save('connected', 'complete');
39
+ return snapshot();
40
+ };
41
+ return {
42
+ snapshot,
43
+ acceptsNewWork: () => state === 'connected',
44
+ async initialize() {
45
+ if (!available) return snapshot();
46
+ if (operation) throw new Error('An update connection operation is already in progress.');
47
+ // Fence admission synchronously and reserve the operation before reading
48
+ // disk: startup participates in the same exclusion as detach/reconnect.
49
+ state = 'reconciling'; phase = 'startup';
50
+ return exclusive(async () => {
51
+ try {
52
+ const record = await store.read();
53
+ if (preserveConnectedOnStartup && record?.connectionState === 'connected') { state = 'connected'; phase = 'complete'; return snapshot(); }
54
+ await gate.close();
55
+ // Missing state may mean a process died before its first checkpoint.
56
+ state = record?.connectionState || 'recovery_required';
57
+ phase = record?.phase || 'startup';
58
+ if (state !== 'connected') { await recover('interrupted_update'); return snapshot(); }
59
+ return await reconcileConnection();
60
+ } catch (error) { await recover('startup_failed'); throw error; }
61
+ });
62
+ },
63
+ async detachUpdate(update) {
64
+ requireAvailable();
65
+ if (state !== 'connected') return Promise.reject(new Error('Reconnect the original conversation before updating.'));
66
+ if (operation) throw new Error('An update connection operation is already in progress.');
67
+ // Synchronous local admission closure precedes the first asynchronous IO.
68
+ state = 'detaching'; phase = 'closing_admission'; cancelled = false;
69
+ return exclusive(async () => {
70
+ try {
71
+ await gate.close();
72
+ await save('detaching', 'draining');
73
+ const deadline = now() + timeoutMs;
74
+ while (true) {
75
+ const remaining = deadline - now();
76
+ if (remaining <= 0) throw new Error('Safe update boundary timed out.');
77
+ let timeout;
78
+ const proof = await Promise.race([
79
+ Promise.resolve().then(boundary),
80
+ new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error('Safe update boundary timed out.')), remaining); }),
81
+ ]).finally(() => clearTimeout(timeout));
82
+ if (cancelled) { await recover('cancelled'); return snapshot(); }
83
+ if (now() >= deadline) throw new Error('Safe update boundary timed out.');
84
+ if (proof?.safe === true && proof.pendingWrites === 0 && proof.unsettledPermissions === 0 &&
85
+ (proof.liveExecutions === 0 || proof.workerHandoffVerified === true)) break;
86
+ await sleep(pollMs);
87
+ }
88
+ await save('detached', 'checkpoint_committed');
89
+ if (cancelled) { await recover('cancelled'); return snapshot(); }
90
+ // The updater returns only after installation and candidate health
91
+ // verification; it owns rollback. An ambiguous outcome stays fenced.
92
+ await save('detached', 'updating');
93
+ const result = await update();
94
+ if (result?.connectionTransferred === true && result?.healthy === true) { state = 'connected'; phase = 'complete'; return snapshot(); }
95
+ if (result?.healthy !== true) throw new Error('Updated runtime health was not verified.');
96
+ return await reconcileConnection();
97
+ } catch (error) {
98
+ try { await recover('update_failed'); } catch { error.recoveryFailed = true; }
99
+ throw error;
100
+ }
101
+ });
102
+ },
103
+ async reconnect() {
104
+ requireAvailable();
105
+ if (operation) throw new Error('An update connection operation is already in progress.');
106
+ state = 'reconciling'; phase = 'closing_admission';
107
+ return exclusive(async () => {
108
+ try { await gate.close(); return await reconcileConnection(); }
109
+ catch (error) { await recover('reconnect_failed'); throw error; }
110
+ }); },
111
+ cancel() {
112
+ if (state !== 'detaching') throw new Error('Cancellation is only available before detachment.');
113
+ cancelled = true;
114
+ return snapshot();
115
+ },
116
+ };
117
+ }
@@ -0,0 +1 @@
1
+ export * from '../codex-plugins/simy/hooks/update-dispatch-gate.mjs';
@@ -0,0 +1,62 @@
1
+ import { join } from 'node:path';
2
+ import { acquireLock } from './state-store.js';
3
+ import { detachmentRoot, readDispatchGate, transitionConnection, prepareUpgradeFence, DISPATCH_PROTOCOL, readTaskAdmission, setTaskAdmissionFence } from './dispatch-gate.js';
4
+
5
+ // Adapt the published PR1 root barrier without creating a competing ledger.
6
+ // Identity/result verification and safe-boundary proof are mandatory PR2/host
7
+ // dependencies; this module never fabricates either from a task status label.
8
+ export function createLedgerCoordinatorAdapters({ root = detachmentRoot(), boundary, reconcile, readWriters } = {}) {
9
+ let releaseOwner = null;
10
+ const release = () => { releaseOwner?.(); releaseOwner = null; };
11
+ const transition = connection => {
12
+ const current = readDispatchGate(root);
13
+ if (current.connection !== connection) transitionConnection({ root, expectedRevision: current.revision, connection });
14
+ };
15
+ const recovery = () => {
16
+ const current = readDispatchGate(root).connection;
17
+ if (current === 'recovery_required') return;
18
+ if (['connected', 'ready'].includes(current)) transition('detaching');
19
+ if (readDispatchGate(root).connection === 'detached') transition('reconciling');
20
+ transition('recovery_required');
21
+ };
22
+ return {
23
+ store: {
24
+ read: async () => { const ledger = readDispatchGate(root); return { ...ledger, connectionState: ledger.connection }; },
25
+ write: async patch => {
26
+ const next = patch.connectionState;
27
+ // Reconnect is a verification path, not an assertion that an active
28
+ // owner drained. Move through recovery instead of claiming detached.
29
+ if (next === 'reconciling' && readDispatchGate(root).connection === 'detaching') recovery();
30
+ if (next === 'recovery_required') recovery();
31
+ else transition(next);
32
+ if (['connected', 'recovery_required'].includes(next)) release();
33
+ },
34
+ },
35
+ gate: {
36
+ close: async () => {
37
+ // Hold ownership across async drain, update and identity verification.
38
+ // A second process cannot replace a newer barrier using stale proof.
39
+ releaseOwner ||= acquireLock(join(root, 'update-detachment', 'coordinator'));
40
+ if (['connected', 'ready'].includes(readDispatchGate(root).connection)) {
41
+ if(readWriters) { const writers=await readWriters(),current=readDispatchGate(root);prepareUpgradeFence({root,expectedRevision:current.revision,writers}); }
42
+ else transition('detaching');
43
+ }
44
+ },
45
+ open: async ({ resumeTasks }) => {
46
+ if (resumeTasks !== false || readDispatchGate(root).connection !== 'ready') throw new Error('Verified ready ledger required.');
47
+ transition('connected');
48
+ },
49
+ fenceTask: async ({identity,taskId,reason}) => {
50
+ if(!releaseOwner || !['detaching','reconciling'].includes(readDispatchGate(root).connection)) throw new Error('Coordinator ownership and closed admission required.');
51
+ const writers=await readWriters(),current=readTaskAdmission({root,identity,taskId});
52
+ // This is the explicit coordinator authority while its exclusive
53
+ // lease is held, never a refresh of an old execution dispatch owner.
54
+ return setTaskAdmissionFence({owner:{root,generation:current.generation,protocol:DISPATCH_PROTOCOL},identity,taskId,
55
+ expectedRevision:current.revision,expectedFenceRevision:current.record?.revision ?? null,reason,writers});
56
+ },
57
+ },
58
+ handoff: release,
59
+ boundary,
60
+ reconcile,
61
+ };
62
+ }
@@ -0,0 +1,42 @@
1
+ import { createStateStore, digest } from './state-store.js';
2
+ import { readDispatchGate } from './dispatch-gate.js';
3
+ import { normalizeDispatchIdentity, listDispatchRegistrations } from './provenance.js';
4
+
5
+ // PR1 stores references, not copied task authority. Read the original record
6
+ // and include all three revisions in the sample; never synthesize missing state.
7
+ export function ledgerSnapshotReader({ identity, taskId, readTaskSnapshot, readGate = readDispatchGate }) {
8
+ identity = Object.freeze(structuredClone(identity));
9
+ const exactIdentity = normalizeDispatchIdentity(identity);
10
+ const exactHashes = new Set([digest(identity), digest(exactIdentity)]);
11
+ const store = createStateStore(identity);
12
+ return async () => {
13
+ const scope = store.read();
14
+ const registration = scope.tasks[digest(taskId)];
15
+ if (!registration?.recordRef) throw new Error('Task not registered in exact scope');
16
+ const task = await readTaskSnapshot(registration.recordRef);
17
+ if (!task || !Number.isSafeInteger(task.revision) || task.revision < 0 ||
18
+ !Object.keys(identity).every(key => task.identity?.[key] === identity[key])) throw new Error('Task identity unavailable');
19
+ const gate = readGate(identity.root);
20
+ const knownScopes = new Set(listDispatchRegistrations(identity.root, { taskId, retained: true }).map(item=>digest(item.identity)));
21
+ const attempts = Object.values(gate.attempts).filter(attempt => {
22
+ if(attempt.taskHash!==digest(taskId))return false;
23
+ if(exactHashes.has(attempt.identityHash))return true;
24
+ if(attempt.identityHash && knownScopes.has(attempt.identityHash))return false;
25
+ // Unbound and unregistered evidence is still ambiguous, never silently
26
+ // attributed to another provider or discarded as someone else's work.
27
+ throw new Error('Attempt identity mismatch');
28
+ }).map(attempt => {
29
+ if (!exactHashes.has(attempt.identityHash)) throw new Error('Attempt identity mismatch');
30
+ const receipts = Object.values(gate.receipts).filter(receipt => receipt.attemptId === attempt.id);
31
+ if (receipts.some(receipt => receipt.generation !== attempt.generation)) throw new Error('Receipt generation mismatch');
32
+ const ids = [...new Set(receipts.map(receipt => receipt.providerExecutionId).filter(value => value != null))];
33
+ const refs = [...new Set(receipts.map(receipt => receipt.resultRef).filter(value => value != null))];
34
+ if (ids.length > 1 || refs.length > 1) throw new Error('Conflicting attempt receipts');
35
+ const state = attempt.phase === 'provider_identified' ? 'sent' : attempt.phase;
36
+ return { id: attempt.id, state, generation: attempt.generation,
37
+ ...(ids.length ? { providerExecutionId: ids[0] } : {}), ...(refs.length ? { resultRef: refs[0] } : {}) };
38
+ });
39
+ return { ...structuredClone(task), revision: digest([scope.revision, gate.revision, task]),
40
+ generation: gate.generation, identity: structuredClone(identity), attempts };
41
+ };
42
+ }
@@ -0,0 +1 @@
1
+ export * from '../codex-plugins/simy/hooks/update-dispatch-identity.mjs';
@@ -0,0 +1,33 @@
1
+ import { readCodexSession } from '../session-supervision/codex-app-server.js';
2
+ import { readClaudeSession } from '../session-supervision/claude-sessions.js';
3
+ import { processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+
5
+ // The existing owner supplies account/origin, policy, history, artifact and
6
+ // execution evidence as one revisioned observation. No new process is opened.
7
+ // Codex thread/read alone does not establish account, permissions or writer.
8
+ export function codexReconciliationAdapter({ client, readObservation, readSession = readCodexSession }) {
9
+ return { async observe(snapshot) {
10
+ if (snapshot.identity.provider !== 'codex') throw new Error('Wrong adapter');
11
+ const metadata = await readSession(client, snapshot.identity.session);
12
+ const observed = await readObservation(snapshot);
13
+ if (metadata?.session !== snapshot.identity.session || metadata.cwd !== snapshot.identity.worktree ||
14
+ metadata.model !== observed?.policy?.model || observed?.writer?.source !== 'existing_owner') throw new Error('Unverified Codex owner');
15
+ return observed;
16
+ } };
17
+ }
18
+
19
+ export function claudeReconciliationAdapter({ readObservation, readSession = readClaudeSession, inspectProcess = processIdentity }) {
20
+ return { async observe(snapshot) {
21
+ if (snapshot.identity.provider !== 'claude') throw new Error('Wrong adapter');
22
+ const metadata = await readSession({ session: snapshot.identity.session, cwd: snapshot.identity.worktree });
23
+ const observed = await readObservation(snapshot);
24
+ const hook = snapshot.liveHook;
25
+ if (metadata?.session !== snapshot.identity.session || metadata.cwd !== snapshot.identity.worktree ||
26
+ metadata.observation?.source !== 'claude_local_index' || !hook?.identity ||
27
+ !Number.isSafeInteger(hook.pid) || hook.pid < 2 ||
28
+ observed?.liveHook?.source !== 'original_live_hook' ||
29
+ observed.liveHook.startedAt !== hook.startedAt || observed.liveHook.pid !== hook.pid || observed.liveHook.identity !== hook.identity ||
30
+ await inspectProcess(hook.pid) !== hook.identity) throw new Error('Original Claude process unavailable');
31
+ return { ...observed, liveHook: { ...observed.liveHook, verified: true } };
32
+ } };
33
+ }