@awak-app/simy-cli 0.7.37 → 0.7.38

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/auto-update.js +25 -13
  3. package/src/cli-contract.js +1 -1
  4. package/src/cli-observability.js +2 -2
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +240 -24
  6. package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +4 -1
  7. package/src/codex-plugins/simy/hooks/registered-requirement-checker.mjs +201 -0
  8. package/src/codex-plugins/simy/hooks/requirement-acceptance.mjs +69 -0
  9. package/src/delivery-hooks/supervisor-watchdog.js +3 -2
  10. package/src/desktop-host.js +48 -18
  11. package/src/session-supervision/acceptance-reads.js +11 -2
  12. package/src/session-supervision/analysis-manager.js +9 -2
  13. package/src/session-supervision/catalog-refresh.js +16 -0
  14. package/src/session-supervision/claude-native-agent-contract.js +62 -0
  15. package/src/session-supervision/claude-outcome-review.js +7 -6
  16. package/src/session-supervision/claude-sessions.js +137 -76
  17. package/src/session-supervision/codex-app-server.js +3 -3
  18. package/src/session-supervision/codex-children.js +2 -2
  19. package/src/session-supervision/conversation-context.js +58 -26
  20. package/src/session-supervision/discovery-window.js +8 -2
  21. package/src/session-supervision/index.js +25 -12
  22. package/src/session-supervision/purpose-verification.js +12 -3
  23. package/src/sqm/advisory-guidance.js +1 -0
  24. package/src/sqm/checkers.js +35 -1
  25. package/src/sqm/claude-plugin.js +7 -1
  26. package/src/sqm/hypothesis-knowledge.js +3 -3
  27. package/src/sqm/knowledge-recall.js +34 -12
  28. package/src/sqm/reasoned-assessment.js +22 -9
  29. package/src/update-detachment/runtime.js +2 -1
@@ -0,0 +1,201 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { cp, lstat, mkdir, open, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { normalizeRequirementContract } from './requirement-acceptance.mjs';
6
+ import { workspaceIdentity } from './purpose-workspace.mjs';
7
+
8
+ const handles = new WeakMap();
9
+ const observers = new WeakSet();
10
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
11
+ const objectiveIdentity = objective => digest(typeof objective === 'string' ? objective.trim().slice(0, 4000) : '');
12
+ const sha = value => /^[a-f0-9]{64}$/.test(value || '');
13
+ const inside = (parent, path) => { const rel = relative(parent, path); return !rel || !rel.startsWith('..') && !isAbsolute(rel); };
14
+ const identity = info => Object.fromEntries(['dev', 'ino', 'mode', 'size', 'mtimeMs', 'ctimeMs'].map(key => [key, info[key]]));
15
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
16
+ const registrations = root => join(root, 'host-requirement-checkers');
17
+ const registrationPath = (root, id) => join(registrations(root), `${id}.json`);
18
+
19
+ async function readJSON(path, limit = 128 * 1024) {
20
+ if ((await stat(path)).size > limit) throw new Error('Checker document exceeds its bound.');
21
+ return JSON.parse(await readFile(path, 'utf8'));
22
+ }
23
+ async function pinFile(file, cwd) {
24
+ if (!isAbsolute(file?.path) || !sha(file.sha256)) throw new Error('Checker inputs require absolute paths and SHA-256 pins.');
25
+ const path = await realpath(file.path);
26
+ if (inside(cwd, path)) throw new Error('Checker inputs must be independent of the candidate workspace.');
27
+ const before = await lstat(path);
28
+ if (!before.isFile() || digest(await readFile(path)) !== file.sha256 || !same(identity(before), identity(await lstat(path))))
29
+ throw new Error('Pinned checker input changed.');
30
+ return { path, sha256: file.sha256, identity: identity(before) };
31
+ }
32
+
33
+ // Host-only enrollment capability. JSON read from managed-file/checkpoint cannot
34
+ // recreate a WeakMap handle. This is not an OS sandbox against same-user Bash.
35
+ export async function registerRequirementChecker({ root, provider, session, cwd, objective, publicRequest,
36
+ command, runtime, requirements, timeoutMs = 140_000, cancellationGraceMs = 1000 }) {
37
+ if (provider !== 'claude') throw new Error('Registered requirement execution currently supports the Claude live-hook channel only.');
38
+ cwd = await realpath(cwd);
39
+ root = resolve(root);
40
+ if (inside(cwd, root)) throw new Error('Host checker state must be outside the task workspace.');
41
+ await mkdir(registrations(root), { recursive: true });
42
+ root = await realpath(root);
43
+ if (inside(cwd, root)) throw new Error('Host checker state resolves inside the task workspace.');
44
+ if (typeof objective !== 'string' || !objective.trim() || typeof session !== 'string' || !session ||
45
+ !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 180_000 ||
46
+ !Number.isSafeInteger(cancellationGraceMs) || cancellationGraceMs < 1000 || cancellationGraceMs > 45_000 ||
47
+ !runtime || !sha(runtime.digest) || !['public-docker-v1', 'deterministic-fixture'].includes(runtime.profile) ||
48
+ !isAbsolute(command?.executable) || !Array.isArray(command.args) || command.args.some(arg => typeof arg !== 'string') ||
49
+ !Array.isArray(command.pins) || !command.pins.length || command.pins.length > 100)
50
+ throw new Error('Invalid host checker registration.');
51
+ const pins = await Promise.all(command.pins.map(file => pinFile(file, cwd)));
52
+ if (new Set(pins.map(pin => pin.path)).size !== pins.length) throw new Error('Duplicate checker input.');
53
+ const executable = await realpath(command.executable);
54
+ if (!pins.some(pin => pin.path === executable)) throw new Error('Checker executable must be pinned.');
55
+ const request = await pinFile(publicRequest, cwd);
56
+ const environment = command.env || {};
57
+ if (Object.entries(environment).some(([key, value]) => !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof value !== 'string' ||
58
+ /^(NODE_OPTIONS|NODE_PATH|PYTHONPATH|PYTHONHOME|LD_|DYLD_)/.test(key))) throw new Error('Invalid fixed checker environment.');
59
+ const checker = { command: { executable, args: [...command.args], env: { ...environment } }, pins,
60
+ runtime: structuredClone(runtime), timeoutMs, cancellationGraceMs };
61
+ const contract = normalizeRequirementContract({ schemaVersion: 1, checkVersion: `registered-${digest(JSON.stringify(checker))}`, requirements });
62
+ const spec = { schemaVersion: 1, id: randomUUID(), provider, session, cwd, objectiveHash: objectiveIdentity(objective), publicRequest: request, checker, contract };
63
+ const bytes = JSON.stringify(spec), binding = { id: spec.id, digest: digest(bytes) };
64
+ await writeFile(registrationPath(root, spec.id), bytes, { flag: 'wx', mode: 0o600 });
65
+ await mkdir(join(registrations(root), 'executions'), { recursive: true });
66
+ const handle = Object.freeze({ contract: structuredClone(contract) });
67
+ handles.set(handle, { root, spec, binding });
68
+ return handle;
69
+ }
70
+
71
+ export async function bindRequirementChecker(handle, { root, provider, session, cwd, objective }) {
72
+ const registration = handles.get(handle);
73
+ if (!registration || registration.root !== await realpath(root) || registration.spec.provider !== provider ||
74
+ registration.spec.session !== session || registration.spec.cwd !== await realpath(cwd) ||
75
+ registration.spec.objectiveHash !== objectiveIdentity(objective)) throw new Error('A matching host enrollment capability is required.');
76
+ await loadRegistration(root, registration.binding, true);
77
+ return { requirementChecker: { ...registration.binding }, requirementContract: structuredClone(registration.spec.contract) };
78
+ }
79
+
80
+ async function loadRegistration(root, binding, full = false) {
81
+ if (!/^[a-f0-9-]{36}$/.test(binding?.id || '') || !sha(binding?.digest)) throw new Error('Invalid enrolled checker binding.');
82
+ const bytes = await readFile(registrationPath(root, binding.id), 'utf8');
83
+ if (Buffer.byteLength(bytes) > 128 * 1024 || digest(bytes) !== binding.digest) throw new Error('Host checker registration changed.');
84
+ const spec = JSON.parse(bytes);
85
+ for (const file of [...spec.checker.pins, spec.publicRequest]) {
86
+ if (!same(identity(await lstat(file.path)), file.identity) ||
87
+ full && digest(await readFile(file.path)) !== file.sha256) throw new Error('Pinned checker input changed.');
88
+ }
89
+ return spec;
90
+ }
91
+
92
+ // Stop only checks the enrollment and inexpensive source metadata. Actual
93
+ // execution validates the complete pinned bytes both before and afterwards.
94
+ export async function registeredCheckerCurrent(root, binding) {
95
+ try { await loadRegistration(root, binding); return true; } catch { return false; }
96
+ }
97
+
98
+ export function receiptFromCaseReport(contract, candidateHash, report, evidenceRef) {
99
+ const expected = contract.requirements.flatMap(row => row.assertionIds);
100
+ const cases = report?.cases;
101
+ const complete = report?.schemaVersion === 1 && report.execution?.status === 'completed' && Array.isArray(cases) &&
102
+ cases.length === expected.length && new Set(cases.map(row => row?.id)).size === expected.length &&
103
+ cases.every(row => expected.includes(row?.id) && ['passed', 'failed'].includes(row.status));
104
+ return { schemaVersion: 1, contractHash: contract.contractHash, checkVersion: contract.checkVersion, candidateHash,
105
+ execution: { status: complete ? 'completed' : 'infrastructure_error', assertionCount: complete ? cases.length : 0 },
106
+ observations: complete ? contract.requirements.map(requirement => ({ requirementId: requirement.id,
107
+ status: cases.some(row => requirement.assertionIds.includes(row.id) && row.status === 'failed') ? 'failed' : 'verified',
108
+ assertionIds: requirement.assertionIds, evidenceRefs: [evidenceRef] })) : [] };
109
+ }
110
+
111
+ async function runProcess(spec, request, directory, signal) {
112
+ const stdout = await open(join(directory, 'stdout.log'), 'wx', 0o600);
113
+ const stderr = await open(join(directory, 'stderr.log'), 'wx', 0o600);
114
+ try {
115
+ signal.throwIfAborted();
116
+ const child = spawn(spec.command.executable, spec.command.args, {
117
+ cwd: directory, env: spec.command.env, stdio: ['pipe', stdout.fd, stderr.fd],
118
+ });
119
+ let killTimer;
120
+ const abort = () => { child.kill('SIGTERM'); killTimer ??= setTimeout(() => child.kill('SIGKILL'), spec.cancellationGraceMs ?? 1000); };
121
+ let inspecting = false;
122
+ const outputLimit = setInterval(async () => {
123
+ if (inspecting) return;
124
+ inspecting = true;
125
+ try { if ((await stdout.stat()).size + (await stderr.stat()).size > 8 * 1024 * 1024) abort(); }
126
+ catch { abort(); } finally { inspecting = false; }
127
+ }, 100);
128
+ const completion = new Promise(accept => {
129
+ let spawnError;
130
+ child.once('error', error => { spawnError = error; });
131
+ child.once('close', (code, terminationSignal) => accept({ code, signal: terminationSignal, ...(spawnError ? { error: spawnError.message } : {}) }));
132
+ });
133
+ signal.addEventListener('abort', abort, { once: true });
134
+ if (signal.aborted) abort();
135
+ child.stdin.on('error', () => {});
136
+ child.stdin.end(JSON.stringify(request));
137
+ try {
138
+ await writeFile(join(directory, 'process.json'), JSON.stringify({ pid: child.pid, launchedAt: new Date().toISOString() }), { flag: 'wx' });
139
+ return await completion;
140
+ } catch (error) { abort(); await completion; throw error;
141
+ } finally { clearTimeout(killTimer); clearInterval(outputLimit); signal.removeEventListener('abort', abort); }
142
+ } finally { await stdout.close(); await stderr.close(); }
143
+ }
144
+
145
+ export function registeredRequirementObserver({ root, binding, provider, session, runId, signal: outerSignal }) {
146
+ const observer = async ({ cwd, contract, candidateHash, signal = outerSignal }) => {
147
+ const spec = await loadRegistration(root, binding, true);
148
+ if (spec.provider !== provider || spec.session !== session || spec.cwd !== await realpath(cwd) ||
149
+ spec.contract.contractHash !== contract.contractHash) throw new Error('Registered checker target changed.');
150
+ const executions = join(registrations(root), 'executions', digest(JSON.stringify([binding, provider, session, runId, candidateHash])));
151
+ // Atomic one-use claim also covers a cold checkpoint subprocess. An unknown
152
+ // process is never replayed; a completed report has an explicit reuse link.
153
+ try { await mkdir(executions); }
154
+ catch (error) {
155
+ if (error.code !== 'EEXIST') throw error;
156
+ const result = await readJSON(join(executions, 'result.json'));
157
+ if (result.bindingDigest !== binding.digest || result.candidateHash !== candidateHash || result.status !== 'complete' || result.snapshotRemoved !== true)
158
+ throw new Error('Previous checker execution is unresolved; do not replay.');
159
+ const bytes = await readFile(join(executions, 'cases.json'));
160
+ if (digest(bytes) !== result.reportHash) throw new Error('Previous checker report changed.');
161
+ return receiptFromCaseReport(contract, candidateHash, JSON.parse(bytes), join(executions, 'result.json'));
162
+ }
163
+ await writeFile(join(executions, 'intent.json'), JSON.stringify({ binding, provider, session, runId, candidateHash,
164
+ createdAt: new Date().toISOString() }), { flag: 'wx' });
165
+ const controller = new AbortController();
166
+ const abort = () => controller.abort(signal.reason);
167
+ signal?.addEventListener('abort', abort, { once: true });
168
+ if (signal?.aborted) abort();
169
+ const timeout = setTimeout(() => controller.abort(new Error('Checker deadline exceeded.')), spec.checker.timeoutMs);
170
+ const snapshot = join(executions, 'candidate');
171
+ try {
172
+ const filter = path => !relative(cwd, path).split(/[\\/]/).some(part => ['.git', 'node_modules', '.npm-cache'].includes(part));
173
+ await cp(cwd, snapshot, { recursive: true, dereference: false, filter, preserveTimestamps: true });
174
+ if (await workspaceIdentity(snapshot, undefined, undefined, { signal: controller.signal }) !== candidateHash ||
175
+ await workspaceIdentity(cwd, root, undefined, { signal: controller.signal }) !== candidateHash) throw new Error('Candidate changed while freezing checker input.');
176
+ const process = await runProcess(spec.checker, { schemaVersion: 1, candidateHash, cwd: snapshot,
177
+ reportPath: join(executions, 'cases.json'), contract: spec.contract, runtime: spec.checker.runtime }, executions, controller.signal);
178
+ await loadRegistration(root, binding, true);
179
+ if (controller.signal.aborted || process.signal || process.error || ![0, 1].includes(process.code) ||
180
+ await workspaceIdentity(snapshot) !== candidateHash) throw new Error('Checker process or immutable input is unresolved.');
181
+ const report = await readJSON(join(executions, 'cases.json'));
182
+ const receipt = receiptFromCaseReport(contract, candidateHash, report, join(executions, 'result.json'));
183
+ if (receipt.execution.status !== 'completed' || process.code === 1 && !report.cases.some(row => row.status === 'failed')) throw new Error('Checker did not execute the complete required case set.');
184
+ await rm(snapshot, { recursive: true });
185
+ await writeFile(join(executions, 'result.json'), JSON.stringify({ status: 'complete', bindingDigest: binding.digest,
186
+ candidateHash, process, snapshotRemoved: true, reportHash: digest(await readFile(join(executions, 'cases.json'))) }), { flag: 'wx' });
187
+ return receipt;
188
+ } catch (error) {
189
+ await writeFile(join(executions, 'error.json'), JSON.stringify({ status: 'unresolved', message: error.message }), { flag: 'wx' });
190
+ throw error;
191
+ } finally {
192
+ clearTimeout(timeout); signal?.removeEventListener('abort', abort);
193
+ // Only our copied input, never the original workspace or Docker resources.
194
+ await rm(snapshot, { recursive: true, force: true });
195
+ }
196
+ };
197
+ observers.add(observer);
198
+ return observer;
199
+ }
200
+
201
+ export const isRegisteredRequirementObserver = observer => observers.has(observer);
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
4
+ const identifier = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(value);
5
+ const strings = (values, maximum) => Array.isArray(values) && values.length > 0 && values.length <= maximum &&
6
+ values.every(identifier) && new Set(values).size === values.length;
7
+
8
+ // This opt-in contract is frozen at enrollment. Receipts cannot redefine its
9
+ // requirements or replace a broad behavior requirement with a literal PASS file.
10
+ export function normalizeRequirementContract(input) {
11
+ if (input?.schemaVersion !== 1 || !identifier(input.checkVersion) || !Array.isArray(input.requirements) ||
12
+ !input.requirements.length || input.requirements.length > 20) throw new Error('Invalid behavior requirement contract.');
13
+ const requirements = input.requirements.map(row => {
14
+ if (!identifier(row?.id) || typeof row.description !== 'string' || !row.description.trim() || row.description.length > 500 ||
15
+ !strings(row.assertionIds, 100)) throw new Error('Each behavior requirement needs an ID, description and distinct assertions.');
16
+ return { id: row.id, description: row.description, assertionIds: [...row.assertionIds].sort() };
17
+ }).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
18
+ if (new Set(requirements.map(row => row.id)).size !== requirements.length) throw new Error('Duplicate behavior requirement.');
19
+ const assertionIds = requirements.flatMap(row => row.assertionIds);
20
+ if (new Set(assertionIds).size !== assertionIds.length) throw new Error('Behavior assertion IDs must be unique across requirements.');
21
+ const contract = { schemaVersion: 1, checkVersion: input.checkVersion, requirements };
22
+ const contractHash = hash(contract);
23
+ if (input.contractHash !== undefined && input.contractHash !== contractHash) throw new Error('Behavior contract digest changed.');
24
+ return { ...contract, contractHash };
25
+ }
26
+
27
+ export function assessRequirementReceipt(contractInput, receipt, candidateHash) {
28
+ const contract = normalizeRequirementContract(contractInput);
29
+ const unknown = detail => contract.requirements.map(row => ({ requirementId: row.id, status: 'unknown', detail }));
30
+ if (!receipt || receipt.schemaVersion !== 1 || receipt.contractHash !== contract.contractHash ||
31
+ receipt.checkVersion !== contract.checkVersion || receipt.candidateHash !== candidateHash ||
32
+ !/^[a-f0-9]{64}$/.test(candidateHash || '')) return unknown('Missing or stale independently bound behavior observation.');
33
+ if (receipt.execution?.status !== 'completed' || !Number.isSafeInteger(receipt.execution.assertionCount) ||
34
+ receipt.execution.assertionCount <= 0) return unknown('Behavior observation did not execute assertions; infrastructure is unresolved.');
35
+ if (!Array.isArray(receipt.observations) || receipt.observations.length > 20) return unknown('Invalid behavior observation rows.');
36
+ const ids = receipt.observations.map(row => row?.requirementId);
37
+ if (new Set(ids).size !== ids.length || ids.some(id => !contract.requirements.some(row => row.id === id)))
38
+ return unknown('Duplicate or unexpected behavior observation rows.');
39
+ let observedCount = 0;
40
+ const rows = contract.requirements.map(requirement => {
41
+ const row = receipt.observations.find(item => item.requirementId === requirement.id);
42
+ if (!row || !['verified', 'failed', 'unknown'].includes(row.status) || !strings(row.assertionIds, 100) ||
43
+ !Array.isArray(row.evidenceRefs) || !row.evidenceRefs.length || row.evidenceRefs.length > 20 ||
44
+ row.evidenceRefs.some(ref => typeof ref !== 'string' || !ref.trim() || ref.length > 1000) ||
45
+ row.assertionIds.some(id => !requirement.assertionIds.includes(id)))
46
+ return { requirementId: requirement.id, status: 'unknown', detail: 'Requirement has no usable assertion/evidence observation.' };
47
+ observedCount += row.assertionIds.length;
48
+ if (row.status === 'verified' && requirement.assertionIds.some(id => !row.assertionIds.includes(id)))
49
+ return { requirementId: requirement.id, status: 'unknown', detail: 'Only part of the required behavior was observed.' };
50
+ return { requirementId: requirement.id, status: row.status,
51
+ detail: row.status === 'failed' ? 'Executed behavior assertion failed.' : row.status === 'unknown' ?
52
+ 'Behavior remains unresolved.' : 'All required behavior assertions were independently observed.' };
53
+ });
54
+ return receipt.execution.assertionCount < observedCount ? unknown('Assertion count does not bind the observed rows.') : rows;
55
+ }
56
+
57
+ export function pendingRequirements(managed) {
58
+ if (!managed?.requirementContract) return [];
59
+ let contract;
60
+ try { contract = normalizeRequirementContract(managed.requirementContract); }
61
+ catch { return [{ id: 'behavior-contract', description: 'Frozen behavior requirements', check: 'behavior', status: 'unknown', detail: 'Behavior contract changed; inspect enrollment.' }]; }
62
+ const observation = managed.requirementObservation;
63
+ const rows = observation?.source === 'host_requirement_observer' && observation.current === true
64
+ ? assessRequirementReceipt(contract, observation.receipt, observation.candidateHash)
65
+ : contract.requirements.map(row => ({ requirementId: row.id, status: 'unknown', detail: 'Current host behavior observation is missing.' }));
66
+ return rows.filter(row => row.status !== 'verified').map(row => ({ id: `behavior:${row.requirementId}`,
67
+ description: contract.requirements.find(item => item.id === row.requirementId).description,
68
+ check: 'behavior', status: row.status, detail: row.detail }));
69
+ }
@@ -20,12 +20,13 @@ async function verifiedPin(root) {
20
20
  return { digest: pin.digest, release: pin.release };
21
21
  }
22
22
  export async function setSupervisorWatchdog({ root, enabled, webOrigin,
23
- inspectPin = verifiedPin, now = Date.now(), expectedEpoch, target } = {}) {
23
+ inspectPin = verifiedPin, now = Date.now(), expectedEpoch, target,
24
+ identity = supervisorProcessIdentity } = {}) {
24
25
  if (target && !enabled) throw new Error('A recovery target requires explicit enable.');
25
26
  const binding = target ? await supervisorTarget(root, target) : null;
26
27
  const pin = enabled ? await inspectPin(root) : null;
27
28
  const lock = await acquireDesktopProcessLock(join(folder(root), 'control-lock'), {
28
- alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
29
+ alive: supervisorProcessAlive, identity });
29
30
  try {
30
31
  const previous = await readSupervisorState(controlPath(root));
31
32
  if (expectedEpoch !== undefined && previous?.epoch !== expectedEpoch) return previous;
@@ -1,5 +1,7 @@
1
1
  import { createDesktopFrameWriter } from './desktop-frame-writer.js';
2
2
  import { boundedDiscovery } from './session-supervision/progressive-discovery.js';
3
+ import { createCatalogRefreshPolicy } from './session-supervision/catalog-refresh.js';
4
+ import { supervisedSession } from './session-supervision/discovery-window.js';
3
5
  import { createClaudeDesktopArchiveController } from './session-supervision/claude-desktop-archive.js';
4
6
  import { createClaudeDesktopNativeAdapter } from './session-supervision/claude-desktop-native.js';
5
7
  import { applyControllerExecutionContext } from './session-supervision/execution-context.js';
@@ -203,6 +205,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
203
205
  status = { ...status, update: { ...status.update,
204
206
  auto_update_supported: updateSettings.auto_update_supported === true,
205
207
  auto_update_enabled: updateSettings.auto_update_enabled === true,
208
+ auto_update_unavailable_reason: updateSettings.auto_update_unavailable_reason || null,
206
209
  auto_update_busy: updateSettings.auto_update_busy === true } };
207
210
  if (env.SIMY_REMOTE_CHILD === "1") status = { ...status, remoteSessionProtocol: 1, remoteControl: { version: 2, actions: [...allowedActions] } };
208
211
  ownerTransport?.publish({ type: 'status', status: { ...status, remoteSessionProtocol: 1, remoteControl: { version: 2, actions: [...allowedActions] } } });
@@ -480,6 +483,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
480
483
  })().finally(() => { emailSync = null; });
481
484
  await emailSync;
482
485
  };
486
+ const saveEmailDeliveryObservation = (controller, completion, observation) => controllerPreferences.change(controller.controllerScope, state => {
487
+ const identity = JSON.stringify([completion.host_id || 'local', completion.provider, completion.provider_session_id]);
488
+ const row = state.conversations[identity] ||= {};
489
+ row.emailDeliveries ||= {};
490
+ row.emailDeliveries[completion.task_id] = { ...observation, checkedAt: Date.now(), provider: completion.provider,
491
+ provider_session_id: completion.provider_session_id, host_id: completion.host_id || 'local' };
492
+ });
483
493
  const submitNativeDevelopmentReportCompletions = ({ completions, controller }) => {
484
494
  const { scope, deviceSession } = controller;
485
495
  if (!deviceSession?.token || !deviceSession?.api_base_url || !sameReportController(controller, nativeDevelopmentReportsController) || nativeDevelopmentReports.state !== "ready" || nativeDevelopmentReportsScope !== scope) return;
@@ -506,23 +516,31 @@ export async function startDesktopHost({ input = process.stdin, output = process
506
516
  if (receipt && nativeDevelopmentReportsGeneration === generation && accessScope(currentAccess) === scope) {
507
517
  if (receipt.status === 'pending') nativeCompletionRetryAt.set(key, Date.now() + 30_000);
508
518
  else { nativeCompletionSuccess.add(key); nativeCompletionRetryAt.delete(key); }
509
- const receiptScope = controller.controllerScope;
510
- await controllerPreferences.change(receiptScope, state => {
511
- const identity = JSON.stringify([hostId, completion.provider, completion.provider_session_id]);
512
- const row = state.conversations[identity] ||= {};
513
- row.emailDeliveries ||= {};
514
- row.emailDeliveries[completion.task_id] = { ...receipt, checkedAt: Date.now(), provider: completion.provider, provider_session_id: completion.provider_session_id, host_id: hostId };
515
- });
519
+ await saveEmailDeliveryObservation(controller, completion, receipt);
516
520
  await requireReportController(controller);
517
521
  if (!closed) void publish().catch(() => {});
518
522
  }
519
523
  return receipt;
520
- }).catch(error => {
524
+ }).catch(async error => {
521
525
  if (nativeDevelopmentReportsGeneration === generation && accessScope(currentAccess) === scope) {
522
526
  // These are immutable authorization/contract rejections. A new
523
527
  // enrollment revision or scope produces a fresh rejection identity.
524
- if ([400, 403, 409].includes(error?.status)) nativeCompletionRejected.add(rejectionKey);
528
+ const rejected = [400, 403, 409].includes(error?.status);
529
+ if (rejected) nativeCompletionRejected.add(rejectionKey);
525
530
  else nativeCompletionRetryAt.set(key, Date.now() + 30_000);
531
+ // ON is consent, not a delivery receipt. Surface a failed submission
532
+ // without exposing backend errors, credentials or completion proofs.
533
+ // The next confirmed server receipt replaces this local observation.
534
+ try {
535
+ await requireReportController(controller);
536
+ if (closed || nativeDevelopmentReportsGeneration !== generation || accessScope(currentAccess) !== scope) return null;
537
+ await saveEmailDeliveryObservation(controller, completion, {
538
+ task_id: completion.task_id, enrollment_revision: completion.enrollment_revision,
539
+ status: rejected ? 'rejected' : 'retrying',
540
+ });
541
+ await requireReportController(controller);
542
+ if (!closed) void publish().catch(() => {});
543
+ } catch { /* A changed controller must not receive an old failure. */ }
526
544
  }
527
545
  return null;
528
546
  }).finally(() => nativeCompletionInFlight.delete(key));
@@ -568,6 +586,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
568
586
  let commands;
569
587
  let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null, initialLoading: true };
570
588
  let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
589
+ const catalogRefresh = createCatalogRefreshPolicy();
571
590
  let catalogRevision = 0;
572
591
  let enrichmentController = null, sessionEnrichment = null;
573
592
  let catalogAnalysisJob = null, catalogAnalysisAgain = false;
@@ -585,7 +604,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
585
604
  const records = await recordReader.list();
586
605
  if (closed || relay || discoveryStopped || manager !== analysisManager ||
587
606
  !desktopAccessReady(currentAccess) || accessScope(currentAccess) !== scope || !coordinator.acceptsNewWork()) return;
588
- await manager.update(analysisSessions(withCurrentSettings(sessionStatus.sessions, records)));
607
+ await manager.update(analysisSessions(withCurrentSettings(sessionStatus.sessions, records)), {
608
+ priorityKeys: new Set(records.filter(supervisedSession).map(record => `${record.provider}:${record.session}`)),
609
+ });
589
610
  }
590
611
  }).catch(() => {}).finally(() => {
591
612
  catalogAnalysisJob = null;
@@ -640,12 +661,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
640
661
  unfinishedDesktopWork = false;
641
662
  })();
642
663
  const observePullRequests = dependencies.observePullRequests || createPullRequestObserver();
643
- const refreshSessions = (force = false) => {
664
+ const refreshSessions = (force = false, { history = false } = {}) => {
665
+ if (history) catalogRefresh.invalidate();
644
666
  if (discoveryStopped && !closed && !relay) return sessionStatus;
645
667
  if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
646
668
  if (force && sessionRefresh) sessionRefreshAgain = true;
647
669
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
648
670
  const requestedScope = accessScope(currentAccess);
671
+ const refresh = catalogRefresh.begin(JSON.stringify([requestedScope, providerLinks]));
649
672
  const controller = discoveryController = new AbortController();
650
673
  const attempt = { id: randomUUID(), requestedAt: Date.now(), state: 'pending' };
651
674
  sessionStatus = { ...sessionStatus, discoveryAttempt: attempt };
@@ -659,7 +682,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
659
682
  }
660
683
  const complete = new Set(value.completeProviders || (value.historyLoading === undefined ? PROVIDER_LINKS : []));
661
684
  const merge = (previous, incoming) => {
662
- const rows = new Map(previous.filter(item => !complete.has(item.provider)).map(item => [`${item.provider}:${item.session}`, item]));
685
+ const rows = new Map(previous.filter(item => refresh.recentOnly || !complete.has(item.provider)).map(item => [`${item.provider}:${item.session}`, item]));
663
686
  for (const item of incoming || []) rows.set(`${item.provider}:${item.session}`, item);
664
687
  return [...rows.values()];
665
688
  };
@@ -674,7 +697,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
674
697
  const firstPageTimeoutMs = dependencies.discoveryTimeoutMs ?? 30_000;
675
698
  const historyTimeoutMs = dependencies.historyDiscoveryTimeoutMs ?? dependencies.discoveryTimeoutMs ?? 300_000;
676
699
  sessionRefresh = boundedDiscovery((signal, startHistory) => (dependencies.discoverSessions || discoverSessions)({
677
- root: deliveryRoot, signal, onDrain: trackDiscoveryDrain, includeArchived: true, includeSelectionCandidates: true,
700
+ root: deliveryRoot, signal, onDrain: trackDiscoveryDrain, includeArchived: true, includeSelectionCandidates: true, recentOnly: refresh.recentOnly,
678
701
  readRecords: () => recordReader.list(),
679
702
  connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected),
680
703
  timeoutMs: firstPageTimeoutMs, historyTimeoutMs,
@@ -685,6 +708,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
685
708
  await applyCatalog(value);
686
709
  if (closed || relay || controller.signal.aborted) return;
687
710
  const failed = Object.values(value.providers || {}).some(provider => provider.discovery === 'unavailable');
711
+ refresh.complete(!failed);
688
712
  sessionStatus = { ...sessionStatus, historyLoading: false,
689
713
  discoveryAttempt: { ...attempt, state: failed ? 'failed' : 'succeeded', completedAt: Date.now() } };
690
714
  // Publish before PR/network observation and queue maintenance. Neither
@@ -1507,7 +1531,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1507
1531
  if(['detach_update','update_reconnect'].includes(action)) {
1508
1532
  const access=await accessSnapshot(); requireDesktopAccess(access);
1509
1533
  updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());
1510
- if(action==='detach_update')await updater.maybeApply({detach:true});
1534
+ if(action==='detach_update')await updater.maybeApply({detach:true,manual:true});
1511
1535
  else await reconnectWithHistoryRecovery({root,coordinator,eligible:()=>updateRuntime.historyRecoveryEligible()});
1512
1536
  emit({type:'result',id:command.id,ok:coordinator.snapshot().state==='connected' && (action!=='detach_update' || updater.snapshot().state==='updated')});
1513
1537
  await publish();return;
@@ -1578,7 +1602,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
1578
1602
  if (['detach_update', 'update_reconnect'].includes(action)) requireDesktopAccess(access);
1579
1603
  else if (!recoveryControl && desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
1580
1604
 
1581
- if (!coordinator.acceptsNewWork() && !recoveryControl && !["refresh", "reconnect", "task_detail", "check_update", "quit", "delivery_stop", "delivery_pause", "delivery_reject"].includes(action)) {
1605
+ // Signing back into the same SIMY environment is a prerequisite for
1606
+ // recovery, not permission to resume work. Keep organization changes
1607
+ // and execution fenced until the original checkpoint is reconciled.
1608
+ const recoveryAuthorization = ["login", "authorization_open", "authorization_copy", "organization_cancel"].includes(action);
1609
+ if (!coordinator.acceptsNewWork() && !recoveryControl && !recoveryAuthorization && !["refresh", "reconnect", "task_detail", "check_update", "quit", "delivery_stop", "delivery_pause", "delivery_reject"].includes(action)) {
1582
1610
  throw new Error("The update connection requires recovery.");
1583
1611
  }
1584
1612
  if (['team_threads_refresh','team_thread_open','team_thread_share'].includes(action)) {
@@ -1890,7 +1918,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1890
1918
  else if (desktopAccessReady(access)) await agent.refreshCodexStatus?.();
1891
1919
  await agent.refreshDesktopStatus();
1892
1920
  await refreshPlugins();
1893
- refreshSessions(true);
1921
+ refreshSessions(true, { history: true });
1894
1922
  if (desktopAccessReady(currentAccess) && coordinator.acceptsNewWork()) void actionAssociations.tick().then(publish).catch(() => {});
1895
1923
  }
1896
1924
  else if (action === "organizations_refresh") await agent.refreshOrganizations();
@@ -2227,7 +2255,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
2227
2255
  } finally { clearInterval(watchdog); }
2228
2256
 
2229
2257
  await recordProviderArchive(deliveryRoot, 'claude', command.session, archived, Date.now());
2230
- refreshSessions(true);
2258
+ refreshSessions(true, { history: true });
2231
2259
  emit({ type: 'result', id: command.id, ok: true, data });
2232
2260
  void publish().catch(() => {});
2233
2261
  return;
@@ -2245,7 +2273,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
2245
2273
  listHidden: archived,
2246
2274
  }));
2247
2275
  }
2248
- refreshSessions(true);
2276
+ // Archive/restore can move an old conversation outside both recent
2277
+ // pages. Explicit organization must be visible immediately.
2278
+ refreshSessions(true, { history: true });
2249
2279
  } else if (action === "session_track" || action === "session_untrack") {
2250
2280
  const candidate = await selectedSession(command);
2251
2281
  if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
@@ -6,7 +6,8 @@ import { evidenceExcerpt } from '../codex-plugins/simy/hooks/purpose-evidence.mj
6
6
 
7
7
  // Read only original file predicates in the authorized task directory. These
8
8
  // are current independent controller reads, not worker claims or authored proof.
9
- export async function captureAcceptanceReads(cwd, criteria, { signal } = {}) {
9
+ export async function captureAcceptanceReads(cwd, criteria, { signal, excerptLimit = 4000, publicTaskSourcePaths = [] } = {}) {
10
+ if (!Number.isSafeInteger(excerptLimit) || excerptLimit < 1 || excerptLimit > 32768) throw new Error('Acceptance excerpt bound is invalid.');
10
11
  const root = await realpath(cwd), rows = [], seen = new Set(); let retained = 0;
11
12
  for (const criterion of criteria) {
12
13
  signal?.throwIfAborted();
@@ -22,8 +23,16 @@ export async function captureAcceptanceReads(cwd, criteria, { signal } = {}) {
22
23
  if (bytesRead > 32768) continue;
23
24
  const body = data.subarray(0, bytesRead); retained += bytesRead;
24
25
  const digest = createHash('sha256').update(body).digest('hex');
26
+ // Host-only, explicit source-read permission for a native task reviewer.
27
+ // The same original Claude already has Read access to these public-task
28
+ // source paths. Business identifiers such as Token are source, not a
29
+ // credential claim. Generic artifact reads retain their redaction policy.
30
+ const sourceRead = publicTaskSourcePaths.some(file => resolve(root, file) === path);
31
+ const sourceText = bytesRead ? body.toString('utf8') : JSON.stringify({ byteLength: 0, content: '' });
32
+ const observation = sourceRead ? { text: sourceText.slice(0, excerptLimit), truncated: sourceText.length > excerptLimit,
33
+ redacted: false, policy: 'host_authorized_public_task_source' } : evidenceExcerpt(sourceText, excerptLimit);
25
34
  rows.push({ id: `file:${target}`, source: 'controller_file_read', status: 'observed', at: Date.now(), digest,
26
- recipe: { type: 'file_read', path: target }, observation: evidenceExcerpt(bytesRead ? body.toString('utf8') : JSON.stringify({ byteLength: 0, content: '' }), 4000) });
35
+ recipe: { type: 'file_read', path: target }, observation });
27
36
  } catch { signal?.throwIfAborted(); /* Missing/large/unreadable files stay unverified; tool investigation remains available. */ }
28
37
  finally { await handle?.close(); }
29
38
  }
@@ -4,6 +4,7 @@ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
4
4
  import { join } from 'node:path';
5
5
  import { analyzeContinuation, CONTINUATION_ANALYSIS_VERSION } from './continuation-analysis.js';
6
6
  import { providerGoalEvidence } from './conversation-context.js';
7
+ import { liveSessionWindow, supervisedSession } from './discovery-window.js';
7
8
 
8
9
  const BACKFILL_LIMIT = 10;
9
10
  const MAX_CACHE_ENTRIES = 2000;
@@ -276,11 +277,17 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
276
277
  });
277
278
  startDrain();
278
279
  },
279
- async update(sessions) {
280
+ async update(sessions, { priorityKeys = new Set() } = {}) {
280
281
  await exclusive(async () => {
281
282
  if (closed || suspended) return;
283
+ // The complete catalog remains navigable. Historical metadata outside
284
+ // this window must not churn the bounded cache or schedule paid work.
285
+ // Preserve queued/manual jobs and explicitly supervised conversations;
286
+ // removal, changed authority and OFF still use the incoming snapshot.
287
+ const window = liveSessionWindow(sessions, { priority: candidate => priorityKeys.has(keyOf(candidate)) || supervisedSession(candidate) ||
288
+ jobs.has(keyOf(candidate)) || ['queued', 'analyzing'].includes(entries.get(keyOf(candidate))?.result?.state) });
282
289
  candidates.clear();
283
- for (const candidate of sessions) candidates.set(keyOf(candidate), observedCandidate(candidate));
290
+ for (const candidate of window) candidates.set(keyOf(candidate), observedCandidate(candidate));
284
291
  const sorted = [...candidates.values()].sort((a, b) => activityOf(b) - activityOf(a) || keyOf(a).localeCompare(keyOf(b)));
285
292
  const backfill = document.initialized ? new Set() : new Set(sorted.slice(0, BACKFILL_LIMIT).map(keyOf));
286
293
  let dirty = !document.initialized;
@@ -0,0 +1,16 @@
1
+ // History is a navigation index, not a real-time subscription. Keep ordinary
2
+ // status/preference refreshes cheap; an explicit Refresh still reloads history.
3
+ export function createCatalogRefreshPolicy({ now = Date.now, intervalMs = 300_000 } = {}) {
4
+ let completed = null, scope = null, revision = 0;
5
+ return {
6
+ invalidate() { completed = null; revision++; },
7
+ begin(nextScope) {
8
+ if (scope !== nextScope) { scope = nextScope; completed = null; revision++; }
9
+ const generation = revision;
10
+ const recentOnly = completed !== null && now() - completed < intervalMs;
11
+ return { recentOnly, complete(success) {
12
+ if (!recentOnly && success && generation === revision) completed = now();
13
+ } };
14
+ },
15
+ };
16
+ }
@@ -0,0 +1,62 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ // Observed foreground-only Agent contract. Omission is never evidence of
4
+ // foreground execution when the provider exposes background or unknown options.
5
+ const FOREGROUND_SCHEMA = {
6
+ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object',
7
+ properties: { description: { type: 'string' }, prompt: { type: 'string' },
8
+ subagent_type: { type: 'string' }, isolation: { type: 'string', enum: ['worktree', 'remote'] } },
9
+ required: ['description', 'prompt'], additionalProperties: false,
10
+ };
11
+ const withoutDescriptions = value => Array.isArray(value) ? value.map(withoutDescriptions) :
12
+ value && typeof value === 'object' ? Object.fromEntries(Object.entries(value)
13
+ .filter(([key, item]) => key !== 'description' || typeof item !== 'string')
14
+ .map(([key, item]) => [key, withoutDescriptions(item)])) : value;
15
+
16
+ export function isForegroundOnlyAgentSchema(schema) {
17
+ return isDeepStrictEqual(withoutDescriptions(schema), FOREGROUND_SCHEMA);
18
+ }
19
+
20
+ export function isForegroundNativeReviewInput(toolName, args, observedSchema = null) {
21
+ if (!['Agent', 'Task'].includes(toolName) || !args || typeof args !== 'object' || Array.isArray(args) ||
22
+ typeof args.prompt !== 'string' || typeof args.subagent_type !== 'string' ||
23
+ Object.keys(args).some(key => !['description', 'prompt', 'subagent_type', 'run_in_background'].includes(key)) ||
24
+ Object.hasOwn(args, 'description') && typeof args.description !== 'string') return false;
25
+ if (Object.hasOwn(args, 'run_in_background')) return args.run_in_background === false;
26
+ // The observed Agent schema cannot establish an omitted flag for legacy Task.
27
+ return toolName === 'Agent' && typeof args.description === 'string' && isForegroundOnlyAgentSchema(observedSchema);
28
+ }
29
+
30
+ // Callers supply rows read from the exact owned, stable parent transcript,
31
+ // never a worker-provided schema. A schema first seen after the call proves nothing.
32
+ export function foregroundAgentContract(rows, candidate, beforeIndex = rows.length) {
33
+ const collector = createForegroundAgentContractCollector(candidate);
34
+ for (const row of rows.slice(0, beforeIndex)) collector.add(row);
35
+ return collector.finish();
36
+ }
37
+
38
+ // Keep the prior schema and version agreement without retaining transcript
39
+ // bodies. A snapshot taken before a call cannot be authorized by a later shape.
40
+ export function createForegroundAgentContractCollector(candidate) {
41
+ let shape = null, shapes = 0, version, mixedVersions = false;
42
+ return {
43
+ add(row) {
44
+ if (row.type === 'api-request-shape') { shapes++; if (shapes === 1) shape = row; }
45
+ if (row.version !== undefined) {
46
+ mixedVersions ||= version !== undefined && version !== row.version;
47
+ version = row.version;
48
+ }
49
+ },
50
+ finish() {
51
+ const row = shape;
52
+ if (shapes !== 1 || mixedVersions || version !== row.version || row.sessionId !== candidate.session || row.isSidechain === true ||
53
+ row.cwd !== undefined && row.cwd !== candidate.cwd ||
54
+ !/^[a-f0-9]{64}$/.test(row.shapeHash || '') || !/^\d+\.\d+\.\d+$/.test(row.version || '') ||
55
+ !Array.isArray(row.shape?.tools)) return null;
56
+ const agents = row.shape.tools.filter(tool => tool.name === 'Agent');
57
+ if (agents.length !== 1 || !isForegroundOnlyAgentSchema(agents[0].input_schema)) return null;
58
+ return { schema: agents[0].input_schema, shapeHash: row.shapeHash,
59
+ session: candidate.session, cwd: candidate.cwd, version: row.version ?? null };
60
+ },
61
+ };
62
+ }