@awak-app/simy-cli 0.6.16 → 0.6.18
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 +99 -6
- package/package.json +1 -1
- package/src/agent.js +123 -28
- package/src/auto-update.js +15 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
- package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
- package/src/codex-plugins/simy/hooks/hooks.json +2 -2
- package/src/codex-work-pattern-sync.js +37 -9
- package/src/delivery-hooks/command.js +9 -2
- package/src/delivery-hooks/desktop.js +10 -12
- package/src/delivery-hooks/evidence.js +50 -0
- package/src/delivery-hooks/supervisor.js +8 -2
- package/src/desktop-access.js +4 -3
- package/src/desktop-host.js +669 -73
- package/src/doctor.js +2 -1
- package/src/host-exit-diagnostics.js +28 -0
- package/src/index.js +1 -0
- package/src/native-development-report-completion.js +143 -0
- package/src/native-development-reports.js +137 -0
- package/src/orchestrator/checklist-continuation.js +5 -0
- package/src/orchestrator/delivery/WORKFLOW.md +4 -0
- package/src/orchestrator/delivery/references/state-model.md +187 -0
- package/src/orchestrator/loop.js +119 -34
- package/src/provider-links.js +74 -0
- package/src/runner.js +31 -2
- package/src/session-supervision/automatic-continuation.js +31 -2
- package/src/session-supervision/claude-sessions.js +25 -15
- package/src/session-supervision/codex-app-server.js +27 -4
- package/src/session-supervision/index.js +163 -42
- package/src/session-supervision/statistics.js +76 -15
- package/src/sqm/command.js +58 -7
- package/src/sqm/evidence-client.js +11 -1
- package/src/sqm/failure-circuit.js +28 -6
- package/src/sqm/history-store.js +49 -18
- package/src/sqm/provider-diagnostics.js +1 -0
- package/src/sqm/review-worker.js +70 -21
- package/src/sqm/workflow-update.js +12 -3
- package/src/windows-bootstrap.js +2 -1
- package/src/windows-desktop-updater.js +6 -1
- package/src/windows-inbound.js +9 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
import { execFile } from 'node:child_process';
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
|
|
@@ -27,6 +27,23 @@ export const taskDirectory = record => record.run?.executionCwd || record.cwd;
|
|
|
27
27
|
export const taskWorkspaceDirectories = record => [...new Set([record.cwd, taskDirectory(record)])];
|
|
28
28
|
export const sharesTaskWorkspace = (left, right) => taskWorkspaceDirectories(left).some(cwd => taskWorkspaceDirectories(right).includes(cwd));
|
|
29
29
|
const checkpointDirectoryMatches = (record, cwd) => record.cwd === cwd || taskDirectory(record) === cwd;
|
|
30
|
+
// Plugins ship this standalone file without the CLI's provider-links module.
|
|
31
|
+
// Honor the same durable revocation file before accepting hook work; missing
|
|
32
|
+
// files retain legacy behavior, while malformed saved policy fails closed.
|
|
33
|
+
export async function providerLinkState(root, provider) {
|
|
34
|
+
try {
|
|
35
|
+
const settings = JSON.parse(await readFile(join(dirname(resolve(root)), 'provider-links.json'), 'utf8'));
|
|
36
|
+
const valid = settings?.schemaVersion === 1 && ['codex', 'claude'].every(key => {
|
|
37
|
+
const value = settings.providers?.[key];
|
|
38
|
+
return value && typeof value.connected === 'boolean' && typeof value.requiresConsent === 'boolean' &&
|
|
39
|
+
(value.epoch === null || typeof value.epoch === 'string' && value.epoch.length > 0 && value.epoch.length <= 128);
|
|
40
|
+
});
|
|
41
|
+
return valid ? settings.providers[provider] ?? { connected: false, epoch: null } : { connected: false, epoch: null };
|
|
42
|
+
} catch (error) { return { connected: error.code === 'ENOENT', epoch: null }; }
|
|
43
|
+
}
|
|
44
|
+
export async function linkedProvider(root, provider) {
|
|
45
|
+
return (await providerLinkState(root, provider)).connected === true;
|
|
46
|
+
}
|
|
30
47
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
31
48
|
const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
32
49
|
const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
|
|
@@ -472,7 +489,10 @@ export function deliveryAcceptanceContext(run) {
|
|
|
472
489
|
` Pending acceptance (last recorded checks): ${JSON.stringify(pendingAcceptance(run))}. Continue only within the original request and permissions; manual checks require the user. SQM is optional and 0 applicable checks do not establish quality acceptance.`;
|
|
473
490
|
}
|
|
474
491
|
export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, objective, nextStep, maxFailures, continuationPolicy, managed, admission,
|
|
475
|
-
liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
|
|
492
|
+
liveHookAdmission, autoContinueEpoch, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
|
|
493
|
+
const link = await providerLinkState(root, provider);
|
|
494
|
+
if (providerLinkEpoch !== undefined && providerLinkEpoch !== link.epoch) throw Object.assign(new Error("SIMY provider connection changed before enrollment."), { code: "provider_link_changed" });
|
|
495
|
+
if (!link.connected) throw Object.assign(new Error("Reconnect this provider to SIMY before using it."), { code: "provider_link_disconnected" });
|
|
476
496
|
cwd = await realpath(cwd);
|
|
477
497
|
if (executionCwd !== undefined) {
|
|
478
498
|
executionCwd = await realpath(executionCwd);
|
|
@@ -534,6 +554,8 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
534
554
|
}
|
|
535
555
|
if (managedState && !managedState.contextBootstrap) freezeEnrollment(run, now);
|
|
536
556
|
if (waiting) escalate(run, run.reason);
|
|
557
|
+
const latest = await providerLinkState(root, provider);
|
|
558
|
+
if (!latest.connected || latest.epoch !== link.epoch) throw Object.assign(new Error("SIMY connection changed before enrollment."), { code: "provider_link_disconnected" });
|
|
537
559
|
return { ...previous, run };
|
|
538
560
|
});
|
|
539
561
|
}
|
|
@@ -1052,12 +1074,16 @@ async function providerProcess(provider) {
|
|
|
1052
1074
|
return null;
|
|
1053
1075
|
}
|
|
1054
1076
|
export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity }) {
|
|
1077
|
+
const link = await providerLinkState(root, provider);
|
|
1078
|
+
if (!link.connected) return {};
|
|
1055
1079
|
if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
|
|
1056
1080
|
let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
|
|
1057
1081
|
const event = input.hook_event_name;
|
|
1058
1082
|
const identity = observedProcess === undefined && (['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) ? await providerProcess(provider) : observedProcess;
|
|
1059
1083
|
let output = {}, boundaryLease = null, claimedWorkspace = null;
|
|
1060
1084
|
try { await updateRecord(root, provider, input.session_id, async old => {
|
|
1085
|
+
const currentLink = await providerLinkState(root, provider);
|
|
1086
|
+
if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
|
|
1061
1087
|
if (old && old.cwd !== cwd) return old;
|
|
1062
1088
|
const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
|
|
1063
1089
|
const owner = record.run?.managed?.liveHookOwner;
|
|
@@ -1139,7 +1165,8 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1139
1165
|
} else if (event === 'Stop') {
|
|
1140
1166
|
// OFF suppresses future Stop blocking; it never interrupts the provider
|
|
1141
1167
|
// turn. Lease settlement and runtime observation above still run.
|
|
1142
|
-
|
|
1168
|
+
const latestLink = await providerLinkState(root, provider);
|
|
1169
|
+
if (!automaticContinuationAllowed(record) || !latestLink.connected || latestLink.epoch !== link.epoch) return record;
|
|
1143
1170
|
// Codex turn IDs make re-delivered Stop events idempotent. Claude has no
|
|
1144
1171
|
// stable event ID; never dedupe on text (identical text can be a new turn).
|
|
1145
1172
|
const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// never a secret), and it always exits 0. The raw provider session id is never
|
|
10
10
|
// sent or stored; only a provider-scoped SHA-256 of it leaves this process.
|
|
11
11
|
|
|
12
|
+
import { linkedProvider, providerLinkState } from "./delivery-loop.mjs";
|
|
12
13
|
import { execFile } from "node:child_process";
|
|
13
14
|
import { createHash } from "node:crypto";
|
|
14
15
|
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
@@ -177,12 +178,14 @@ export async function runDevWorkHook({
|
|
|
177
178
|
fetchImpl = globalThis.fetch,
|
|
178
179
|
runImpl = run,
|
|
179
180
|
}) {
|
|
181
|
+
const sessionRoot = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
182
|
+
const link = await providerLinkState(join(sessionRoot, "delivery-hooks"), provider);
|
|
183
|
+
if (!link.connected) return { sent: false, reason: "provider_link_disconnected" };
|
|
180
184
|
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
181
185
|
const sessionId = input.session_id || input.sessionId || env.CODEX_THREAD_ID;
|
|
182
186
|
const event = await collectDevWorkEvent({ cwd, provider, sessionId, env, runImpl });
|
|
183
187
|
if (!event) return { sent: false, reason: "not_applicable" };
|
|
184
188
|
|
|
185
|
-
const sessionRoot = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
186
189
|
const pluginData =
|
|
187
190
|
env.PLUGIN_DATA?.trim() || env.CLAUDE_PLUGIN_DATA?.trim() || join(sessionRoot, "plugin-data", "simy");
|
|
188
191
|
// The upstream observation is keyed by (Project, repo, HEAD, session): a
|
|
@@ -192,10 +195,13 @@ export async function runDevWorkHook({
|
|
|
192
195
|
if ((await readState(statePath))?.fingerprint === fingerprint) {
|
|
193
196
|
return { sent: false, reason: "unchanged" };
|
|
194
197
|
}
|
|
198
|
+
if (!await linkedProvider(join(sessionRoot, "delivery-hooks"), provider)) return { sent: false, reason: "provider_link_disconnected" };
|
|
195
199
|
const port = await findAgentPort({ env, sessionRoot });
|
|
196
200
|
if (!port) return { sent: false, reason: "agent_unavailable" };
|
|
197
201
|
const prUrl = await pullRequestUrl({ cwd, repo: event.repo, env, runImpl });
|
|
198
202
|
if (prUrl) event.pr_url = prUrl;
|
|
203
|
+
const latest = await providerLinkState(join(sessionRoot, "delivery-hooks"), provider);
|
|
204
|
+
if (!latest.connected || latest.epoch !== link.epoch) return { sent: false, reason: "provider_link_disconnected" };
|
|
199
205
|
|
|
200
206
|
const response = await fetchImpl(`http://127.0.0.1:${port}/v1/dev-work-events`, {
|
|
201
207
|
method: "POST",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main();\"",
|
|
10
|
+
"command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main('codex');\"",
|
|
11
11
|
"timeout": 3,
|
|
12
12
|
"statusMessage": "SIMY connection check at session start"
|
|
13
13
|
}
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"hooks": [
|
|
30
30
|
{
|
|
31
31
|
"type": "command",
|
|
32
|
-
"command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main();\"",
|
|
32
|
+
"command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main('codex');\"",
|
|
33
33
|
"timeout": 3,
|
|
34
34
|
"statusMessage": "SIMY connection check before a prompt"
|
|
35
35
|
}
|
|
@@ -6,6 +6,7 @@ import { basename, join, relative, resolve, sep } from "node:path";
|
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
7
|
|
|
8
8
|
import { webApiErrorMessage, webApiHeaders, webApiUrl } from "./web-api.js";
|
|
9
|
+
import { readProviderLinks } from "./provider-links.js";
|
|
9
10
|
|
|
10
11
|
const MAX_THREADS = 10;
|
|
11
12
|
const MAX_MESSAGES = 24;
|
|
@@ -76,9 +77,20 @@ export async function syncCodexWorkPatterns({
|
|
|
76
77
|
onDirectoryVisited,
|
|
77
78
|
onBeforeExtract,
|
|
78
79
|
signal,
|
|
80
|
+
connectedProviders = [PROVIDER_CODEX, PROVIDER_CLAUDE],
|
|
79
81
|
}) {
|
|
80
82
|
if (!token || !deviceId || !sessionRoot) return { status: "not_connected", uploaded: 0 };
|
|
81
83
|
signal?.throwIfAborted();
|
|
84
|
+
const links = await readProviderLinks(sessionRoot);
|
|
85
|
+
const providersToRead = connectedProviders.filter(provider => links[provider]?.connected === true);
|
|
86
|
+
if (providersToRead.length === 0) return { status: "provider_disconnected", uploaded: 0 };
|
|
87
|
+
const authorizeProviders = async () => {
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
const current = await readProviderLinks(sessionRoot);
|
|
90
|
+
if (providersToRead.some(provider => !current[provider]?.connected || current[provider].epoch !== links[provider].epoch)) {
|
|
91
|
+
throw Object.assign(new Error("SIMY provider connection changed before history synchronization. Retry after reconnecting."), { code: "provider_link_changed" });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
82
94
|
// SIMY Web runs with trailingSlash=true; use the canonical slash form so the
|
|
83
95
|
// POST body never depends on a 308 redirect being replayed.
|
|
84
96
|
const endpoint = webApiUrl("work-pattern-observations/", {
|
|
@@ -114,7 +126,8 @@ export async function syncCodexWorkPatterns({
|
|
|
114
126
|
});
|
|
115
127
|
const persistedState = await readSyncState(statePath);
|
|
116
128
|
const previousState = statusPayload.reset_cursor === true ? {} : persistedState;
|
|
117
|
-
const state = previousState
|
|
129
|
+
const state = { ...previousState, providers: Object.fromEntries(Object.entries(previousState.providers || {})
|
|
130
|
+
.filter(([, cursor]) => cursor?.reset_pending !== true && (cursor?.lookback_days ?? previousState.lookback_days) === lookbackDays)) };
|
|
118
131
|
const lookbackCutoffMs = now - lookbackDays * 24 * 60 * 60 * 1_000;
|
|
119
132
|
const providerSpecs = [
|
|
120
133
|
{
|
|
@@ -131,11 +144,12 @@ export async function syncCodexWorkPatterns({
|
|
|
131
144
|
// machine-to-machine work, not a user's conversation, so exclude them.
|
|
132
145
|
includeRelativePath: (filePath) => !filePath.split(sep).includes("subagents"),
|
|
133
146
|
},
|
|
134
|
-
];
|
|
147
|
+
].filter(spec => providersToRead.includes(spec.provider));
|
|
135
148
|
const inventories = new Map();
|
|
136
149
|
let incompleteReplayRoot = false;
|
|
137
150
|
let hasCompleteRoot = false;
|
|
138
151
|
const files = (await Promise.all(providerSpecs.map(async (spec) => {
|
|
152
|
+
await authorizeProviders();
|
|
139
153
|
const providerState = providerCursor(state, spec.provider);
|
|
140
154
|
// Do not use the history window as a pre-read mtime filter: restored or
|
|
141
155
|
// migrated files can have mtimes that disagree with their message dates.
|
|
@@ -170,6 +184,7 @@ export async function syncCodexWorkPatterns({
|
|
|
170
184
|
for (const file of selectedFiles) {
|
|
171
185
|
const spec = providerSpecs.find((item) => item.provider === file.provider);
|
|
172
186
|
await onBeforeExtract?.(file);
|
|
187
|
+
await authorizeProviders();
|
|
173
188
|
const extracted = await spec.extract(file.path, {
|
|
174
189
|
deviceId,
|
|
175
190
|
fallbackObservedAt: new Date(file.mtimeMs).toISOString(),
|
|
@@ -204,6 +219,7 @@ export async function syncCodexWorkPatterns({
|
|
|
204
219
|
}
|
|
205
220
|
const shouldAcknowledgeEmptyReset =
|
|
206
221
|
statusPayload.reset_cursor === true &&
|
|
222
|
+
providerSpecs.length === 2 &&
|
|
207
223
|
hasCompleteRoot &&
|
|
208
224
|
!incompleteReplayRoot &&
|
|
209
225
|
selectedFiles.length === successfullyReadFiles.length &&
|
|
@@ -213,10 +229,10 @@ export async function syncCodexWorkPatterns({
|
|
|
213
229
|
}
|
|
214
230
|
|
|
215
231
|
const uploadResult = threads.length > 0
|
|
216
|
-
? await uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal })
|
|
232
|
+
? await uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders })
|
|
217
233
|
: { uploaded: 0 };
|
|
218
234
|
if (shouldAcknowledgeEmptyReset) {
|
|
219
|
-
const { payload } = await postThreadBatch({ endpoint, token, threads: [], fetchImpl, signal });
|
|
235
|
+
const { payload } = await postThreadBatch({ endpoint, token, threads: [], fetchImpl, signal, authorizeProviders });
|
|
220
236
|
validateThreadBatchAcknowledgement(payload, []);
|
|
221
237
|
}
|
|
222
238
|
if (selectedFiles.length === 0) return { status: "up_to_date", uploaded: 0 };
|
|
@@ -227,15 +243,21 @@ export async function syncCodexWorkPatterns({
|
|
|
227
243
|
};
|
|
228
244
|
}
|
|
229
245
|
|
|
230
|
-
|
|
246
|
+
// Keep disconnected providers' acknowledged history intact. Their own
|
|
247
|
+
// lookback records prevent a later reconnect from inheriting a changed window.
|
|
248
|
+
const providers = Object.fromEntries(Object.entries(persistedState.providers || {})
|
|
249
|
+
.filter(([provider]) => !providersToRead.includes(provider))
|
|
250
|
+
.map(([provider, cursor]) => [provider, { ...cursor, lookback_days: cursor?.lookback_days ?? persistedState.lookback_days,
|
|
251
|
+
...(statusPayload.reset_cursor === true ? { reset_pending: true } : {}) }]));
|
|
231
252
|
for (const spec of providerSpecs) {
|
|
232
|
-
providers[spec.provider] = advanceProviderCursor(
|
|
253
|
+
providers[spec.provider] = { ...advanceProviderCursor(
|
|
233
254
|
providerCursor(state, spec.provider),
|
|
234
255
|
successfullyReadFiles.filter((file) => file.provider === spec.provider),
|
|
235
256
|
inventories.get(spec.provider),
|
|
236
|
-
);
|
|
257
|
+
), lookback_days: lookbackDays };
|
|
237
258
|
}
|
|
238
259
|
signal?.throwIfAborted();
|
|
260
|
+
await authorizeProviders();
|
|
239
261
|
await writeSyncState(statePath, {
|
|
240
262
|
schema_version: STATE_SCHEMA_VERSION,
|
|
241
263
|
lookback_days: lookbackDays,
|
|
@@ -257,7 +279,7 @@ export function workPatternSyncStatePath({ sessionRoot, apiOrigin, accountId, de
|
|
|
257
279
|
return join(sessionRoot, "work-pattern-history-sync", `${scopeHash}.json`);
|
|
258
280
|
}
|
|
259
281
|
|
|
260
|
-
async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal }) {
|
|
282
|
+
async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders }) {
|
|
261
283
|
const batches = [];
|
|
262
284
|
let batch = [];
|
|
263
285
|
for (const thread of threads) {
|
|
@@ -284,13 +306,14 @@ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal
|
|
|
284
306
|
threads: current,
|
|
285
307
|
fetchImpl,
|
|
286
308
|
signal,
|
|
309
|
+
authorizeProviders,
|
|
287
310
|
});
|
|
288
311
|
uploaded += result.uploaded;
|
|
289
312
|
}
|
|
290
313
|
return { uploaded };
|
|
291
314
|
}
|
|
292
315
|
|
|
293
|
-
async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal }) {
|
|
316
|
+
async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal, authorizeProviders }) {
|
|
294
317
|
const { response, payload } = await postThreadBatch({
|
|
295
318
|
endpoint,
|
|
296
319
|
token,
|
|
@@ -298,6 +321,7 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
|
|
|
298
321
|
fetchImpl,
|
|
299
322
|
signal,
|
|
300
323
|
allow413: true,
|
|
324
|
+
authorizeProviders,
|
|
301
325
|
});
|
|
302
326
|
if (response.status !== 413) {
|
|
303
327
|
validateThreadBatchAcknowledgement(payload, threads);
|
|
@@ -311,6 +335,7 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
|
|
|
311
335
|
threads: threads.slice(0, middle),
|
|
312
336
|
fetchImpl,
|
|
313
337
|
signal,
|
|
338
|
+
authorizeProviders,
|
|
314
339
|
});
|
|
315
340
|
const right = await postBatchWithBounded413Recovery({
|
|
316
341
|
endpoint,
|
|
@@ -318,6 +343,7 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
|
|
|
318
343
|
threads: threads.slice(middle),
|
|
319
344
|
fetchImpl,
|
|
320
345
|
signal,
|
|
346
|
+
authorizeProviders,
|
|
321
347
|
});
|
|
322
348
|
return { uploaded: left.uploaded + right.uploaded };
|
|
323
349
|
}
|
|
@@ -334,11 +360,13 @@ async function postThreadBatch({
|
|
|
334
360
|
fetchImpl,
|
|
335
361
|
signal,
|
|
336
362
|
allow413 = false,
|
|
363
|
+
authorizeProviders,
|
|
337
364
|
}) {
|
|
338
365
|
const body = JSON.stringify({ threads });
|
|
339
366
|
if (Buffer.byteLength(body, "utf8") > MAX_GATEWAY_BODY_BYTES) {
|
|
340
367
|
throw new Error("Work-pattern observation payload exceeds the local CLI gateway limit.");
|
|
341
368
|
}
|
|
369
|
+
await authorizeProviders();
|
|
342
370
|
const response = await fetchImpl(endpoint, {
|
|
343
371
|
method: "POST",
|
|
344
372
|
headers: webApiHeaders({ token }, { "Content-Type": "application/json" }),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { deliveryEvidence } from './evidence.js';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery, readRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
4
|
import { continueSession } from '../session-supervision/index.js';
|
|
4
5
|
import { cancelHandoffSession, handoffSession } from '../session-supervision/handoff.js';
|
|
5
6
|
import { runActivityCommand } from './activity.js';
|
|
@@ -20,6 +21,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
|
|
|
20
21
|
const [action, ...args] = argv;
|
|
21
22
|
if (action === 'activity') return runActivityCommand(args, { root, output });
|
|
22
23
|
if (!action || action === '--help') {
|
|
24
|
+
output('simy delivery evidence --provider codex|claude --session ID — read-only execution and acceptance receipts.');
|
|
23
25
|
output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
|
|
24
26
|
output('A task stops after repeated failures, and pauses for review when no progress is confirmed. Resume starts a new progress window.');
|
|
25
27
|
output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
|
|
@@ -36,7 +38,12 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
|
|
|
36
38
|
}
|
|
37
39
|
const base = { root, cwd, provider: options.provider, session: options.session };
|
|
38
40
|
let result;
|
|
39
|
-
if (action === '
|
|
41
|
+
if (action === 'evidence') {
|
|
42
|
+
const record = await readRecord(root, options.provider, options.session);
|
|
43
|
+
if (!record) throw new Error('Exact provider session record not found.');
|
|
44
|
+
result = deliveryEvidence(record, root);
|
|
45
|
+
}
|
|
46
|
+
else if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
|
|
40
47
|
else if (action === 'continue') result = await continueSession({ ...base, executionCwd: options['execution-cwd'], autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
|
|
41
48
|
...Object.fromEntries([['max-failures', 'maxFailures'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
|
|
42
49
|
.filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
|
|
@@ -4,7 +4,7 @@ import { canReplanDelivery, pendingAcceptance, deliveryStop, deliveryStopBlocksR
|
|
|
4
4
|
import { originalSessionTarget } from '../session-supervision/original-session-target.js';
|
|
5
5
|
|
|
6
6
|
export const HOOK_SETUP = Object.freeze({
|
|
7
|
-
codex: { settingsUrl:
|
|
7
|
+
codex: { settingsUrl: null, docsUrl: 'https://learn.chatgpt.com/docs/hooks', command: '/hooks' },
|
|
8
8
|
claude: { settingsUrl: null, docsUrl: 'https://code.claude.com/docs/en/hooks-guide', command: '/hooks' },
|
|
9
9
|
});
|
|
10
10
|
|
|
@@ -85,19 +85,17 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
88
|
+
// Codex registering its URL scheme does not prove a settings route exists.
|
|
89
|
+
// Both providers use their official guide and leave hook trust to the user.
|
|
90
90
|
export async function openHookSetup(provider, { platform = process.platform, run = promisify(execFile) } = {}) {
|
|
91
91
|
const setup = HOOK_SETUP[provider];
|
|
92
92
|
if (!setup) throw new Error("Unsupported provider.");
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
} catch {}
|
|
93
|
+
const command = platform === "darwin" ? "/usr/bin/open" : platform === "win32" ? "rundll32" : "xdg-open";
|
|
94
|
+
const args = platform === "win32" ? ["url.dll,FileProtocolHandler", setup.docsUrl] : [setup.docsUrl];
|
|
95
|
+
try {
|
|
96
|
+
await run(command, args, { timeout: 5000, maxBuffer: 4096 });
|
|
97
|
+
return { opened: true, url: setup.docsUrl };
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error("Could not open hook setup. Use /hooks inside your provider.");
|
|
101
100
|
}
|
|
102
|
-
throw new Error("Could not open hook setup. Use /hooks inside your provider.");
|
|
103
101
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { pendingAcceptance, recordPath, taskDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
2
|
+
|
|
3
|
+
// A read-only projection: provider reports and continuation instructions are
|
|
4
|
+
// deliberately separate from supervisor checkpoint receipts and acceptance.
|
|
5
|
+
export function deliveryEvidence(record, root) {
|
|
6
|
+
const run = record?.run;
|
|
7
|
+
const managed = run?.managed;
|
|
8
|
+
const attempts = managed?.attempts || [];
|
|
9
|
+
const counts = Object.fromEntries(['checkpoint', 'failed', 'cancelled', 'busy'].map(outcome =>
|
|
10
|
+
[outcome, attempts.filter(attempt => attempt.outcome === outcome).length]));
|
|
11
|
+
return {
|
|
12
|
+
schemaVersion: 1,
|
|
13
|
+
provider: record.provider, session: record.session,
|
|
14
|
+
recordPath: recordPath(root, record.provider, record.session),
|
|
15
|
+
providerCwd: record.cwd, executionCwd: taskDirectory(record),
|
|
16
|
+
runId: run?.id || null, taskId: managed?.taskId || null,
|
|
17
|
+
mode: !run ? 'not_started' : managed ? 'managed' : 'checkpoint_tracking',
|
|
18
|
+
state: run?.state || null,
|
|
19
|
+
controller: managed ? { capability: managed.capability, resumeChannel: managed.resumeChannel,
|
|
20
|
+
autoRecovery: managed.autoRecovery === true, deliveryLevel: managed.deliveryLevel || null } : null,
|
|
21
|
+
execution: {
|
|
22
|
+
supervisorAttemptsTotal: managed?.autoAttempts || 0,
|
|
23
|
+
retainedAttemptCount: attempts.length, retainedAttemptOutcomes: counts,
|
|
24
|
+
supervisorStructuredCheckpointCount: counts.checkpoint,
|
|
25
|
+
currentAttempt: managed?.executionAttempt ? {
|
|
26
|
+
id: managed.executionAttempt.id, phase: managed.executionAttempt.phase,
|
|
27
|
+
dispatchedAt: managed.executionAttempt.dispatchedAt || null,
|
|
28
|
+
checkpointRevision: managed.executionAttempt.checkpointRevision ?? null,
|
|
29
|
+
} : null,
|
|
30
|
+
continuationInstructions: run?.continuations || 0,
|
|
31
|
+
providerReportedWorkCount: managed?.workHistory?.length || 0,
|
|
32
|
+
historyScope: 'retained_history_only',
|
|
33
|
+
},
|
|
34
|
+
acceptance: {
|
|
35
|
+
status: !managed || !managed.acceptanceCriteria?.length ? 'not_independently_verified'
|
|
36
|
+
: pendingAcceptance(run).length ? 'pending' : 'recorded_checks_passed',
|
|
37
|
+
criteria: (managed?.acceptanceCriteria || []).map(criterion => ({
|
|
38
|
+
id: criterion.id, method: criterion.check.type,
|
|
39
|
+
receipt: managed.verified?.[criterion.id] || null,
|
|
40
|
+
})),
|
|
41
|
+
pending: managed ? pendingAcceptance(run).map(criterion => criterion.id) : [],
|
|
42
|
+
},
|
|
43
|
+
limitations: [
|
|
44
|
+
'Enrollment, claimed attempts and continuation instructions do not prove provider execution.',
|
|
45
|
+
'Structured checkpoints and provider-reported work do not certify user acceptance or DMG behavior.',
|
|
46
|
+
'Attempt and work history are bounded; counts describe retained receipts, not lifetime executions.',
|
|
47
|
+
'Recorded acceptance is a snapshot; this read-only command does not rerun checks.',
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requireProviderLink } from '../provider-links.js';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { dirname } from 'node:path';
|
|
@@ -36,6 +37,7 @@ function watchLease(root, provider, session, inspect, onFailure) {
|
|
|
36
37
|
}
|
|
37
38
|
async function assertActiveLease(root, provider, session, leaseId, signal, currentTime = Date.now) {
|
|
38
39
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
40
|
+
await requireProviderLink({ root, provider });
|
|
39
41
|
const current = await readRecord(root, provider, session);
|
|
40
42
|
if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
|
|
41
43
|
throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
@@ -159,6 +161,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
159
161
|
continue;
|
|
160
162
|
}
|
|
161
163
|
if (!automaticContinuationAllowed(record)) continue;
|
|
164
|
+
try { await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
|
|
162
165
|
const appServer = run.managed.resumeChannel === 'codex_app_server';
|
|
163
166
|
const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
|
|
164
167
|
const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
|
|
@@ -168,8 +171,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
168
171
|
if (run.state !== 'recovery_required' || !causeAllowed || !run.managed.autoRecovery || run.managed.pendingApproval || run.managed.autonomyPolicy && run.managed.decision?.outcome === 'wait' && !approvedWait || appServer && run.managed.nextAttemptAt > claimNow) continue;
|
|
169
172
|
let authorizationScope;
|
|
170
173
|
const authorizeClaim = async () => {
|
|
174
|
+
await requireProviderLink({ root, provider: record.provider });
|
|
171
175
|
if (!authorizeDispatch) return;
|
|
172
|
-
const latest = await authorizeDispatch();
|
|
176
|
+
const latest = await authorizeDispatch(record);
|
|
173
177
|
if (authorizationScope !== undefined && latest !== authorizationScope) throw Object.assign(new Error("Desktop authorization scope changed."), { code: "desktop_access_required" });
|
|
174
178
|
authorizationScope = latest;
|
|
175
179
|
};
|
|
@@ -285,7 +289,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
285
289
|
} });
|
|
286
290
|
}
|
|
287
291
|
}
|
|
288
|
-
} catch (error) { result = error?.code === '
|
|
292
|
+
} catch (error) { result = error?.code === 'provider_link_disconnected'
|
|
293
|
+
? { code: -2, cancelled: true }
|
|
294
|
+
: error?.code === 'desktop_access_required'
|
|
289
295
|
? { code: -3, busy: true, accessDenied: true }
|
|
290
296
|
: { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
|
|
291
297
|
errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
|
package/src/desktop-access.js
CHANGED
|
@@ -8,11 +8,12 @@ export function validDesktopAccessPath(path, slug) {
|
|
|
8
8
|
}
|
|
9
9
|
export const desktopAccessReady = value => value?.state === 'ready';
|
|
10
10
|
const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
|
|
11
|
-
'check_update', 'update', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
|
|
12
|
-
'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
|
|
11
|
+
'check_update', 'update', 'prepare_update_restart', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
|
|
12
|
+
'provider_disconnect', 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
|
|
13
13
|
export function desktopActionAllowed(access, command) {
|
|
14
14
|
return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
|
|
15
|
-
command?.action === 'session_set_auto_continue' && command.enabled === false
|
|
15
|
+
command?.action === 'session_set_auto_continue' && command.enabled === false ||
|
|
16
|
+
command?.action === 'session_set_email_reports' && command.enabled === false;
|
|
16
17
|
}
|
|
17
18
|
export function requireDesktopAccess(access) {
|
|
18
19
|
if (!desktopAccessReady(access)) throw Object.assign(new Error('Sign in to an organization with SQM and Developer access before using SIMY.'), { code: 'desktop_access_required' });
|