@awak-app/simy-cli 0.6.30 → 0.6.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.
- package/package.json +3 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +24 -6
- package/src/delivery-hooks/desktop.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +8 -3
- package/src/delivery-hooks/local-runtime.js +65 -14
- package/src/desktop-host.js +24 -14
- package/src/session-supervision/decision-presentation.js +54 -0
- package/src/session-supervision/index.js +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.32",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"test:macos-installer": "node --test packaging/test-macos-packaging.mjs",
|
|
51
51
|
"build:macos-installer:production": "bash packaging/build-macos-arm64.sh --production",
|
|
52
52
|
"test:e2e:claude-parity": "node ./scripts/claude-parity-runtime-e2e.js",
|
|
53
|
-
"test:e2e:claude-plugin": "node ./scripts/claude-plugin-runtime-e2e.js"
|
|
53
|
+
"test:e2e:claude-plugin": "node ./scripts/claude-plugin-runtime-e2e.js",
|
|
54
|
+
"test:e2e:sqm-faults": "node ./scripts/sqm-complete-loop-e2e.js"
|
|
54
55
|
},
|
|
55
56
|
"engines": {
|
|
56
57
|
"node": ">=20"
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.6.
|
|
16
|
+
export const CLI_VERSION = "0.6.32";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -1810,15 +1810,33 @@ function reopenLegacyLimitStop(run, now) {
|
|
|
1810
1810
|
}
|
|
1811
1811
|
run.state = 'paused'; run.reason = 'SIMY no longer limits time or turns. Resume this task to continue.';
|
|
1812
1812
|
}
|
|
1813
|
-
|
|
1813
|
+
// Decision versions include explicit human controls, which must invalidate old
|
|
1814
|
+
// UI commands without fabricating provider checkpoint/progress revisions.
|
|
1815
|
+
export function advanceDeliveryControlRevision(run) {
|
|
1816
|
+
run.controlRevision = (run.controlRevision ?? 0) + 1;
|
|
1817
|
+
}
|
|
1818
|
+
export function deliveryDecisionRevision(run) {
|
|
1819
|
+
const controls = run?.controlRevision ?? 0;
|
|
1820
|
+
if (!Number.isSafeInteger(run?.revision) || run.revision < 0 || !Number.isSafeInteger(controls) || controls < 0) return undefined;
|
|
1821
|
+
const revision = run.revision + controls;
|
|
1822
|
+
return Number.isSafeInteger(revision) ? revision : undefined;
|
|
1823
|
+
}
|
|
1824
|
+
export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, expectedRunId, expectedRevision, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, purposeUpdate, action, inspectProcess = processIdentity, now = Date.now() }) {
|
|
1814
1825
|
for (let retry = 0; ; retry += 1) {
|
|
1815
1826
|
try { return await updateRecord(root, provider, session, async record => {
|
|
1816
1827
|
const run = record?.run;
|
|
1817
1828
|
if (!run) throw new Error('Delivery Loop not found.');
|
|
1829
|
+
if ((expectedRunId !== undefined || expectedRevision !== undefined) &&
|
|
1830
|
+
(typeof expectedRunId !== 'string' || !expectedRunId || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0 ||
|
|
1831
|
+
run.id !== expectedRunId || deliveryDecisionRevision(run) !== expectedRevision)) throw new Error('This decision changed. Refresh before choosing.');
|
|
1818
1832
|
if (record.providerArchived === true && !['stop', 'pause'].includes(action)) throw new Error('Restore the original provider conversation before changing Delivery Loop.');
|
|
1819
1833
|
if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
1820
1834
|
if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
|
|
1821
1835
|
if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
|
|
1836
|
+
const commitControl = () => {
|
|
1837
|
+
advanceDeliveryControlRevision(run);
|
|
1838
|
+
return record;
|
|
1839
|
+
};
|
|
1822
1840
|
reopenLegacyLimitStop(run, now);
|
|
1823
1841
|
if (action === 'revise_purpose') {
|
|
1824
1842
|
if (!searchEnabled(run.managed) || run.managed.resumeLease || run.state !== 'paused' || !text(purposeUpdate?.sourceRef, 500) || !text(purposeUpdate?.objective, 2000)) throw new Error('Pause the task and supply an explicit user purpose revision with sourceRef, objective and acceptanceCriteria.');
|
|
@@ -1843,20 +1861,20 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
1843
1861
|
run.managed.pendingApproval=null;delete run.managed.approvedAction;delete run.managed.purpose.lastReview;
|
|
1844
1862
|
run.managed.purpose.search={cycle:0,pivots:0,experiments:[],waits:[],observed:[],lastPivotCycle:0};
|
|
1845
1863
|
run.revision++;run.updatedAt=now;run.reason='User purpose revision recorded; prior evidence and exact approvals require re-evaluation. Resume explicitly within the existing budget.';
|
|
1846
|
-
run.managed.autoRecovery=false;return
|
|
1864
|
+
run.managed.autoRecovery=false;return commitControl();
|
|
1847
1865
|
} else if (action === 'continuation_policy') {
|
|
1848
1866
|
if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation policy.');
|
|
1849
1867
|
if (!['bounded', 'progress'].includes(continuationPolicy)) throw new Error('Select an explicit continuation policy.');
|
|
1850
1868
|
run.managed.continuationPolicy = continuationMode(continuationPolicy);
|
|
1851
1869
|
run.continuationPolicyHistory ||= [];
|
|
1852
1870
|
run.continuationPolicyHistory.push({ policy: continuationPolicy, at: now, source: 'explicit_controller_selection' });
|
|
1853
|
-
run.updatedAt = now; return
|
|
1871
|
+
run.updatedAt = now; return commitControl();
|
|
1854
1872
|
} else if (action === 'disable_auto_continue') {
|
|
1855
1873
|
if (!run.managed?.purpose) throw new Error('Purpose continuation is not enabled.');
|
|
1856
1874
|
run.managed.purpose.enabled = false; run.managed.autoRecovery = false;
|
|
1857
1875
|
if (!TERMINAL.has(run.state)) { run.state = 'paused'; run.reason = 'Automatic continuation paused by user.'; }
|
|
1858
1876
|
run.updatedAt = now;
|
|
1859
|
-
return
|
|
1877
|
+
return commitControl();
|
|
1860
1878
|
} else if (action === 'dependency_ready') {
|
|
1861
1879
|
const wait = run.managed?.dependencyWait;
|
|
1862
1880
|
if (run.state !== 'blocked' || run.blocker !== 'external_dependency' || !wait || wait.id !== waitId ||
|
|
@@ -1902,8 +1920,8 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
1902
1920
|
else { run.state = 'recovery_required'; run.recoveryCause = 'policy_change'; run.reason = 'Policy changed; resume the original conversation for the selected action.'; run.managed.autoRecovery = false; }
|
|
1903
1921
|
}
|
|
1904
1922
|
else if (action === 'stop') {
|
|
1905
|
-
run.stopReason = { actor: 'user', code: 'explicit_stop', at: now };
|
|
1906
1923
|
if (TERMINAL.has(run.state)) return record;
|
|
1924
|
+
run.stopReason = { actor: 'user', code: 'explicit_stop', at: now };
|
|
1907
1925
|
run.state = 'stopped'; run.reason = 'Stopped by user.';
|
|
1908
1926
|
}
|
|
1909
1927
|
else if (action === 'pause') {
|
|
@@ -1997,7 +2015,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
1997
2015
|
rememberRejectedAction(run.managed, pending);
|
|
1998
2016
|
}
|
|
1999
2017
|
} else throw new Error('Unsupported control.');
|
|
2000
|
-
run.updatedAt = now; notifyState(run, now); return
|
|
2018
|
+
run.updatedAt = now; notifyState(run, now); return commitControl();
|
|
2001
2019
|
}); } catch (error) {
|
|
2002
2020
|
// Explicit user controls wait briefly for a live short state writer;
|
|
2003
2021
|
// they never reclaim unknown owners or broaden execution authority.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { canReplanDelivery, purposeProgressAt, pendingAcceptance, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { canReplanDelivery, deliveryDecisionRevision, purposeProgressAt, pendingAcceptance, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
4
|
import { originalSessionTarget } from '../session-supervision/original-session-target.js';
|
|
5
5
|
|
|
6
6
|
export const HOOK_SETUP = Object.freeze({
|
|
@@ -30,7 +30,7 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
30
30
|
})),
|
|
31
31
|
runs: records.filter(r => r.run && r.providerArchived !== true).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
|
|
32
32
|
provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
|
|
33
|
-
objective: r.run.objective, state: r.run.state, reason: r.run.reason,
|
|
33
|
+
objective: r.run.objective, state: r.run.state, reason: r.run.reason, runId: r.run.id, revision: deliveryDecisionRevision(r.run), recoveryCause: r.run.recoveryCause, autoRecovery: r.run.managed?.autoRecovery === true,
|
|
34
34
|
continuations: r.run.continuations,
|
|
35
35
|
continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
|
|
36
36
|
startedAt: r.run.startedAt,
|
|
@@ -6,7 +6,7 @@ import { pinnedWorkflowSource } from './local-runtime.js';
|
|
|
6
6
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
7
7
|
// Exact shipped 0.6.23 and 0.6.24 entrypoints only. Customized hooks are retained.
|
|
8
8
|
const shipped = new Set(['8a454293d119eac7cdb46c2df70c6b54a431fcf575bbf4f48f3373ca14414f3b', '339dc64d7ac980f9bfdafd1b2feea47ef8e86b2895b9d6e040086b005a806a79']);
|
|
9
|
-
|
|
9
|
+
const originalLegacyRoutingShim = `// SIMY managed immutable-runtime route v1. Original exports retained for compatibility.
|
|
10
10
|
export * from './delivery-loop.pre-runtime-route.mjs';
|
|
11
11
|
import { readFile, readdir, lstat } from 'node:fs/promises';
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
@@ -34,6 +34,10 @@ export async function main(provider) {
|
|
|
34
34
|
} catch { process.stdout.write('{}\\n'); process.stderr.write('SIMY runtime route unavailable; original task state was preserved.\\n'); }
|
|
35
35
|
}
|
|
36
36
|
`;
|
|
37
|
+
export const legacyRoutingShim = originalLegacyRoutingShim.replace(
|
|
38
|
+
"for (const input of ['package.json', 'package-lock.json', 'README.md', 'src']) await visit(input);",
|
|
39
|
+
"for (const input of ['package.json', 'package-lock.json', 'README.md', 'src']) { try { await visit(input); } catch (error) { if (input === 'package-lock.json' && error.code === 'ENOENT') hash.update('packaged-without-lock\\0'); else throw error; } } if (pin.fingerprintVersion === 2) await visit('node_modules');",
|
|
40
|
+
);
|
|
37
41
|
export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
|
|
38
42
|
await pinnedWorkflowSource({ root });
|
|
39
43
|
const folder = join(codexHome, 'plugins/cache/personal/simy');
|
|
@@ -47,9 +51,10 @@ export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOM
|
|
|
47
51
|
if (manifest.name !== 'simy' || manifest.version !== version || (await lstat(file)).isSymbolicLink()) continue;
|
|
48
52
|
const bytes = await readFile(file), before = digest(bytes), next = digest(legacyRoutingShim);
|
|
49
53
|
if (before === next) { receipts.push({ version, outcome: 'current', routeDigest: next }); continue; }
|
|
50
|
-
|
|
54
|
+
const managedV1 = before === digest(originalLegacyRoutingShim);
|
|
55
|
+
if (!shipped.has(before) && !managedV1) { receipts.push({ version, outcome: 'customized_retained', originalDigest: before }); continue; }
|
|
51
56
|
const backup = join(cache, 'hooks/delivery-loop.pre-runtime-route.mjs');
|
|
52
|
-
try { await writeFile(backup, bytes, { mode: 0o600, flag: 'wx' }); }
|
|
57
|
+
try { if (!managedV1) await writeFile(backup, bytes, { mode: 0o600, flag: 'wx' }); }
|
|
53
58
|
catch (error) { if (error.code !== 'EEXIST' || digest(await readFile(backup)) !== before) throw error; }
|
|
54
59
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
55
60
|
await writeFile(temporary, legacyRoutingShim, { mode: 0o600 }); await rename(temporary, file);
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
-
import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { acquireDesktopProcessLock } from '../desktop-process-lock.js';
|
|
8
|
+
import { compareVersions } from '../backend-executable.js';
|
|
7
9
|
import { stableHookRuntime } from '../hook-runtime.js';
|
|
8
10
|
|
|
9
11
|
const exec = promisify(execFile);
|
|
10
12
|
const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
11
13
|
const inputs = ['package.json', 'package-lock.json', 'README.md', 'src'];
|
|
12
14
|
|
|
13
|
-
async function fingerprint(source, { allowMissingLock = false } = {}) {
|
|
15
|
+
async function fingerprint(source, { allowMissingLock = false, includeDependencies = false } = {}) {
|
|
14
16
|
const hash = createHash('sha256');
|
|
15
17
|
async function visit(relative) {
|
|
16
18
|
const target = join(source, relative);
|
|
@@ -29,6 +31,7 @@ async function fingerprint(source, { allowMissingLock = false } = {}) {
|
|
|
29
31
|
else throw error;
|
|
30
32
|
}
|
|
31
33
|
}
|
|
34
|
+
if (includeDependencies) await visit('node_modules');
|
|
32
35
|
return hash.digest('hex');
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -40,12 +43,17 @@ async function atomicJson(file, value) {
|
|
|
40
43
|
|
|
41
44
|
function quote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
42
45
|
|
|
43
|
-
export async function localRuntimeStatus({ root, source = packageRoot, allowMissingLock = false } = {}) {
|
|
46
|
+
export async function localRuntimeStatus({ root, source = packageRoot, allowMissingLock = false, includeDependencies } = {}) {
|
|
44
47
|
const pkg = JSON.parse(await readFile(join(source, 'package.json'), 'utf8'));
|
|
48
|
+
if (includeDependencies === undefined) {
|
|
49
|
+
try { includeDependencies = JSON.parse(await readFile(join(source, 'runtime-build.json'), 'utf8')).fingerprintVersion === 2; }
|
|
50
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; includeDependencies = false; }
|
|
51
|
+
}
|
|
52
|
+
if (includeDependencies) allowMissingLock = true;
|
|
45
53
|
let installation = null;
|
|
46
54
|
try { installation = JSON.parse(await readFile(join(root, 'delivery-runtime', 'current.json'), 'utf8')); }
|
|
47
55
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
48
|
-
return { version: pkg.version, source: resolve(source), sourceDigest: await fingerprint(source, { allowMissingLock }),
|
|
56
|
+
return { version: pkg.version, source: resolve(source), sourceDigest: await fingerprint(source, { allowMissingLock, includeDependencies }),
|
|
49
57
|
purposeVersion: 2, decisionPolicy: 'llm-first-hc102', stateContractVersion: 2, consistencyMonitorVersion: 1, installation };
|
|
50
58
|
}
|
|
51
59
|
|
|
@@ -58,14 +66,51 @@ export async function pinnedWorkflowSource({ root, source = packageRoot } = {})
|
|
|
58
66
|
if (resolve(current.release || '') !== expected) throw new Error('Local workflow pin points outside its immutable releases.');
|
|
59
67
|
const info = await lstat(expected);
|
|
60
68
|
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Pinned workflow source must be a regular directory.');
|
|
61
|
-
const status = await localRuntimeStatus({ root, source: expected });
|
|
69
|
+
const status = await localRuntimeStatus({ root, source: expected, allowMissingLock: true, includeDependencies: current.fingerprintVersion === 2 });
|
|
62
70
|
if (status.sourceDigest !== current.digest || status.version !== current.version) throw new Error('Pinned workflow runtime failed integrity verification.');
|
|
63
71
|
return resolve(source) === expected ? null : expected;
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
// Only the installer-selected bundle can advance an older local pin. Never
|
|
75
|
+
// adopt a checkout, downgrade a candidate, or touch task/provider state.
|
|
76
|
+
export async function adoptInstalledRuntime({ root, source = packageRoot, codexHome } = {}) {
|
|
77
|
+
let selected;
|
|
78
|
+
try { selected = await realpath(join(root, 'installer/current/Contents/Resources/cli')); }
|
|
79
|
+
catch (error) { if (error.code === 'ENOENT') return { adopted: false }; throw error; }
|
|
80
|
+
if (await realpath(source) !== selected) return { adopted: false };
|
|
81
|
+
const status = await localRuntimeStatus({ root, source, allowMissingLock: true, includeDependencies: false });
|
|
82
|
+
if (!status.installation || compareVersions(status.version, status.installation.version) <= 0) return { adopted: false };
|
|
83
|
+
// Validate the existing pin before replacing it; corruption is not an upgrade.
|
|
84
|
+
await pinnedWorkflowSource({ root, source });
|
|
85
|
+
const owner = await acquireDesktopProcessLock(root);
|
|
86
|
+
try {
|
|
87
|
+
const { routeLegacyHooks } = await import('./legacy-hook-routing.js');
|
|
88
|
+
await routeLegacyHooks({ root, ...(codexHome ? { codexHome } : {}) });
|
|
89
|
+
const installation = await installLocalRuntime({ root, source, allowMissingLock: true, includeDependencies: true,
|
|
90
|
+
expectedInstallationDigest: status.installation.digest,
|
|
91
|
+
verifyBeforeActivate: async () => {
|
|
92
|
+
if (await realpath(join(root, 'installer/current/Contents/Resources/cli')) !== selected) throw new Error('Installer selection changed during runtime adoption.');
|
|
93
|
+
},
|
|
94
|
+
installDependencies: async target => {
|
|
95
|
+
// Use the packaged dependencies without network or lifecycle scripts.
|
|
96
|
+
await cp(join(source, 'node_modules'), join(target, 'node_modules'), { recursive: true, dereference: true });
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
// A locally activated candidate is a separate explicit activation contract.
|
|
100
|
+
// Retain its receipt, but prevent watchdog replay of the superseded source.
|
|
101
|
+
const activeFile = join(root, 'desktop-runtime/active.json');
|
|
102
|
+
try {
|
|
103
|
+
const active = JSON.parse(await readFile(activeFile, 'utf8'));
|
|
104
|
+
if (active.digest !== installation.digest && !active.suspendedBy) await atomicJson(activeFile, { ...active, suspendedBy: 'runtime_upgrade_requires_activation', supersededBy: installation.digest });
|
|
105
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
106
|
+
return { adopted: true, installation };
|
|
107
|
+
} finally { await owner.release(); }
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
// Install a frozen candidate alongside the released app, without replacing a
|
|
67
111
|
// signed application, changing provider trust, or starting any registered task.
|
|
68
|
-
export async function installLocalRuntime({ root, source = packageRoot,
|
|
112
|
+
export async function installLocalRuntime({ root, source = packageRoot, allowMissingLock = false, includeDependencies = false,
|
|
113
|
+
expectedInstallationDigest, verifyBeforeActivate = async () => {},
|
|
69
114
|
installDependencies = async target => exec(process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
|
70
115
|
['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: target, timeout: 120_000, maxBuffer: 1024 * 1024 }),
|
|
71
116
|
} = {}) {
|
|
@@ -76,7 +121,8 @@ export async function installLocalRuntime({ root, source = packageRoot,
|
|
|
76
121
|
await mkdir(lock); // An existing install is not silently stolen.
|
|
77
122
|
const staged = join(storage, `.staged-${randomUUID()}`);
|
|
78
123
|
try {
|
|
79
|
-
const before = await localRuntimeStatus({ root, source });
|
|
124
|
+
const before = await localRuntimeStatus({ root, source, allowMissingLock, includeDependencies });
|
|
125
|
+
if (expectedInstallationDigest && before.installation?.digest !== expectedInstallationDigest) throw new Error('Runtime selection changed before adoption.');
|
|
80
126
|
const digest = before.sourceDigest;
|
|
81
127
|
const release = join(storage, 'releases', digest);
|
|
82
128
|
await mkdir(dirname(release), { recursive: true, mode: 0o700 });
|
|
@@ -88,16 +134,21 @@ export async function installLocalRuntime({ root, source = packageRoot,
|
|
|
88
134
|
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
89
135
|
if (!exists) {
|
|
90
136
|
await mkdir(staged, { mode: 0o700 });
|
|
91
|
-
for (const input of inputs)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
137
|
+
for (const input of inputs) {
|
|
138
|
+
try { await cp(join(source, input), join(staged, input), { recursive: true, errorOnExist: true }); }
|
|
139
|
+
catch (error) { if (!(allowMissingLock && input === 'package-lock.json' && error.code === 'ENOENT')) throw error; }
|
|
140
|
+
}
|
|
141
|
+
if (includeDependencies) await installDependencies(staged);
|
|
142
|
+
if (await fingerprint(staged, { allowMissingLock, includeDependencies }) !== digest || await fingerprint(source, { allowMissingLock, includeDependencies }) !== digest) throw new Error('Runtime source changed while copying; no candidate activated.');
|
|
143
|
+
if (!includeDependencies) await installDependencies(staged);
|
|
144
|
+
if (await fingerprint(staged, { allowMissingLock, includeDependencies }) !== digest) throw new Error('Dependency installation changed the runtime source.');
|
|
145
|
+
await atomicJson(join(staged, 'runtime-build.json'), { schema: 1, version: before.version, digest, fingerprintVersion: includeDependencies ? 2 : 1 });
|
|
96
146
|
await rename(staged, release);
|
|
97
147
|
} else {
|
|
98
148
|
const saved = JSON.parse(await readFile(join(release, 'runtime-build.json'), 'utf8'));
|
|
99
|
-
if (saved.schema !== 1 || saved.digest !== digest || await fingerprint(release) !== digest) throw new Error('Existing candidate failed integrity verification.');
|
|
149
|
+
if (saved.schema !== 1 || saved.digest !== digest || await fingerprint(release, { allowMissingLock, includeDependencies }) !== digest) throw new Error('Existing candidate failed integrity verification.');
|
|
100
150
|
}
|
|
151
|
+
await verifyBeforeActivate();
|
|
101
152
|
const bin = join(root, 'bin');
|
|
102
153
|
await mkdir(bin, { recursive: true, mode: 0o700 });
|
|
103
154
|
const launcher = join(bin, 'simy-delivery');
|
|
@@ -114,7 +165,7 @@ export async function installLocalRuntime({ root, source = packageRoot,
|
|
|
114
165
|
await writeFile(cliTemporary, `#!/bin/sh\nexec ${quote(stableHookRuntime())} ${quote(join(release, 'src/index.js'))} --no-auto-update "$@"\n`, { mode: 0o700 });
|
|
115
166
|
await rename(cliTemporary, cli);
|
|
116
167
|
} finally { await rm(cliTemporary, { force: true }); }
|
|
117
|
-
const current = { schema: 1, version: before.version, digest, release, launcher, cli,
|
|
168
|
+
const current = { schema: 1, version: before.version, digest, release, launcher, cli, fingerprintVersion: includeDependencies ? 2 : 1,
|
|
118
169
|
previous: before.installation?.digest === digest ? before.installation.previous
|
|
119
170
|
: before.installation ? { version: before.installation.version, digest: before.installation.digest, release: before.installation.release } : null };
|
|
120
171
|
await atomicJson(join(storage, 'current.json'), current);
|
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionDecisionPresentation } from './session-supervision/decision-presentation.js';
|
|
1
2
|
import { PROVIDER_LINKS, readProviderLinks, writeProviderLink, requireProviderLink } from "./provider-links.js";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { readCodexStatistics, unavailableStatistics } from './session-supervision/statistics.js';
|
|
@@ -32,7 +33,7 @@ import { startAgent } from "./agent.js";
|
|
|
32
33
|
import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
|
|
33
34
|
import { suspendDesktopWatchdog } from './delivery-hooks/runtime-watchdog.js';
|
|
34
35
|
import { attestDesktopRuntime } from './delivery-hooks/desktop-runtime.js';
|
|
35
|
-
import { pinnedWorkflowSource } from './delivery-hooks/local-runtime.js';
|
|
36
|
+
import { adoptInstalledRuntime, pinnedWorkflowSource } from './delivery-hooks/local-runtime.js';
|
|
36
37
|
import { applyLoginShellPath } from "./login-shell-path.js";
|
|
37
38
|
import { createCliAutoUpdater, readCliPackageInfo } from "./auto-update.js";
|
|
38
39
|
import { createAgentShutdown } from "./shutdown.js";
|
|
@@ -695,7 +696,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
695
696
|
const run = runsBySession.get(`${session.provider}:${session.session}`);
|
|
696
697
|
if (run) run.actionDeadline = session.actionDeadline;
|
|
697
698
|
}
|
|
698
|
-
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions
|
|
699
|
+
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: sessions.map(candidate => ({ ...candidate,
|
|
700
|
+
decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)) })) };
|
|
699
701
|
const latestAccess = await accessSnapshot();
|
|
700
702
|
if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
|
|
701
703
|
!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
|
|
@@ -1172,7 +1174,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1172
1174
|
await (dependencies.openHookSettings || openHookSetup)(provider);
|
|
1173
1175
|
} else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "delivery_continuation_policy"].includes(action)) {
|
|
1174
1176
|
await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
1175
|
-
taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
|
|
1177
|
+
taskId: command.taskId, expectedRunId: command.expectedRunId, expectedRevision: command.expectedRevision, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
|
|
1176
1178
|
} else if (action === "delivery_verify") {
|
|
1177
1179
|
await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
1178
1180
|
taskId: command.taskId, criterionId: command.criterionId });
|
|
@@ -1334,7 +1336,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1334
1336
|
await refreshSessions(true);
|
|
1335
1337
|
} else if (action === "session_answer") {
|
|
1336
1338
|
await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
1337
|
-
session: command.session, taskId: command.taskId, questionId: command.questionId, answer: command.answer });
|
|
1339
|
+
session: command.session, taskId: command.taskId, expectedRunId: command.expectedRunId, expectedRevision: command.expectedRevision, questionId: command.questionId, answer: command.answer });
|
|
1338
1340
|
await refreshSessions(true);
|
|
1339
1341
|
}
|
|
1340
1342
|
else throw new Error("Unsupported desktop action.");
|
|
@@ -1463,6 +1465,15 @@ export function isDesktopHostEntry(entry = process.argv[1]) {
|
|
|
1463
1465
|
catch { return false; }
|
|
1464
1466
|
}
|
|
1465
1467
|
|
|
1468
|
+
function desktopStartupError(error, fallbackMessage = "SIMY could not start. Check whether another SIMY window is already running, then reconnect.") {
|
|
1469
|
+
const conflict = error?.code === PROCESS_CONFLICT_CODE;
|
|
1470
|
+
return {
|
|
1471
|
+
type: "error",
|
|
1472
|
+
...(conflict ? { code: PROCESS_CONFLICT_CODE, conflicts: error.conflicts || [] } : {}),
|
|
1473
|
+
message: conflict ? PROCESS_CONFLICT_MESSAGE : fallbackMessage,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1466
1477
|
export function startDesktopEntry() {
|
|
1467
1478
|
let host;
|
|
1468
1479
|
const stop = () => { void host?.close().finally(() => process.exit(0)); };
|
|
@@ -1473,14 +1484,7 @@ export function startDesktopEntry() {
|
|
|
1473
1484
|
captureRuntimeOutput(diagnostics, { stderr: process.stderr });
|
|
1474
1485
|
applyLoginShellPath({ diagnostics }).catch(() => {}).then(() => startDesktopHost()).then((value) => { host = value; }).catch((error) => {
|
|
1475
1486
|
diagnostics.write(`startup failed: ${error?.stack || error}`);
|
|
1476
|
-
|
|
1477
|
-
process.stdout.write(`${JSON.stringify({
|
|
1478
|
-
type: "error",
|
|
1479
|
-
...(conflict ? { code: PROCESS_CONFLICT_CODE, conflicts: error.conflicts || [] } : {}),
|
|
1480
|
-
message: conflict
|
|
1481
|
-
? PROCESS_CONFLICT_MESSAGE
|
|
1482
|
-
: "SIMY could not start. Check whether another SIMY window is already running, then reconnect.",
|
|
1483
|
-
})}\n`);
|
|
1487
|
+
process.stdout.write(`${JSON.stringify(desktopStartupError(error))}\n`);
|
|
1484
1488
|
process.exitCode = 1;
|
|
1485
1489
|
});
|
|
1486
1490
|
}
|
|
@@ -1488,14 +1492,20 @@ export function startDesktopEntry() {
|
|
|
1488
1492
|
if (isDesktopHostEntry()) {
|
|
1489
1493
|
try {
|
|
1490
1494
|
const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
|
|
1495
|
+
try { await adoptInstalledRuntime({ root }); }
|
|
1496
|
+
catch (error) {
|
|
1497
|
+
if (error.code === PROCESS_CONFLICT_CODE) throw error;
|
|
1498
|
+
await pinnedWorkflowSource({ root }); // Only a verified previous runtime may recover.
|
|
1499
|
+
process.stderr.write('SIMY runtime upgrade deferred: ' + error.message + '\n');
|
|
1500
|
+
}
|
|
1491
1501
|
const selected = await pinnedWorkflowSource({ root });
|
|
1492
1502
|
if (selected) {
|
|
1493
1503
|
process.env.SIMY_AUTO_UPDATE = '0';
|
|
1494
1504
|
const selectedHost = await import(pathToFileURL(join(selected, 'src/desktop-host.js')).href);
|
|
1495
1505
|
selectedHost.startDesktopEntry();
|
|
1496
1506
|
} else startDesktopEntry();
|
|
1497
|
-
} catch {
|
|
1498
|
-
process.stdout.write(JSON.stringify(
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
process.stdout.write(JSON.stringify(desktopStartupError(error, 'Local desktop runtime integrity could not be verified. Inspect delivery runtime status; the previous task state was retained.')) + '\n');
|
|
1499
1509
|
process.exitCode = 1;
|
|
1500
1510
|
}
|
|
1501
1511
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const clean = value => typeof value === 'string' ? value.trim().slice(0, 2000) : '';
|
|
2
|
+
|
|
3
|
+
// The presentation never invents consent or executes a choice. Commands still
|
|
4
|
+
// validate the exact live proposal, question, run identity and revision.
|
|
5
|
+
export function sessionDecisionPresentation(candidate, run) {
|
|
6
|
+
if (!candidate || candidate.isRunning || candidate.archived) return null;
|
|
7
|
+
const identity = { provider: candidate.provider, session: candidate.session };
|
|
8
|
+
const control = { ...identity, ...(run?.taskId ? { taskId: run.taskId } : {}),
|
|
9
|
+
...(run?.runId ? { expectedRunId: run.runId, expectedRevision: run.revision } : {}) };
|
|
10
|
+
const choice = (label, effect, action, fields = {}) => ({ label, effect, action, fields: { ...control, ...fields } });
|
|
11
|
+
const frozen = typeof run?.runId === 'string' && run.runId.length > 0 && Number.isSafeInteger(run.revision) && run.revision >= 0;
|
|
12
|
+
const refreshDecision = () => ({ kind: 'unresolved', title: '判断の状態を再確認します',
|
|
13
|
+
question: '現在の作業と判断の対応を確認できません。最新の状態を取得してから選んでください。',
|
|
14
|
+
context: 'この確認だけでは操作を許可したり、作業を再開したりしません。',
|
|
15
|
+
choices: [choice('状況を再確認する', '最新の状態を取得します。許可・停止状態は変更しません。', 'refresh', identity)] });
|
|
16
|
+
const approval = run?.pendingApproval;
|
|
17
|
+
if (approval?.id && clean(approval.summary) && !approval.denied && run.canApprove && !frozen) return refreshDecision();
|
|
18
|
+
if (approval?.id && clean(approval.summary) && !approval.denied && run.canApprove) return {
|
|
19
|
+
kind: 'approval', title: 'この操作を許可しますか?',
|
|
20
|
+
question: `実行する操作:${clean(approval.summary)}`, context: `対象:${({ task: 'この依頼の範囲', repository: 'この作業のリポジトリ' })[approval.scope] || clean(approval.scope) || '依頼された操作の範囲'}`,
|
|
21
|
+
choices: [choice('この操作だけを許可', '表示された対象・操作だけを許可します。AI側で権限確認が必要な場合は別途案内します。', 'delivery_approve', { approvalId: approval.id }),
|
|
22
|
+
choice('許可しない', run.canReplan ? 'この操作は実行しません。別の方法を調べる場合は「AIに状況確認を任せる」を選んでください。' : 'この操作は実行しません。別の方法が必要な場合は、元のAI会話で確認してください。', 'delivery_reject', { approvalId: approval.id })],
|
|
23
|
+
};
|
|
24
|
+
const question = run?.understandingQuestion;
|
|
25
|
+
if (run?.canAnswer && question?.id && clean(question.text) && !frozen) return refreshDecision();
|
|
26
|
+
if (run?.canAnswer && question?.id && clean(question.text)) return {
|
|
27
|
+
kind: 'question', title: '続けるために、この点を教えてください', question: clean(question.text),
|
|
28
|
+
context: '回答を受け取ってから目的を確認し、許可された作業を続けます。',
|
|
29
|
+
answerFields: { ...control, taskId: run.taskId, questionId: question.id },
|
|
30
|
+
choices: [],
|
|
31
|
+
};
|
|
32
|
+
if (run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
|
|
33
|
+
['supervising', 'watching', 'waiting'].includes(candidate.autoContinueStatus)) return null;
|
|
34
|
+
if (run && ['recovery_required', 'paused', 'failed', 'blocked'].includes(run.state)) {
|
|
35
|
+
const providerName = candidate.provider === 'claude' ? 'Claude' : candidate.provider === 'codex' ? 'Codex' : 'AI';
|
|
36
|
+
const context = run.state === 'paused' ? 'この作業は停止中です。停止の指示は自動で解除しません。' : ({ session_end: `元の${providerName}会話が終了し、自動継続が止まっています。`,
|
|
37
|
+
process_exit: '元のAIが終了したため、自動継続できていません。', provider_error: 'AIの実行でエラーが発生し、作業が止まっています。',
|
|
38
|
+
shutdown_cancelled: 'SIMYの終了時に作業が中断されました。', hook_owner_mismatch: '元のAI会話の接続が変わりました。' })[run.recoveryCause]
|
|
39
|
+
|| '作業を続けるための条件を確認できていません。';
|
|
40
|
+
const choices = [];
|
|
41
|
+
if (run.canReplan && frozen) choices.push(choice('AIに状況確認を任せる', '目的・履歴・制限を維持して原因と安全な次の手順を調べます。操作の許可を追加することはありません。', 'delivery_replan'));
|
|
42
|
+
if (run.canResume && frozen) choices.push(choice('再開を依頼する', '保存した目的と履歴を引き継ぎます。接続や権限が不足する場合は、その内容を案内します。', 'delivery_resume'));
|
|
43
|
+
choices.push(choice(`元の${candidate.provider === 'claude' ? 'Claude' : 'Codex'}会話を開く`, '接続や権限の確認が必要な場合、元のAI会話で確認できます。会話を開くだけでは作業を再開しません。', 'open_original'));
|
|
44
|
+
if (candidate.autoContinue === true) choices.push(choice('自動継続をオフにする', '今後の自動継続をオフにします。作業の履歴は残ります。', 'session_set_auto_continue', { ...identity, enabled: false }));
|
|
45
|
+
return { kind: 'recovery', title: '作業が止まっています。対応を選んでください', question: frozen && (run.canReplan || run.canResume) ? (run.canReplan ? '選ぶこと:AIに状況確認を任せるか、元の会話を確認するか。' : '選ぶこと:再開を依頼するか、元の会話を確認するか。') : 'この画面からの再開には対応していません。元のAI会話で状態を確認できます。', context: `理由:${context}`, choices };
|
|
46
|
+
}
|
|
47
|
+
if (candidate.autoContinueStatus === 'needs_user' || candidate.analysis?.state === 'needs_user') return {
|
|
48
|
+
kind: 'unresolved', title: '必要な判断内容を確認します',
|
|
49
|
+
question: '判断していただく内容をまだ特定できていません。具体的な内容が分かるまで、操作の許可は求めません。',
|
|
50
|
+
context: 'まず会話と最新の状態を確認します。元のAIが権限の確認を求めている場合は、その会話で確認が必要です。',
|
|
51
|
+
choices: [choice('状況を再確認する', '最新の状態を取得します。許可・停止状態は変更しません。', 'refresh', identity)],
|
|
52
|
+
};
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
@@ -4,7 +4,7 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
|
|
|
4
4
|
import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
5
5
|
import { realpath } from 'node:fs/promises';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { TERMINAL as TERMINAL_RUNS, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
7
|
+
import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
8
8
|
import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
|
|
9
9
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
10
10
|
import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
@@ -404,6 +404,9 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
404
404
|
if (next.run.state === 'paused' && next.run.reason === 'Automatic continuation paused by user.') { next.run.state = 'recovery_required'; next.run.recoveryCause = 'session_supervision'; next.run.managed.autoRecovery = true; }
|
|
405
405
|
} else if (!enabled && next.run.managed.purpose) { if (next.run.state !== 'auto_resuming') next.run.managed.purpose.enabled = false; next.run.managed.autoRecovery = false; if (!TERMINAL_RUNS.has(next.run.state) && next.run.state !== 'auto_resuming') { next.run.state = 'paused'; next.run.reason = 'Automatic continuation paused by user.'; } }
|
|
406
406
|
}
|
|
407
|
+
// The desktop preference is an explicit human control too: an old decision
|
|
408
|
+
// card must not undo this switch, even when no provider checkpoint arrived.
|
|
409
|
+
if (next.run) advanceDeliveryControlRevision(next.run);
|
|
407
410
|
return next;
|
|
408
411
|
});
|
|
409
412
|
if (enabled && updated.run && !updated.run.managed) {
|
|
@@ -524,6 +527,6 @@ export async function continueSession(options) {
|
|
|
524
527
|
acceptanceCriteria: [], contextBootstrap: true });
|
|
525
528
|
}
|
|
526
529
|
|
|
527
|
-
export async function answerSession({ root, provider, session, taskId, questionId, answer, now = Date.now() }) {
|
|
528
|
-
return controlDelivery({ root, provider, session, taskId, questionId, answer, action: 'session_answer', now });
|
|
530
|
+
export async function answerSession({ root, provider, session, taskId, expectedRunId, expectedRevision, questionId, answer, now = Date.now() }) {
|
|
531
|
+
return controlDelivery({ root, provider, session, taskId, expectedRunId, expectedRevision, questionId, answer, action: 'session_answer', now });
|
|
529
532
|
}
|