@awak-app/simy-cli 0.7.23 → 0.7.26
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/README.md +6 -0
- package/package.json +2 -2
- package/src/agent.js +16 -1
- package/src/auto-update.js +3 -2
- package/src/browser-handoff.js +14 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
- package/src/connection-health.js +253 -0
- package/src/connection-overview.js +58 -0
- package/src/connection-presentation.js +85 -0
- package/src/connection-recovery.js +102 -0
- package/src/console/app.js +60 -6
- package/src/console/commands.js +3 -0
- package/src/console/index.js +1 -0
- package/src/delivery-hooks/legacy-hook-routing.js +24 -0
- package/src/delivery-hooks/supervisor.js +14 -14
- package/src/desktop-host.js +43 -8
- package/src/desktop-status.js +9 -0
- package/src/direct-executor.js +8 -2
- package/src/doctor.js +1 -0
- package/src/index.js +12 -2
- package/src/remote-connection-guide.js +222 -0
- package/src/session-supervision/analysis-manager.js +24 -4
- package/src/session-supervision/automatic-continuation.js +69 -18
- package/src/session-supervision/codex-app-server.js +52 -7
- package/src/session-supervision/codex-peer-opinion.js +27 -5
- package/src/session-supervision/consistency-auditor.js +1 -1
- package/src/session-supervision/continuation-analysis.js +7 -3
- package/src/session-supervision/decision-presentation.js +1 -1
- package/src/session-supervision/index.js +21 -13
- package/src/session-supervision/outcome-plan.js +76 -0
- package/src/session-supervision/purpose-instruction.js +28 -0
- package/src/session-supervision/recovery-frontier.js +14 -4
- package/src/session-supervision/remote-command-queue.js +15 -0
- package/src/session-supervision/remote-control.js +3 -0
- package/src/session-supervision/remote-hosts.js +70 -19
- package/src/session-supervision/remote-managed-delivery.js +94 -0
- package/src/session-supervision/remote-session-list.js +1 -1
- package/src/session-supervision/statistics.js +10 -3
- package/src/session-supervision/work-state.js +5 -0
- package/src/sqm/bundle-store.js +30 -2
- package/src/sqm/provider-plugin-status.js +21 -3
- package/src/web-origin.js +7 -0
package/README.md
CHANGED
|
@@ -1425,3 +1425,9 @@ interrupted write or approve a pending external action. Explicit task
|
|
|
1425
1425
|
stop/pause, provider permission prompts and unanswered user questions remain
|
|
1426
1426
|
boundaries. `runtime supervisor-disable` is a durable explicit stop and is never
|
|
1427
1427
|
reversed by waking or booting. A later explicit enable is required.
|
|
1428
|
+
|
|
1429
|
+
A recovery read can inspect the one unknown operation it is bound to while
|
|
1430
|
+
that operation continues to block writes. Observing the result settles local
|
|
1431
|
+
occupancy without accepting the task. Long-running tasks transmit current
|
|
1432
|
+
recovery context and recent work; the full history remains in durable state
|
|
1433
|
+
and can be inspected through `delivery status`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.26",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
|
|
40
40
|
"test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
|
|
41
41
|
"check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
|
|
42
|
-
"check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js",
|
|
42
|
+
"check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js && node --check ./src/session-supervision/remote-managed-delivery.js",
|
|
43
43
|
"check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
|
|
44
44
|
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
45
45
|
"check:sqm": "node --check ./src/sqm/provider-plugin-repair.js && node --check ./src/sqm/provider-plugin-status.js && node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
package/src/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
|
|
1
2
|
import { readLocalPhotoSource } from "./local-photos.js";
|
|
2
3
|
import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
|
|
3
4
|
import { observePairedNativeIdentities } from './native-provider-identity.js';
|
|
@@ -1217,7 +1218,7 @@ export async function startAgent({
|
|
|
1217
1218
|
|
|
1218
1219
|
if (req.method === "GET" && url.pathname === "/v1/auth/browser") {
|
|
1219
1220
|
if (req.headers.host !== `127.0.0.1:${port}`) { json(res, 403, { error: "Invalid local host." }); return; }
|
|
1220
|
-
const page = browserHandoffPage();
|
|
1221
|
+
const page = browserHandoffPage(apiOrigin);
|
|
1221
1222
|
res.writeHead(200, page.headers).end(page.html);
|
|
1222
1223
|
return;
|
|
1223
1224
|
}
|
|
@@ -1516,6 +1517,7 @@ export async function startAgent({
|
|
|
1516
1517
|
const previousSession = session ?? reclaimableSession;
|
|
1517
1518
|
const authorizedSession = {
|
|
1518
1519
|
token: String(body.token || ""),
|
|
1520
|
+
email: typeof body.email === "string" && body.email.length <= 254 ? body.email : null,
|
|
1519
1521
|
device_id: typeof body.device_id === "string" ? body.device_id : null,
|
|
1520
1522
|
api_origin: apiOrigin,
|
|
1521
1523
|
api_base_url: apiBaseUrl,
|
|
@@ -2857,8 +2859,21 @@ export async function startAgent({
|
|
|
2857
2859
|
const { codex, claude } = deferStartupSynchronization
|
|
2858
2860
|
? providerStatuses
|
|
2859
2861
|
: await readProviderStatuses();
|
|
2862
|
+
const overviewSession = session;
|
|
2863
|
+
const overviewAccess = desktopAccess.snapshot();
|
|
2864
|
+
let overviewSqm = { state: 'unknown' };
|
|
2865
|
+
if (auth.state === 'signed_in' && !pendingOrganization) {
|
|
2866
|
+
try { overviewSqm = await readSqmConnectionObservation({ endpoint: process.env.SIMY_SQM_BUNDLE_URL?.trim() || new URL('sqm/knowledge-bundle', resolveWebApiBaseUrl(apiOrigin, overviewSession?.api_base_url)).href,
|
|
2867
|
+
accountId: overviewSession?.account_id || overviewSession?.auth_user_id || overviewSession?.device_id,
|
|
2868
|
+
organizationId: overviewSession?.organization_id || overviewSession?.org_id }, { root: process.env.SIMY_SQM_CACHE_DIR || join(sessionRoot, 'sqm') }); } catch { /* A missing endpoint must not block the status frame. */ }
|
|
2869
|
+
}
|
|
2870
|
+
// An organization switch/sign-out while reading local metadata must not
|
|
2871
|
+
// publish an old tenant's identity or SQM result in a fresh frame.
|
|
2872
|
+
const overview = connectionOverview({ webOrigin: apiOrigin, session, access: session === overviewSession ? overviewAccess : null,
|
|
2873
|
+
signedIn: session === overviewSession && auth.state === 'signed_in', switching: Boolean(pendingOrganization), sqm: overviewSqm });
|
|
2860
2874
|
return {
|
|
2861
2875
|
schemaVersion: 1,
|
|
2876
|
+
connectionOverview: overview,
|
|
2862
2877
|
sampledAt: new Date().toISOString(),
|
|
2863
2878
|
version: CLI_VERSION,
|
|
2864
2879
|
webOrigin: apiOrigin,
|
package/src/auto-update.js
CHANGED
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { promisify } from "node:util";
|
|
17
17
|
|
|
18
|
+
import { daemonRestartCommand } from "./web-origin.js";
|
|
18
19
|
import { openExternalUrl } from "./browser.js";
|
|
19
20
|
import {
|
|
20
21
|
installMacosInstallerRelease,
|
|
@@ -1094,7 +1095,7 @@ export function autoUpdateHelpText({ installMode = null, webOrigin = null } = {}
|
|
|
1094
1095
|
return [
|
|
1095
1096
|
"Recommended installation (enables verified CLI auto-update):",
|
|
1096
1097
|
` npm install -g ${CLI_PACKAGE_NAME}`,
|
|
1097
|
-
|
|
1098
|
+
` ${daemonRestartCommand(webOrigin)}`,
|
|
1098
1099
|
].join("\n");
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
@@ -1131,7 +1132,7 @@ export function updateAction(version, { installMode = "global_npm", webOrigin }
|
|
|
1131
1132
|
return {
|
|
1132
1133
|
id: "install_global_cli",
|
|
1133
1134
|
label: "Install and restart SIMY",
|
|
1134
|
-
command: `npm install -g ${CLI_PACKAGE_NAME}@${version} &&
|
|
1135
|
+
command: `npm install -g ${CLI_PACKAGE_NAME}@${version} && ${daemonRestartCommand(webOrigin)}`,
|
|
1135
1136
|
};
|
|
1136
1137
|
}
|
|
1137
1138
|
|
package/src/browser-handoff.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeWebOrigin } from "./web-origin.js";
|
|
1
2
|
import { createDecipheriv, createHash } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
export const BROWSER_HANDOFF_TRANSPORT = "loopback_redirect_v1";
|
|
@@ -33,6 +34,14 @@ const copy = ja ? {
|
|
|
33
34
|
};
|
|
34
35
|
document.documentElement.lang = ja ? 'ja' : 'en';
|
|
35
36
|
const title = document.getElementById('title'), message = document.getElementById('message');
|
|
37
|
+
const environment = document.getElementById('environment');
|
|
38
|
+
const origin = environment.dataset.origin;
|
|
39
|
+
const host = new URL(origin).hostname;
|
|
40
|
+
const label = origin === 'https://app.simy.one' ? (ja ? '本番' : 'Production')
|
|
41
|
+
: origin === 'https://app-dev.simy.one' ? (ja ? '開発' : 'Development')
|
|
42
|
+
: ['localhost', '127.0.0.1', '[::1]'].includes(host) ? (ja ? 'ローカル開発' : 'Local development')
|
|
43
|
+
: (ja ? 'カスタムホスト' : 'Custom host');
|
|
44
|
+
environment.textContent = (ja ? '接続先環境' : 'Connection environment') + ': ' + label + ' · ' + origin;
|
|
36
45
|
title.textContent = copy.title; message.textContent = copy.waiting;
|
|
37
46
|
const handoff = new URLSearchParams(location.hash.slice(1)).get('handoff');
|
|
38
47
|
history.replaceState(null, '', location.pathname);
|
|
@@ -54,15 +63,17 @@ history.replaceState(null, '', location.pathname);
|
|
|
54
63
|
|
|
55
64
|
// A self-contained local page: credentials never enter the URL query, HTTP
|
|
56
65
|
// request, history or third-party resources. Only the encrypted fragment does.
|
|
57
|
-
const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}body[data-state=failed] h1{color:var(--ink)}`;
|
|
66
|
+
const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}#environment{margin-top:16px;font-size:14px;overflow-wrap:anywhere}body[data-state=failed] h1{color:var(--ink)}`;
|
|
58
67
|
const hash = value => createHash("sha256").update(value).digest("base64");
|
|
59
|
-
export function browserHandoffPage() {
|
|
68
|
+
export function browserHandoffPage(webOrigin) {
|
|
69
|
+
const origin = normalizeWebOrigin(webOrigin);
|
|
70
|
+
const escapedOrigin = origin.replace(/[&<>"']/g, value => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[value]);
|
|
60
71
|
return {
|
|
61
72
|
headers: {
|
|
62
73
|
"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store",
|
|
63
74
|
"Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
|
|
64
75
|
"Content-Security-Policy": `default-src 'none'; script-src 'sha256-${hash(script)}'; style-src 'sha256-${hash(style)}'; connect-src 'self'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
|
|
65
76
|
},
|
|
66
|
-
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
|
|
77
|
+
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><p id="environment" data-origin="${escapedOrigin}">Connection environment: ${escapedOrigin}</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
|
|
67
78
|
};
|
|
68
79
|
}
|
package/src/cli-contract.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./durable-local-task-steps-contract.js";
|
|
15
15
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
16
16
|
|
|
17
|
-
export const CLI_VERSION = "0.7.
|
|
17
|
+
export const CLI_VERSION = "0.7.26";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Keep every original receipt outside the mutable task record before collapsing
|
|
6
|
+
// repeated inspections of the same contract, stage, decision and lease. This is
|
|
7
|
+
// storage maintenance, never a completed action or permission to replay one.
|
|
8
|
+
export async function compactConsistencyOutbox(root, record) {
|
|
9
|
+
const managed = record.run?.managed, outbox = managed?.consistencyOutbox;
|
|
10
|
+
if (!Array.isArray(outbox) || outbox.length < 2) return false;
|
|
11
|
+
const groups = new Map();
|
|
12
|
+
for (const item of outbox) {
|
|
13
|
+
const compatible = item.stage === 'executing' && item.decisionId && item.leaseId ||
|
|
14
|
+
item.stage === 'inspected' && item.targetTask && item.objectiveDigest;
|
|
15
|
+
const key = compatible
|
|
16
|
+
? JSON.stringify([item.targetTask, item.version, item.objectiveDigest, item.stage, item.decisionId, item.leaseId, item.advisory === true, item.result])
|
|
17
|
+
: item;
|
|
18
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
19
|
+
groups.get(key).push(item);
|
|
20
|
+
}
|
|
21
|
+
if (groups.size === outbox.length) return false;
|
|
22
|
+
const payload = JSON.stringify({ schema: 1, kind: 'execution_handoff_snapshot', provider: record.provider,
|
|
23
|
+
session: record.session, runId: record.run.id, requests: outbox });
|
|
24
|
+
const digest = createHash('sha256').update(payload).digest('hex');
|
|
25
|
+
const directory = join(root, 'consistency', 'handoff-history');
|
|
26
|
+
const file = join(directory, `${digest}.json`), temporary = `${file}.${randomUUID()}.tmp`;
|
|
27
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
28
|
+
try {
|
|
29
|
+
await writeFile(temporary, payload, { mode: 0o600 });
|
|
30
|
+
await rename(temporary, file);
|
|
31
|
+
if (await readFile(file, 'utf8') !== payload) throw new Error('Handoff history could not be retained.');
|
|
32
|
+
} finally { await rm(temporary, { force: true }); }
|
|
33
|
+
managed.consistencyOutbox = [...groups.values()].map(items => {
|
|
34
|
+
if (items.length === 1) return items[0];
|
|
35
|
+
const latest = items.at(-1);
|
|
36
|
+
return { ...latest, findings: [...new Set(items.flatMap(item => item.findings || []))],
|
|
37
|
+
...Object.fromEntries(['createdAt', 'dueAt'].flatMap(key => {
|
|
38
|
+
const values = items.map(item => item[key]).filter(Number.isFinite);
|
|
39
|
+
return values.length ? [[key, Math.min(...values)]] : [];
|
|
40
|
+
})),
|
|
41
|
+
retainedHistory: { digest, requestCount: items.reduce((count, item) => count + (item.retainedHistory?.requestCount || 1), 0) } };
|
|
42
|
+
});
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
1
46
|
// Durable handoff deadlines concern unowned work, never a live effect lease.
|
|
2
47
|
// Expiry queues diagnosis; it does not replay a write or grant permission.
|
|
3
48
|
export const HANDOFF_RECEIPT_MS = 2 * 60_000;
|
|
@@ -8,7 +8,7 @@ import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, s
|
|
|
8
8
|
import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
|
|
9
9
|
import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
|
|
10
10
|
import { bindControllerCompletion } from './completion-binding.mjs';
|
|
11
|
-
import { recordHandoffStage } from './consistency-handoff.mjs';
|
|
11
|
+
import { recordHandoffStage, compactConsistencyOutbox } from './consistency-handoff.mjs';
|
|
12
12
|
import { retainApplicableRules } from './purpose-rule-policy.mjs';
|
|
13
13
|
import { reconcileRequirements, retainControllerEvidence, reconcileFindings, findingText, evidenceLedger, currentPurposeObservation } from './purpose-evidence.mjs';
|
|
14
14
|
import { workspaceIdentity } from './purpose-workspace.mjs';
|
|
@@ -116,11 +116,13 @@ export async function linkedProvider(root, provider) {
|
|
|
116
116
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
117
117
|
const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
118
118
|
const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
|
|
119
|
-
export function canUpgradeDelivery(run) {
|
|
119
|
+
export function canUpgradeDelivery(run, { automatic = false } = {}) {
|
|
120
120
|
return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
|
|
121
121
|
Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
|
|
122
122
|
(['active', 'paused', 'recovery_required'].includes(run.state) ||
|
|
123
|
-
run.state === 'blocked' && run.blocker === 'external_dependency')
|
|
123
|
+
run.state === 'blocked' && run.blocker === 'external_dependency') &&
|
|
124
|
+
(!automatic || run.stopReason?.actor !== 'user' && (run.state !== 'paused' || run.stopReason?.actor === 'system' ||
|
|
125
|
+
/^(?:Paused because no progress was confirmed in \d+ minutes\.|No verified acceptance progress in \d+ minutes\.|Paused after \d+ steps without confirmed progress\.) Review the original conversation, then resume\.$/.test(run.reason || ''))));
|
|
124
126
|
}
|
|
125
127
|
const contextText = (value, max = 500) => text(value, max).replace(/\b(?:sk|key)-[A-Za-z0-9_-]{12,}\b|\bBearer\s+\S+|\b(?:password|token|api[_-]?key)\s*[:=]\s*\S+/gi, '[redacted]');
|
|
126
128
|
export function recordPath(root, provider, session) {
|
|
@@ -275,7 +277,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
275
277
|
delete criterion.check.path;
|
|
276
278
|
}
|
|
277
279
|
}
|
|
278
|
-
|
|
280
|
+
let serialized = JSON.stringify(next);
|
|
281
|
+
if (Buffer.byteLength(serialized) > MAX_STATE_BYTES / 2 && await compactConsistencyOutbox(root, next))
|
|
282
|
+
serialized = JSON.stringify(next);
|
|
279
283
|
if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error('Delivery state exceeds 4 MiB; retain history before reducing task details.');
|
|
280
284
|
if (serialized === previousBytes) return next;
|
|
281
285
|
temporary = `${file}.${randomUUID()}.tmp`;
|
|
@@ -864,7 +868,8 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
864
868
|
const fences = Object.values(gate.taskFences?.records || {}).filter(item => item.taskId === (prior.managed?.taskId || prior.id) && item.identity.provider === provider && item.identity.session === session);
|
|
865
869
|
if (fences.some(item => !readTaskAdmission({ root: gateRoot, identity: item.identity, taskId: item.taskId }).canResume)) throw new Error('Existing task admission must be reconciled before enrolling follow-up work.');
|
|
866
870
|
}
|
|
867
|
-
if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run
|
|
871
|
+
if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run, { automatic: Boolean(autoContinueEpoch) })) ||
|
|
872
|
+
autoContinueEpoch && hasPendingUserStop(root, previous) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
|
|
868
873
|
if (previous.run && !upgrading && !following) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
869
874
|
if (upgrading && (text(objective) !== previous.run.objective || executionCwd && previous.run.executionCwd && executionCwd !== previous.run.executionCwd)) throw new Error('Upgrade must preserve the original objective and task directory.');
|
|
870
875
|
const managedConfig = managed && { ...(managed === true ? {} : managed),
|
|
@@ -1201,6 +1206,18 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1201
1206
|
const localReconciliation = flexibleReasoning(run.managed) && ['read','local_edit','local_test'].includes(uncertainAction?.kind);
|
|
1202
1207
|
if (run.managed?.executionUncertain && run.managed.recoveryDiagnosisDecisionId === lastWork?.result?.decisionId && lastWork.result.status === 'observed' && (lastWork.result.evidence?.length || lastWork.result.observationSource === 'controller_tool_result' || localReconciliation) && lastWork.action.kind === 'read') {
|
|
1203
1208
|
run.managed.recoveryEvidence = { decisionId: lastWork.result.decisionId, source: lastWork.result.observationSource || 'provider_reported_read', checkedAt: now };
|
|
1209
|
+
const priorUnit = run.managed.purpose?.evidence?.units?.find(unit =>
|
|
1210
|
+
unit.id === uncertain.decisionId && unit.status === 'unknown' &&
|
|
1211
|
+
(!uncertain.attemptId || unit.attemptId === uncertain.attemptId));
|
|
1212
|
+
if (priorUnit) {
|
|
1213
|
+
priorUnit.status = 'observed';
|
|
1214
|
+
// The read already met the reconciliation guard above, including a
|
|
1215
|
+
// local observation reported without separate evidence references.
|
|
1216
|
+
// Release only local occupancy, never acceptance or external effects.
|
|
1217
|
+
if (['local_edit', 'local_test'].includes(priorUnit.action?.kind)) {
|
|
1218
|
+
priorUnit.localExecutionSettledAt = now;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1204
1221
|
delete run.managed.executionUncertain;
|
|
1205
1222
|
resolveRecoveryReceipt(run, uncertain?.attemptId);
|
|
1206
1223
|
delete run.managed.recoveryDiagnosisDecisionId;
|
|
@@ -1873,14 +1890,15 @@ export async function processIdentity(pid) {
|
|
|
1873
1890
|
export async function claudeHookChannel(record, { session, cwd, inspectProcess = processIdentity, platform = process.platform } = {}) {
|
|
1874
1891
|
const unavailable = (code, reason) => ({ ready: false, code, reason });
|
|
1875
1892
|
if (platform === 'win32') return unavailable('platform_unsupported', 'Claude process verification is unavailable on Windows; monitoring remains available.');
|
|
1876
|
-
if (!record) return unavailable('hook_missing', 'SIMY
|
|
1893
|
+
if (!record) return unavailable('hook_missing', 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
|
|
1877
1894
|
if (record.provider !== 'claude' || record.session !== session) return unavailable('session_mismatch', 'The observed hook belongs to a different Claude conversation. Refresh this conversation.');
|
|
1878
1895
|
try { if (record.cwd !== await realpath(cwd)) return unavailable('workspace_mismatch', 'The observed Claude hook belongs to a different folder. Refresh this conversation.'); }
|
|
1879
1896
|
catch { return unavailable('workspace_unavailable', 'The Claude conversation folder could not be verified. Check the folder and refresh.'); }
|
|
1880
1897
|
if (record.hookEndedAt || record.lastEvent === 'SessionEnd') return unavailable('session_ended', 'This Claude session has ended. Return to the original conversation to continue.');
|
|
1898
|
+
if (!record.hookStartedAt && record.hookRecoveryBlocked) return unavailable('process_changed', 'A different Claude process reported this conversation. Reopen the original conversation to establish its hook owner.');
|
|
1881
1899
|
if (!record.hookStartedAt) return unavailable(record.hookObservedAt ? 'process_unverified' : 'hook_missing', record.hookObservedAt
|
|
1882
1900
|
? 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.'
|
|
1883
|
-
: 'SIMY
|
|
1901
|
+
: 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
|
|
1884
1902
|
const owner = record.hookSessionProcess || record.process; // Existing verified records remain compatible.
|
|
1885
1903
|
if (!owner?.identity || !Number.isSafeInteger(owner.pid) || owner.pid < 2) return unavailable('process_unverified', 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.');
|
|
1886
1904
|
if (record.process?.pid !== owner.pid || record.process?.identity !== owner.identity) return unavailable('process_changed', 'A different Claude process reported this conversation. Refresh the original conversation.');
|
|
@@ -2019,6 +2037,22 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2019
2037
|
backgroundIdle: event === 'Stop' && Array.isArray(input.background_tasks) && input.background_tasks.length === 0 && Array.isArray(input.session_crons) && input.session_crons.length === 0 };
|
|
2020
2038
|
}
|
|
2021
2039
|
record.lastNotificationType = event === 'Notification' ? text(input.notification_type, 80) : null;
|
|
2040
|
+
if (provider === 'claude' && event !== 'SessionStart' && identity && record.process &&
|
|
2041
|
+
(record.process.pid !== identity.pid || record.process.identity !== identity.identity)) {
|
|
2042
|
+
record.hookRecoveryBlocked = 'process_changed';
|
|
2043
|
+
}
|
|
2044
|
+
// A plugin loaded after the conversation opened can miss SessionStart.
|
|
2045
|
+
// A real prompt/stop hook proves the same exact session and provider parent
|
|
2046
|
+
// without fabricating a start event or adopting an existing task/owner.
|
|
2047
|
+
if (provider === 'claude' && ['UserPromptSubmit', 'Stop'].includes(event) &&
|
|
2048
|
+
!record.run && !record.hookStartedAt && !record.hookSessionProcess && !record.hookEndedAt && !record.hookRecoveryBlocked &&
|
|
2049
|
+
!record.parentSession && !env.SIMY_PARENT_SESSION && !env.CODEX_THREAD_ID &&
|
|
2050
|
+
!record.providerArchived && identity?.identity && Number.isSafeInteger(identity.pid) && identity.pid > 1 &&
|
|
2051
|
+
(!record.process || record.process.pid === identity.pid && record.process.identity === identity.identity)) {
|
|
2052
|
+
record.hookStartedAt = now;
|
|
2053
|
+
record.hookSessionProcess = { ...identity };
|
|
2054
|
+
record.hookRecovery = { source: event, recoveredAt: now, reason: 'session_start_missing' };
|
|
2055
|
+
}
|
|
2022
2056
|
if (identity) {
|
|
2023
2057
|
// Legacy records have no frozen hook owner; a new process must not inherit
|
|
2024
2058
|
// an old SessionStart admission through a later prompt or stop event.
|
|
@@ -2028,6 +2062,8 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2028
2062
|
}
|
|
2029
2063
|
if (event === 'SessionStart') {
|
|
2030
2064
|
record.hookObservedAt = now;
|
|
2065
|
+
delete record.hookRecovery;
|
|
2066
|
+
delete record.hookRecoveryBlocked;
|
|
2031
2067
|
delete record.hookEndedAt;
|
|
2032
2068
|
if (identity) { record.hookStartedAt = now; record.hookSessionProcess = { ...identity }; }
|
|
2033
2069
|
else { delete record.hookStartedAt; delete record.hookSessionProcess; delete record.process; }
|
|
@@ -37,7 +37,7 @@ export function compactWorkHistory(ledger, historyLimit = 64) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** Pure projection of the existing evidence.units; never grants authority or dispatches. */
|
|
40
|
-
export function planWorkFrontier(ledger, { prObservations = {}, stopped = false, preferredUnitId = null } = {}) {
|
|
40
|
+
export function planWorkFrontier(ledger, { prObservations = {}, stopped = false, preferredUnitId = null, reconcileUnknownUnitId = null } = {}) {
|
|
41
41
|
if (!ledger || !Array.isArray(ledger.units)) throw new Error('Existing work-unit ledger required.');
|
|
42
42
|
const units = ledger.units;
|
|
43
43
|
const ids = new Set();
|
|
@@ -88,9 +88,11 @@ export function planWorkFrontier(ledger, { prObservations = {}, stopped = false,
|
|
|
88
88
|
}
|
|
89
89
|
if (!reason) {
|
|
90
90
|
const conflict = [...holders, ...proposals.map(id => byId.get(id))].find(other => other.id !== unit.id && conflicts(unit, other) &&
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
!(unit.action?.kind === 'read' && unit.writeResources.length === 0 &&
|
|
91
|
+
// Inspect observed effects, or one controller-bound unknown effect.
|
|
92
|
+
// Keep all writers fenced and check every other resource holder.
|
|
93
|
+
!(unit.action?.kind === 'read' && unit.writeResources.length === 0 &&
|
|
94
|
+
(other.status === 'observed' || unit.id === preferredUnitId &&
|
|
95
|
+
other.status === 'unknown' && other.id === reconcileUnknownUnitId)));
|
|
94
96
|
if (conflict) reason = `resource_conflict:${conflict.id}`;
|
|
95
97
|
}
|
|
96
98
|
if (!reason) { state = 'proposed'; proposals.push(unit.id); }
|
|
@@ -103,7 +105,7 @@ export function planWorkFrontier(ledger, { prObservations = {}, stopped = false,
|
|
|
103
105
|
const inputDigest = hash({ purposeRevision: ledger.purposeRevision, unitsOmitted: ledger.unitsOmitted,
|
|
104
106
|
units: units.map(({ id, owner, status, purposeRevision, dependsOn, readResources, writeResources, waitReason, pr, action, effectReconciledRevision, localExecutionSettledAt }) =>
|
|
105
107
|
({ id, owner, status, purposeRevision, dependsOn, readResources, writeResources, waitReason, pr, action, effectReconciledRevision, localExecutionSettledAt })),
|
|
106
|
-
prObservations, stopped, preferredUnitId });
|
|
108
|
+
prObservations, stopped, preferredUnitId, reconcileUnknownUnitId });
|
|
107
109
|
return { version: 1, inputDigest, proposals, rows, authority: 'proposal_only' };
|
|
108
110
|
}
|
|
109
111
|
|
|
@@ -66,6 +66,42 @@ export function confirmDispatch({ root = detachmentRoot(), attemptId }) {
|
|
|
66
66
|
if (!attempt || !Object.values(state.receipts).some(item => item.attemptId === attemptId && item.resultRef)) fail('result_required');
|
|
67
67
|
attempt.phase = 'confirmed'; state.revision++; atomicSave(path(root), state); return state; });
|
|
68
68
|
}
|
|
69
|
+
// Transport settlement is independent of acceptance of the provider's report.
|
|
70
|
+
// Only an observed terminal event for the exact owned turn may use this path.
|
|
71
|
+
export function queueProviderTerminalReceipt({ root = detachmentRoot(), attemptId, providerSession, providerExecutionId, status, response = '' }) {
|
|
72
|
+
if (!['completed', 'failed', 'interrupted'].includes(status) || typeof response !== 'string' ||
|
|
73
|
+
!providerSession || !providerExecutionId) fail('terminal_evidence_invalid');
|
|
74
|
+
root = realpathSync(root);
|
|
75
|
+
const state = read(root), attempt = state.attempts[digest(attemptId)];
|
|
76
|
+
if (!attempt?.provenance || attempt.provenance.providerSession !== providerSession) fail('terminal_identity_mismatch');
|
|
77
|
+
const ref = join(root, 'update-detachment', 'provider-results', digest([attemptId, providerExecutionId]) + '.json');
|
|
78
|
+
const evidence = { schemaVersion: 1, kind: 'provider_terminal', attemptId, providerSession, providerExecutionId, status,
|
|
79
|
+
taskHash: attempt.taskHash, identityHash: attempt.identityHash, generation: attempt.generation,
|
|
80
|
+
responseDigest: digest(response), response: response.slice(0, 65536), truncated: response.length > 65536 };
|
|
81
|
+
const prior = readState(ref);
|
|
82
|
+
if (prior && digest(prior) !== digest(evidence)) fail('terminal_evidence_conflict');
|
|
83
|
+
if (!prior) atomicSave(ref, evidence);
|
|
84
|
+
const item = { schemaVersion: 1, kind: 'terminal_receipt', attemptId, providerExecutionId, resultRef: ref,
|
|
85
|
+
receiptId: `terminal:${attemptId}:${providerExecutionId}`, evidenceDigest: digest(evidence) };
|
|
86
|
+
atomicSave(join(root, 'update-detachment', 'intake', digest(item) + '.json'), item);
|
|
87
|
+
try { flushDispatchIntake(root); } catch (error) { if (error.code !== 'state_busy') throw error; }
|
|
88
|
+
return ref;
|
|
89
|
+
}
|
|
90
|
+
function applyTerminalReceipt(state, item, root) {
|
|
91
|
+
const ref = item.resultRef, attempt = state.attempts[digest(item.attemptId)];
|
|
92
|
+
const expected = join(resolve(root), 'update-detachment', 'provider-results', digest([item.attemptId, item.providerExecutionId]) + '.json');
|
|
93
|
+
if (ref !== expected || realpathSync(ref) !== ref) fail('terminal_evidence_invalid');
|
|
94
|
+
const evidence = readState(ref);
|
|
95
|
+
if (!evidence || digest(evidence) !== item.evidenceDigest || evidence.schemaVersion !== 1 || evidence.kind !== 'provider_terminal' ||
|
|
96
|
+
!['completed', 'failed', 'interrupted'].includes(evidence.status) || evidence.attemptId !== item.attemptId ||
|
|
97
|
+
evidence.providerExecutionId !== item.providerExecutionId || !attempt?.provenance ||
|
|
98
|
+
evidence.providerSession !== attempt.provenance.providerSession || evidence.taskHash !== attempt.taskHash ||
|
|
99
|
+
evidence.identityHash !== attempt.identityHash || evidence.generation !== attempt.generation) fail('terminal_identity_mismatch');
|
|
100
|
+
const priorTurns = Object.values(state.receipts).filter(row => row.attemptId === item.attemptId).map(row => row.providerExecutionId).filter(Boolean);
|
|
101
|
+
if (priorTurns.some(id => id !== item.providerExecutionId)) fail('terminal_evidence_conflict');
|
|
102
|
+
applyReceipt(state, item);
|
|
103
|
+
attempt.phase = 'confirmed';
|
|
104
|
+
}
|
|
69
105
|
export function dispatchWithFence(owner, { taskId, attemptId = randomUUID(), identity = null, provenance = null }, send) {
|
|
70
106
|
if (!owner || typeof send !== 'function' || typeof taskId !== 'string' || !taskId || typeof attemptId !== 'string' || !attemptId) fail('dispatch_identity_required');
|
|
71
107
|
return withLock(path(owner.root), () => {
|
|
@@ -165,7 +201,9 @@ export function flushDispatchIntake(root = detachmentRoot()) {
|
|
|
165
201
|
for (const name of names) {
|
|
166
202
|
if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
|
|
167
203
|
const item = readState(join(directory, name));
|
|
168
|
-
if (item?.kind === 'receipt') applyReceipt(state, item);
|
|
204
|
+
if (item?.kind === 'receipt') applyReceipt(state, item);
|
|
205
|
+
else if (item?.kind === 'terminal_receipt') applyTerminalReceipt(state, item, root);
|
|
206
|
+
else applyIdentityIntake(state, item);
|
|
169
207
|
applied.push(name);
|
|
170
208
|
}
|
|
171
209
|
if (applied.length) { state.revision++; atomicSave(path(root), state); for (const name of applied) unlinkSync(join(directory, name)); }
|