@awak-app/simy-cli 0.5.29 → 0.5.31
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 +15 -5
- package/package.json +3 -2
- package/src/agent.js +148 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +15 -6
- package/src/codex-plugins/simy/hooks/README.md +15 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +196 -0
- package/src/codex-plugins/simy/hooks/hooks.json +53 -0
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +28 -0
- package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +3 -1
- package/src/coding-run-store.js +69 -0
- package/src/delivery-hooks/command.js +31 -0
- package/src/delivery-hooks/desktop.js +40 -0
- package/src/desktop-host.js +11 -1
- package/src/index.js +7 -0
- package/src/local-task.js +55 -0
- package/src/orchestrator/audit.js +3 -0
- package/src/orchestrator/checklist-continuation.js +3 -3
- package/src/orchestrator/completion-contract.js +7 -0
- package/src/orchestrator/contract.js +6 -4
- package/src/orchestrator/delivery/WORKFLOW.md +93 -6
- package/src/orchestrator/delivery/references/acceptance-evidence.md +9 -5
- package/src/orchestrator/delivery/references/change-boundary-matrix.md +20 -1
- package/src/orchestrator/delivery/references/desktop-monitor-triggers.md +35 -0
- package/src/orchestrator/delivery/references/shared-system-specifications.md +3 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +19 -0
- package/src/orchestrator/execution-io.js +15 -0
- package/src/orchestrator/independent-audit.js +14 -1
- package/src/orchestrator/instruction.js +3 -0
- package/src/orchestrator/loop.js +71 -0
- package/src/orchestrator/outcome-monitor.js +123 -0
- package/src/provider-stream.js +3 -1
- package/src/runner.js +162 -20
- package/src/sqm/agent-skill.js +3 -2
- package/src/sqm/claude-plugin.js +6 -1
- package/src/sqm/command.js +13 -0
- package/src/sqm/report.js +23 -0
- package/src/sqm/workflow-onboarding.js +1 -1
- package/src/workflow-observation-sender.js +208 -0
- package/src/workflow-reporting.js +35 -0
- package/src/workflow-reporting.md +137 -0
package/README.md
CHANGED
|
@@ -76,11 +76,13 @@ marketplace still points to SIMY’s generated local source. Plugin versions inc
|
|
|
76
76
|
receive a fresh cache. Use a new Codex task to pick up updated skills; running
|
|
77
77
|
tasks are not restarted. Missing or disabled plugins are not enabled automatically.
|
|
78
78
|
Failed refreshes retry on the next check without blocking daemon startup.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
The plugin also provides an opt-in, hook-based Delivery Loop in the user's existing
|
|
80
|
+
Codex or Claude Code conversation. Install the plugin, review and enable SIMY hooks
|
|
81
|
+
with `/hooks`, then request `/simy:delivery-loop` for the task. Installing or updating
|
|
82
|
+
the plugin never approves hook trust. SIMY's Connection & settings panel links to
|
|
83
|
+
Codex settings and the Claude setup guide and shows observed hook activity and
|
|
84
|
+
blocked/recovery states. The legacy `agentic-delivery-loop` skill remains retired;
|
|
85
|
+
the Web/CLI executor is unchanged. See [hook delivery behavior and limits](docs/delivery-hooks.md).
|
|
84
86
|
`--no-auto-update` or `SIMY_AUTO_UPDATE=0` disables these background updates too;
|
|
85
87
|
`--no-workflow-prompt` only suppresses the initial installation prompt.
|
|
86
88
|
|
|
@@ -342,6 +344,14 @@ SQM deliberately has two identity-bound stages:
|
|
|
342
344
|
state transitions, invariants, stress/strength expectations, and scenarios,
|
|
343
345
|
writes `proof.json`, and signs that proof with the local device identity.
|
|
344
346
|
|
|
347
|
+
The full-check summary reports applicable modules, evaluated rules, scenario
|
|
348
|
+
statuses and blocking/advisory scope. `passed` with `no_applicable_rules` or zero
|
|
349
|
+
scenario evaluations does not establish feature or historical incident coverage.
|
|
350
|
+
Scenario statuses come from deterministic rule evaluation, not browser/provider
|
|
351
|
+
execution. Keep concrete regression and delivery acceptance evidence separately.
|
|
352
|
+
If knowledge is unavailable, the command returns exit 2 with the service cause
|
|
353
|
+
and writes no new proof; any older artifact is not evidence for that attempt.
|
|
354
|
+
|
|
345
355
|
Normal runs do not require a separate sync command: the CLI
|
|
346
356
|
uses a verified local cache and revalidates it with TTL and ETag semantics.
|
|
347
357
|
The Agentic Loop runs min-check after each code modification and the full check
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.31",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
"test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
|
|
39
39
|
"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:real-provider-syntax && npm test && npm run check:package",
|
|
40
40
|
"check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
|
|
41
|
+
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
41
42
|
"check:sqm": "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/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
|
42
43
|
"check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
|
|
43
|
-
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js",
|
|
44
|
+
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js",
|
|
44
45
|
"check:package": "node ./scripts/check-package-contents.js",
|
|
45
46
|
"build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
|
|
46
47
|
"build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
|
package/src/agent.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
createRun,
|
|
11
11
|
increaseLocalCodingRunProviderTokenBudget,
|
|
12
12
|
LocalRunRegistry,
|
|
13
|
+
markCodingRunMonitorFailure,
|
|
13
14
|
isLocalRepositoryApprovalPending,
|
|
14
15
|
pauseLocalCodingRun,
|
|
15
16
|
queueLocalCodingGuidance,
|
|
@@ -19,10 +20,12 @@ import {
|
|
|
19
20
|
restoreRun,
|
|
20
21
|
resumeLocalCodingRun,
|
|
21
22
|
startLocalCodingRun,
|
|
23
|
+
syncLocalCodingRun,
|
|
22
24
|
stopLocalCodingRun,
|
|
23
25
|
toCompatibleCodingLoopState,
|
|
24
26
|
toLedgerSnapshot,
|
|
25
27
|
} from "./runner.js";
|
|
28
|
+
import { codingRunsRoot, loadCodingRunSnapshots, saveCodingRunSnapshot } from "./coding-run-store.js";
|
|
26
29
|
import {
|
|
27
30
|
expiresAtFromNow,
|
|
28
31
|
isSessionValid,
|
|
@@ -92,6 +95,7 @@ import {
|
|
|
92
95
|
classifyExecutionRequirement,
|
|
93
96
|
EXECUTION_GUARDRAIL_POLICY_VERSION,
|
|
94
97
|
} from "./execution-guardrail.js";
|
|
98
|
+
import { normalizeWorkflowObservationBinding } from "./workflow-observation-sender.js";
|
|
95
99
|
import {
|
|
96
100
|
createLocalTask,
|
|
97
101
|
createLocalTaskId,
|
|
@@ -134,6 +138,7 @@ import {
|
|
|
134
138
|
} from "./connection-discovery.js";
|
|
135
139
|
|
|
136
140
|
const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
|
|
141
|
+
const CODING_RUN_MONITOR_INTERVAL_MS = 5 * 60_000;
|
|
137
142
|
|
|
138
143
|
export async function startAgent({
|
|
139
144
|
requestedPort = 0,
|
|
@@ -148,6 +153,7 @@ export async function startAgent({
|
|
|
148
153
|
} = {}) {
|
|
149
154
|
const apiOrigin = resolveWebOrigin(webOrigin);
|
|
150
155
|
const registry = new LocalRunRegistry();
|
|
156
|
+
const codingRunStateRoot = codingRunsRoot(sessionRoot);
|
|
151
157
|
const directRegistry = new DirectTaskRegistry();
|
|
152
158
|
const localTaskRegistry = new LocalTaskRegistry();
|
|
153
159
|
const taskStorageRoot = localTasksRoot(
|
|
@@ -217,6 +223,8 @@ export async function startAgent({
|
|
|
217
223
|
let port = requestedPort;
|
|
218
224
|
let heartbeatTimer = null;
|
|
219
225
|
let heartbeatInFlight = null;
|
|
226
|
+
let codingRunMonitorTimer = null;
|
|
227
|
+
let codingRunMonitorInFlight = null;
|
|
220
228
|
let shuttingDown = false;
|
|
221
229
|
const notifyDesktopStatusChange = () => {
|
|
222
230
|
if (shuttingDown) return;
|
|
@@ -296,6 +304,14 @@ export async function startAgent({
|
|
|
296
304
|
return loginUrl.toString();
|
|
297
305
|
};
|
|
298
306
|
|
|
307
|
+
const publishOrSyncRestoredRun = async (run) => {
|
|
308
|
+
if (run.restoredFromState || run.restoredObjectiveUnverified) {
|
|
309
|
+
await publishRestoredLocalCodingRun(run);
|
|
310
|
+
} else {
|
|
311
|
+
syncLocalCodingRun(run);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
299
315
|
const restoreRemoteRuns = async () => {
|
|
300
316
|
if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
|
|
301
317
|
const snapshots = dependencies.listRemoteRuns
|
|
@@ -309,24 +325,113 @@ export async function startAgent({
|
|
|
309
325
|
apiBaseUrl: session.api_base_url,
|
|
310
326
|
token: session.token,
|
|
311
327
|
});
|
|
328
|
+
const remoteIds = new Set();
|
|
312
329
|
let restored = false;
|
|
313
330
|
for (const snapshot of snapshots) {
|
|
314
331
|
if (shuttingDown) return;
|
|
315
|
-
if (!snapshot?.id
|
|
332
|
+
if (!snapshot?.id) continue;
|
|
333
|
+
remoteIds.add(snapshot.id);
|
|
334
|
+
if (registry.has(snapshot.id)) {
|
|
335
|
+
const localRun = registry.get(snapshot.id);
|
|
336
|
+
localRun.session = session;
|
|
337
|
+
localRun.apiOrigin = apiOrigin;
|
|
338
|
+
const remoteTime = Date.parse(snapshot.updated_at || "") || 0;
|
|
339
|
+
const localTime = Date.parse(localRun.snapshot.updated_at || "") || 0;
|
|
340
|
+
if (remoteTime > localTime) {
|
|
341
|
+
const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
|
|
342
|
+
const remoteRun = restoreRun({
|
|
343
|
+
snapshot, session, apiOrigin,
|
|
344
|
+
localPath: repository?.local_path || null,
|
|
345
|
+
localStateRoot: codingRunStateRoot,
|
|
346
|
+
});
|
|
347
|
+
Object.assign(localRun, {
|
|
348
|
+
request: remoteRun.request,
|
|
349
|
+
status: remoteRun.status,
|
|
350
|
+
snapshot: remoteRun.snapshot,
|
|
351
|
+
controlState: remoteRun.controlState,
|
|
352
|
+
completedAt: remoteRun.completedAt,
|
|
353
|
+
restoredFromState: remoteRun.restoredFromState,
|
|
354
|
+
restoredObjectiveUnverified: remoteRun.restoredObjectiveUnverified,
|
|
355
|
+
});
|
|
356
|
+
await saveCodingRunSnapshot(codingRunStateRoot, localRun.snapshot);
|
|
357
|
+
await publishRestoredLocalCodingRun(localRun);
|
|
358
|
+
} else {
|
|
359
|
+
await publishOrSyncRestoredRun(localRun);
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
316
363
|
const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
|
|
317
364
|
const restoredRun = restoreRun({
|
|
318
365
|
snapshot,
|
|
319
366
|
session,
|
|
320
367
|
apiOrigin,
|
|
321
368
|
localPath: repository?.local_path || null,
|
|
369
|
+
localStateRoot: codingRunStateRoot,
|
|
322
370
|
});
|
|
323
371
|
registry.create(restoredRun);
|
|
324
372
|
restored = true;
|
|
325
373
|
await publishRestoredLocalCodingRun(restoredRun);
|
|
326
374
|
}
|
|
375
|
+
for (const localRun of registry.list()) {
|
|
376
|
+
if (!remoteIds.has(localRun.id)) await publishOrSyncRestoredRun(localRun);
|
|
377
|
+
}
|
|
327
378
|
if (restored) notifyDesktopStatusChange();
|
|
328
379
|
};
|
|
329
380
|
|
|
381
|
+
const restoreLocalCodingRuns = async () => {
|
|
382
|
+
const { snapshots, errors } = await loadCodingRunSnapshots(codingRunStateRoot);
|
|
383
|
+
for (const error of errors) {
|
|
384
|
+
reportStartupConnectionError(new Error(`Coding run ${error.id}: ${error.message}`), quiet);
|
|
385
|
+
}
|
|
386
|
+
for (const snapshot of snapshots) {
|
|
387
|
+
const boundDevice = snapshot?.metadata?.execution_device_id;
|
|
388
|
+
if (boundDevice && boundDevice !== session?.device_id) continue;
|
|
389
|
+
if (registry.has(snapshot.id)) {
|
|
390
|
+
const existing = registry.get(snapshot.id);
|
|
391
|
+
existing.session = session;
|
|
392
|
+
existing.apiOrigin = apiOrigin;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
|
|
397
|
+
const run = restoreRun({
|
|
398
|
+
snapshot, session, apiOrigin,
|
|
399
|
+
localPath: repository?.local_path || null,
|
|
400
|
+
localStateRoot: codingRunStateRoot,
|
|
401
|
+
});
|
|
402
|
+
// Keep the persisted timestamp until the Web ledger has been compared.
|
|
403
|
+
// Publishing recovery here would make an older local record look newest.
|
|
404
|
+
registry.create(run);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
reportStartupConnectionError(error, quiet);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const monitorCodingRuns = () => {
|
|
412
|
+
if (!daemon || !isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve();
|
|
413
|
+
if (codingRunMonitorInFlight) return codingRunMonitorInFlight;
|
|
414
|
+
codingRunMonitorInFlight = (async () => {
|
|
415
|
+
for (const run of registry.list()) {
|
|
416
|
+
if (!["pr_ready_for_review", "merge_ready"].includes(run.status) || run.operation || run.child) continue;
|
|
417
|
+
try {
|
|
418
|
+
await (dependencies.recheckCodingRun || recheckLocalCodingRun)(run);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
await markCodingRunMonitorFailure(run, error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
})().finally(() => { codingRunMonitorInFlight = null; });
|
|
424
|
+
return codingRunMonitorInFlight;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const startCodingRunMonitor = () => {
|
|
428
|
+
if (!daemon || codingRunMonitorTimer) return;
|
|
429
|
+
codingRunMonitorTimer = setInterval(() => {
|
|
430
|
+
void monitorCodingRuns().catch((error) => reportStartupConnectionError(error, quiet));
|
|
431
|
+
}, dependencies.codingRunMonitorIntervalMs || CODING_RUN_MONITOR_INTERVAL_MS);
|
|
432
|
+
codingRunMonitorTimer.unref();
|
|
433
|
+
};
|
|
434
|
+
|
|
330
435
|
const heartbeat = () => {
|
|
331
436
|
if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve(false);
|
|
332
437
|
if (heartbeatInFlight) return heartbeatInFlight;
|
|
@@ -406,7 +511,9 @@ export async function startAgent({
|
|
|
406
511
|
reportHeartbeatError(error, quiet);
|
|
407
512
|
}
|
|
408
513
|
try {
|
|
514
|
+
await restoreLocalCodingRuns();
|
|
409
515
|
await restoreRemoteRuns();
|
|
516
|
+
startCodingRunMonitor();
|
|
410
517
|
} catch (error) {
|
|
411
518
|
reportStartupConnectionError(error, quiet);
|
|
412
519
|
}
|
|
@@ -966,6 +1073,19 @@ export async function startAgent({
|
|
|
966
1073
|
}
|
|
967
1074
|
throw error;
|
|
968
1075
|
}
|
|
1076
|
+
let workflowObservationBinding;
|
|
1077
|
+
try {
|
|
1078
|
+
workflowObservationBinding = normalizeWorkflowObservationBinding(
|
|
1079
|
+
body.workflow_observation_binding,
|
|
1080
|
+
{ provider: body.backend },
|
|
1081
|
+
);
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
json(res, 400, {
|
|
1084
|
+
error: error instanceof Error ? error.message : "WorkFlow observation binding is invalid.",
|
|
1085
|
+
code: "workflow_observation_binding_invalid",
|
|
1086
|
+
});
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
969
1089
|
let requestFingerprint;
|
|
970
1090
|
try {
|
|
971
1091
|
requestFingerprint = createLocalTaskRequestFingerprint({
|
|
@@ -981,6 +1101,7 @@ export async function startAgent({
|
|
|
981
1101
|
timeoutMs: body.timeout_ms,
|
|
982
1102
|
attachmentManifest: preparedAttachments,
|
|
983
1103
|
scenarioPack,
|
|
1104
|
+
workflowObservationBinding,
|
|
984
1105
|
});
|
|
985
1106
|
hashLocalTaskIdempotencyKey(idempotencyKey);
|
|
986
1107
|
} catch (error) {
|
|
@@ -1105,12 +1226,15 @@ export async function startAgent({
|
|
|
1105
1226
|
timeoutMs: body.timeout_ms,
|
|
1106
1227
|
idempotencyKeyHash: hashLocalTaskIdempotencyKey(idempotencyKey),
|
|
1107
1228
|
requestFingerprint,
|
|
1229
|
+
workflowObservationBinding,
|
|
1108
1230
|
});
|
|
1109
1231
|
await persistLocalTask(task);
|
|
1110
1232
|
localTaskRegistry.create(task);
|
|
1111
1233
|
requestClaim.complete(task);
|
|
1112
1234
|
requestClaimCompleted = true;
|
|
1113
|
-
const runLocalTask =
|
|
1235
|
+
const runLocalTask =
|
|
1236
|
+
dependencies.runLocalTask ||
|
|
1237
|
+
((localTask) => startLocalTask(localTask, { session, apiOrigin }));
|
|
1114
1238
|
task.operation_promise = Promise.resolve(runLocalTask(task))
|
|
1115
1239
|
.catch(async (error) => {
|
|
1116
1240
|
task.status = "failed";
|
|
@@ -1338,6 +1462,19 @@ export async function startAgent({
|
|
|
1338
1462
|
return;
|
|
1339
1463
|
}
|
|
1340
1464
|
const indexedRepository = await ensureRepositoryIndexed(body.repository);
|
|
1465
|
+
let workflowObservationBinding;
|
|
1466
|
+
try {
|
|
1467
|
+
workflowObservationBinding = normalizeWorkflowObservationBinding(
|
|
1468
|
+
body.workflow_observation_binding,
|
|
1469
|
+
{ provider: body.backend },
|
|
1470
|
+
);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
json(res, 400, {
|
|
1473
|
+
error: error instanceof Error ? error.message : "WorkFlow observation binding is invalid.",
|
|
1474
|
+
code: "workflow_observation_binding_invalid",
|
|
1475
|
+
});
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1341
1478
|
const run = createRun({
|
|
1342
1479
|
runId,
|
|
1343
1480
|
request: {
|
|
@@ -1379,6 +1516,7 @@ export async function startAgent({
|
|
|
1379
1516
|
typeof body.design_review_url === "string" ? body.design_review_url : "",
|
|
1380
1517
|
must_not: Array.isArray(body.must_not) ? body.must_not : [],
|
|
1381
1518
|
proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
|
|
1519
|
+
workflow_observation_binding: workflowObservationBinding,
|
|
1382
1520
|
charter_context:
|
|
1383
1521
|
body.charter_context &&
|
|
1384
1522
|
typeof body.charter_context === "object" &&
|
|
@@ -1389,8 +1527,10 @@ export async function startAgent({
|
|
|
1389
1527
|
},
|
|
1390
1528
|
session,
|
|
1391
1529
|
apiOrigin,
|
|
1530
|
+
localStateRoot: codingRunStateRoot,
|
|
1392
1531
|
});
|
|
1393
1532
|
try {
|
|
1533
|
+
await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
|
|
1394
1534
|
registry.create(run);
|
|
1395
1535
|
} catch (error) {
|
|
1396
1536
|
await cleanupRunAttachments(stagedAttachments);
|
|
@@ -1678,8 +1818,10 @@ export async function startAgent({
|
|
|
1678
1818
|
await refreshAuthorizedRepositoryInventory();
|
|
1679
1819
|
if (shuttingDown) return;
|
|
1680
1820
|
await heartbeat();
|
|
1821
|
+
await restoreLocalCodingRuns();
|
|
1681
1822
|
if (shuttingDown) return;
|
|
1682
1823
|
await restoreRemoteRuns();
|
|
1824
|
+
startCodingRunMonitor();
|
|
1683
1825
|
} catch (error) {
|
|
1684
1826
|
reportStartupConnectionError(error, quiet);
|
|
1685
1827
|
}
|
|
@@ -1703,6 +1845,8 @@ export async function startAgent({
|
|
|
1703
1845
|
shuttingDown = true;
|
|
1704
1846
|
shutdownPromise = (async () => {
|
|
1705
1847
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
1848
|
+
if (codingRunMonitorTimer) clearInterval(codingRunMonitorTimer);
|
|
1849
|
+
if (codingRunMonitorInFlight) await codingRunMonitorInFlight;
|
|
1706
1850
|
updateManager?.stop?.();
|
|
1707
1851
|
await sqmLocalReviewWorker?.close?.();
|
|
1708
1852
|
await durableLocalTaskWorker?.close?.();
|
|
@@ -1824,7 +1968,8 @@ export async function startAgent({
|
|
|
1824
1968
|
});
|
|
1825
1969
|
const runId = String(remoteRun?.id || "");
|
|
1826
1970
|
if (!runId) throw new Error("SIMY Web did not return an Agentic Loop run id.");
|
|
1827
|
-
const run = createRun({ runId, request, session, apiOrigin });
|
|
1971
|
+
const run = createRun({ runId, request, session, apiOrigin, localStateRoot: codingRunStateRoot });
|
|
1972
|
+
await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
|
|
1828
1973
|
registry.create(run);
|
|
1829
1974
|
void startLocalCodingRun(run, runOptions);
|
|
1830
1975
|
return run;
|
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.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.31";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simy",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "SIMY quality, PR review, and opt-in hook-based Delivery Loop in the current conversation.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Specialist Doctors Inc.",
|
|
7
7
|
"url": "https://github.com/SpecialistDoctors-Inc"
|
|
8
8
|
},
|
|
9
9
|
"repository": "https://github.com/SpecialistDoctors-Inc/simy-cli",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
|
-
"keywords": [
|
|
11
|
+
"keywords": [
|
|
12
|
+
"simy",
|
|
13
|
+
"sqm",
|
|
14
|
+
"pull-request"
|
|
15
|
+
],
|
|
12
16
|
"skills": "./skills/",
|
|
13
17
|
"interface": {
|
|
14
18
|
"displayName": "SIMY",
|
|
15
|
-
"shortDescription": "Quality
|
|
16
|
-
"longDescription": "
|
|
19
|
+
"shortDescription": "Quality, PR review, and bounded Delivery Loop.",
|
|
20
|
+
"longDescription": "Run quality checks and PR reviews, or explicitly start a bounded Delivery Loop using user-reviewed hooks in the current conversation. Hook trust and per-task opt-in are required; Web/CLI delivery remains available.",
|
|
17
21
|
"developerName": "Specialist Doctors Inc.",
|
|
18
22
|
"category": "Developer Tools",
|
|
19
|
-
"capabilities": [
|
|
23
|
+
"capabilities": [
|
|
24
|
+
"Quality",
|
|
25
|
+
"Review",
|
|
26
|
+
"Delivery"
|
|
27
|
+
],
|
|
20
28
|
"defaultPrompt": [
|
|
21
29
|
"Run the SIMY SQM Loop for the current thread.",
|
|
22
|
-
"Review the current pull request with SIMY."
|
|
30
|
+
"Review the current pull request with SIMY.",
|
|
31
|
+
"Start SIMY Delivery Loop for the current task."
|
|
23
32
|
]
|
|
24
33
|
}
|
|
25
34
|
}
|
|
@@ -15,3 +15,18 @@ When `SIMY_WEB_ORIGIN` or `SIMY_API_ORIGIN` is set, only that origin is accepted
|
|
|
15
15
|
Otherwise the hook checks at most 16 origin-keyed discovery records and accepts
|
|
16
16
|
the first live, authenticated connection with a fresh cloud heartbeat. It never
|
|
17
17
|
scans ports or reads stored session tokens.
|
|
18
|
+
|
|
19
|
+
## Delivery Loop hooks
|
|
20
|
+
|
|
21
|
+
`delivery-loop.mjs` is an independent opt-in workflow, shared by the Codex and
|
|
22
|
+
Claude distributions. SessionStart supplies the exact session identity. It does
|
|
23
|
+
not start a loop. After an explicit `simy delivery start`, Stop checks that
|
|
24
|
+
session's checkpoint and returns bounded next-step instructions. Approval/input
|
|
25
|
+
blocks, interruption, completion, failures and budgets stop automatic continuation.
|
|
26
|
+
Provider errors/exits are surfaced to SIMY's desktop; a dead provider is not
|
|
27
|
+
silently restarted. No hook writes trust, permission settings, or SQM evidence.
|
|
28
|
+
|
|
29
|
+
Private state is under `SIMY_HOME/delivery-hooks` (default `~/.simy/delivery-hooks`),
|
|
30
|
+
keyed by provider and session ID and bound to the canonical working directory.
|
|
31
|
+
Historical hook observations do not prove hooks are still enabled. See the
|
|
32
|
+
bundled delivery-loop skill for checkpoint commands and explicit recovery.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Standalone by design: this exact module is shipped to both provider plugins.
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
export const TERMINAL = new Set(['completed', 'stopped', 'failed', 'budget_exhausted']);
|
|
11
|
+
const EVENTS = new Set(['SessionStart', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
|
|
12
|
+
const MAX_BYTES = 64 * 1024;
|
|
13
|
+
const RETRYABLE = new Set(['overloaded', 'server_error', 'rate_limit']);
|
|
14
|
+
export const rootDirectory = (env = process.env) => join(env.SIMY_HOME?.trim() || join(homedir(), '.simy'), 'delivery-hooks');
|
|
15
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
16
|
+
const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
17
|
+
export function recordPath(root, provider, session) {
|
|
18
|
+
if (!['codex', 'claude'].includes(provider) || typeof session !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(session)) throw new Error('A provider and exact session ID are required.');
|
|
19
|
+
return join(root, `${digest(`${provider}\0${session}`)}.json`);
|
|
20
|
+
}
|
|
21
|
+
async function read(file) {
|
|
22
|
+
try { const raw = await readFile(file, 'utf8'); if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('State exceeds limit.'); return JSON.parse(raw); }
|
|
23
|
+
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
24
|
+
}
|
|
25
|
+
export async function updateRecord(root, provider, session, mutate) {
|
|
26
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
27
|
+
const file = recordPath(root, provider, session), lock = `${file}.lock`;
|
|
28
|
+
// Short, non-waiting critical sections keep hooks bounded. A crashed writer's
|
|
29
|
+
// stale lock is surfaced, never silently removed while a writer may own it.
|
|
30
|
+
try { await mkdir(lock); } catch (e) { if (e.code === 'EEXIST') throw new Error('Delivery state is busy; retry the command.'); throw e; }
|
|
31
|
+
let temporary;
|
|
32
|
+
try {
|
|
33
|
+
const next = await mutate(await read(file));
|
|
34
|
+
if (next) {
|
|
35
|
+
temporary = `${file}.${randomUUID()}.tmp`;
|
|
36
|
+
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
|
|
37
|
+
await rename(temporary, file);
|
|
38
|
+
}
|
|
39
|
+
return next;
|
|
40
|
+
} finally { if (temporary) await rm(temporary, { force: true }); await rm(lock, { recursive: true, force: true }); }
|
|
41
|
+
}
|
|
42
|
+
export async function listRecords(root = rootDirectory()) {
|
|
43
|
+
let names; try { names = await readdir(root); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
44
|
+
const results = await Promise.all(names.filter(n => /^[a-f0-9]{64}\.json$/.test(n)).slice(0, 500).map(async n => {
|
|
45
|
+
try { const r = await read(join(root, n)); return r?.schemaVersion === 1 && recordPath(root, r.provider, r.session) === join(root, n) ? r : null; } catch { return null; }
|
|
46
|
+
}));
|
|
47
|
+
return results.filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
function positive(value, fallback, max) {
|
|
50
|
+
if (value === undefined) return fallback;
|
|
51
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error('Invalid delivery budget.');
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, now = Date.now() }) {
|
|
55
|
+
cwd = await realpath(cwd);
|
|
56
|
+
if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
|
|
57
|
+
return updateRecord(root, provider, session, previous => {
|
|
58
|
+
if (!previous?.observedAt || previous.cwd !== cwd) throw new Error('No matching hook session observed here. Enable SIMY hooks, then start a new turn and use the session ID from hook context.');
|
|
59
|
+
if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
60
|
+
return { ...previous, run: { id: randomUUID(), objective: text(objective), state: 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
|
|
61
|
+
deadline: now + positive(maxMinutes, 30, 1440) * 60_000, maxContinuations: positive(maxContinuations, 6, 50), maxFailures: positive(maxFailures, 3, 10),
|
|
62
|
+
continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: 'started' } };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, now = Date.now() }) {
|
|
66
|
+
try { cwd = await realpath(cwd); } catch { throw new Error('No matching active Delivery Loop.'); }
|
|
67
|
+
const statuses = ['in_progress', 'completed', 'blocked', 'error'];
|
|
68
|
+
if (!checkpoint || !statuses.includes(checkpoint.status)) throw new Error('Checkpoint status must be in_progress, completed, blocked, or error.');
|
|
69
|
+
if (!text(checkpoint.summary)) throw new Error('Checkpoint summary is required.');
|
|
70
|
+
if (['in_progress', 'error'].includes(checkpoint.status) && !text(checkpoint.nextStep)) throw new Error('A concrete nextStep is required.');
|
|
71
|
+
if (checkpoint.status === 'blocked' && !['authorization', 'missing_information'].includes(checkpoint.blocker)) throw new Error('Specify authorization or missing_information.');
|
|
72
|
+
const evidence = Array.isArray(checkpoint.evidence) ? checkpoint.evidence.map(v => text(v, 1000)).filter(Boolean).slice(0, 20) : [];
|
|
73
|
+
if (checkpoint.status === 'completed' && (!evidence.length || checkpoint.acceptanceMet !== true)) throw new Error('Completion requires acceptanceMet and concrete verification evidence.');
|
|
74
|
+
return updateRecord(root, provider, session, record => {
|
|
75
|
+
const run = record?.run;
|
|
76
|
+
if (!run || record.cwd !== cwd) throw new Error('No matching active Delivery Loop.');
|
|
77
|
+
if (TERMINAL.has(run.state) || run.state === 'blocked' || run.state === 'recovery_required') throw new Error('Loop is stopped or awaiting a human resume.');
|
|
78
|
+
run.checkpoint = { status: checkpoint.status, summary: text(checkpoint.summary), nextStep: text(checkpoint.nextStep), blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
|
|
79
|
+
run.revision += 1; run.updatedAt = now;
|
|
80
|
+
return record;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function decideStop(run, now = Date.now()) {
|
|
84
|
+
if (TERMINAL.has(run.state) || ['blocked', 'recovery_required'].includes(run.state)) return { run, output: {} };
|
|
85
|
+
const next = structuredClone(run);
|
|
86
|
+
const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
|
|
87
|
+
// A fresh, explicitly evidenced completion may finish at the budget boundary.
|
|
88
|
+
const fresh = next.checkpoint && next.revision > next.consumedRevision;
|
|
89
|
+
const cp = fresh ? next.checkpoint : null;
|
|
90
|
+
if (cp?.status === 'completed') return finish('completed', cp.summary);
|
|
91
|
+
if (cp?.status === 'blocked') { next.blocker = cp.blocker; return finish('blocked', cp.summary); }
|
|
92
|
+
if (now >= next.deadline || next.continuations >= next.maxContinuations) return finish('budget_exhausted', 'Delivery time or continuation budget exhausted.');
|
|
93
|
+
if (!fresh || cp.status === 'error') next.failures += 1;
|
|
94
|
+
else next.failures = 0;
|
|
95
|
+
if (next.failures >= next.maxFailures) return finish('failed', 'Repeated failure or missing progress checkpoint; human review required.');
|
|
96
|
+
next.consumedRevision = next.revision;
|
|
97
|
+
if (cp?.nextStep) next.nextStep = cp.nextStep;
|
|
98
|
+
next.continuations += 1; next.updatedAt = now; next.state = 'active';
|
|
99
|
+
next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
|
|
100
|
+
const instruction = fresh ? next.nextStep : `Check the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
|
|
101
|
+
return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations}/${next.maxContinuations}). ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Do not reset budgets or start a replacement loop.` } };
|
|
102
|
+
}
|
|
103
|
+
// A process signature protects against PID reuse. Never treat a stale timer alone
|
|
104
|
+
// as a crash; an idle provider may be waiting for its user.
|
|
105
|
+
export async function processIdentity(pid) {
|
|
106
|
+
if (!Number.isSafeInteger(pid) || pid < 2 || process.platform === 'win32') return null;
|
|
107
|
+
try { const { stdout } = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'lstart=,comm='], { timeout: 500, maxBuffer: 2048 }); return text(stdout, 1000) || null; }
|
|
108
|
+
catch (e) { return e.code === 1 ? '' : null; }
|
|
109
|
+
}
|
|
110
|
+
async function providerProcess(provider) {
|
|
111
|
+
if (process.platform === 'win32') return null;
|
|
112
|
+
let pid = process.ppid;
|
|
113
|
+
for (let i = 0; i < 3 && pid > 1; i++) {
|
|
114
|
+
const identity = await processIdentity(pid);
|
|
115
|
+
if (identity && new RegExp(`(?:^|[/\\\\ ])${provider}(?:$|[ .-])`, 'i').test(identity)) return { pid, identity };
|
|
116
|
+
try { const r = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'ppid='], { timeout: 300, maxBuffer: 128 }); pid = Number(r.stdout.trim()); } catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined }) {
|
|
121
|
+
if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
|
|
122
|
+
let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
|
|
123
|
+
const event = input.hook_event_name;
|
|
124
|
+
const identity = observedProcess === undefined && event === 'SessionStart' ? await providerProcess(provider) : observedProcess;
|
|
125
|
+
let output = {};
|
|
126
|
+
await updateRecord(root, provider, input.session_id, old => {
|
|
127
|
+
if (old && old.cwd !== cwd) return old;
|
|
128
|
+
const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
|
|
129
|
+
record.observedAt = now; record.lastEvent = event;
|
|
130
|
+
if (identity) record.process = identity;
|
|
131
|
+
if (event === 'SessionStart') {
|
|
132
|
+
output = { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `SIMY hooks observed this ${provider} session: ${input.session_id}. Only if the user requests SIMY Delivery Loop, use simy delivery start --provider ${provider} --session ${input.session_id} with the agreed objective and next step. Existing loop: ${record.run?.state || 'not started'}. Never enable, trust, resume, or reset a loop on the user's behalf without their request.` } };
|
|
133
|
+
}
|
|
134
|
+
const run = record.run;
|
|
135
|
+
if (!run || TERMINAL.has(run.state)) return record;
|
|
136
|
+
if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
|
|
137
|
+
else if (event === 'PermissionRequest' || event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(input.notification_type)) {
|
|
138
|
+
run.state = 'blocked'; run.blocker = 'authorization'; run.reason = 'Resolve the provider approval or input request, then explicitly resume Delivery Loop.';
|
|
139
|
+
} else if (event === 'SessionEnd') {
|
|
140
|
+
run.state = 'recovery_required'; run.reason = 'Provider session ended. Check why it ended before resuming; no automatic restart.';
|
|
141
|
+
} else if (event === 'StopFailure') {
|
|
142
|
+
run.failures += 1;
|
|
143
|
+
run.state = run.failures >= run.maxFailures ? 'failed' : 'recovery_required';
|
|
144
|
+
run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
|
|
145
|
+
run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
|
|
146
|
+
} else if (event === 'Stop') {
|
|
147
|
+
// Codex turn IDs make re-delivered Stop events idempotent. Claude has no
|
|
148
|
+
// stable event ID; never dedupe on text (identical text can be a new turn).
|
|
149
|
+
const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
|
|
150
|
+
if (key && run.lastStopKey === key) return record;
|
|
151
|
+
const decision = decideStop(run, now); record.run = decision.run; output = decision.output;
|
|
152
|
+
record.run.lastStopKey = key;
|
|
153
|
+
}
|
|
154
|
+
record.run.updatedAt = now;
|
|
155
|
+
return record;
|
|
156
|
+
});
|
|
157
|
+
return output;
|
|
158
|
+
}
|
|
159
|
+
export async function controlDelivery({ root = rootDirectory(), provider, session, action, now = Date.now() }) {
|
|
160
|
+
return updateRecord(root, provider, session, record => {
|
|
161
|
+
const run = record?.run;
|
|
162
|
+
if (!run) throw new Error('Delivery Loop not found.');
|
|
163
|
+
if (action === 'stop') { run.state = 'stopped'; run.reason = 'Stopped by user.'; }
|
|
164
|
+
else if (action === 'resume') {
|
|
165
|
+
if (!['blocked', 'recovery_required'].includes(run.state)) throw new Error('Only a blocked or recoverable loop can resume.');
|
|
166
|
+
if (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) throw new Error('Original budget exhausted. Loop cannot resume.');
|
|
167
|
+
run.state = 'active'; run.reason = 'Explicit user resume'; run.checkpoint = null; run.consumedRevision = run.revision;
|
|
168
|
+
} else throw new Error('Unsupported control.');
|
|
169
|
+
run.updatedAt = now; return record;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity } = {}) {
|
|
173
|
+
const records = await listRecords(root);
|
|
174
|
+
for (const record of records) {
|
|
175
|
+
if (!record.run || TERMINAL.has(record.run.state)) continue;
|
|
176
|
+
const observed = record.process ? await inspectProcess(record.process.pid) : null;
|
|
177
|
+
await updateRecord(root, record.provider, record.session, current => {
|
|
178
|
+
if (!current?.run || TERMINAL.has(current.run.state)) return current;
|
|
179
|
+
const run = current.run;
|
|
180
|
+
if (now >= run.deadline) { run.state = 'budget_exhausted'; run.reason = 'Delivery time budget exhausted.'; run.updatedAt = now; }
|
|
181
|
+
else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
|
|
182
|
+
run.state = 'recovery_required'; run.reason = 'Observed provider process exited. Reopen the existing conversation and explicitly resume.'; run.updatedAt = now;
|
|
183
|
+
}
|
|
184
|
+
return current;
|
|
185
|
+
}).catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
return listRecords(root);
|
|
188
|
+
}
|
|
189
|
+
export async function main(provider) {
|
|
190
|
+
let raw = '';
|
|
191
|
+
try {
|
|
192
|
+
for await (const chunk of process.stdin) { raw += String(chunk); if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('Hook input too large.'); }
|
|
193
|
+
const output = await handleHook({ input: JSON.parse(raw || '{}'), provider });
|
|
194
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
195
|
+
} catch { process.stdout.write('{}\n'); process.stderr.write('SIMY could not check delivery state; continuation was skipped. Inspect simy delivery status.\n'); }
|
|
196
|
+
}
|