@awak-app/simy-cli 0.7.26 → 0.7.27
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 +2 -0
- package/package.json +1 -1
- package/src/agent.js +22 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +262 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +33 -4
- package/src/delivery-hooks/command.js +8 -0
- package/src/desktop-host.js +6 -2
- package/src/session-supervision/index.js +2 -2
- package/src/session-supervision/stop-notifications.js +26 -10
- package/src/sqm/claude-plugin.js +8 -1
package/README.md
CHANGED
|
@@ -345,6 +345,8 @@ from the same maintained source as Codex; only provider terminology and hook/man
|
|
|
345
345
|
format differ. Installing source files does not imply that either provider has enabled
|
|
346
346
|
the plugin. Existing Claude settings, unrelated marketplaces, and plugins are preserved.
|
|
347
347
|
|
|
348
|
+
State-only feature-improvement collection ships with the generated Claude plugin, but uploads require the paired desktop CLI runtime to refresh device consent and drain its local outbox. Without that paired authenticated runtime, the plugin remains default-off and does not enable Auto-continue.
|
|
349
|
+
|
|
348
350
|
After that first installation, the daemon checks installed workflows at startup
|
|
349
351
|
and every hour, including after a CLI automatic upgrade. It updates only existing
|
|
350
352
|
platform installations and refreshes an already enabled SIMY Codex plugin through
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
|
|
2
|
+
import { flushObservability, observeHeartbeat, syncObservabilityConsent } from './cli-observability.js';
|
|
2
3
|
import { readLocalPhotoSource } from "./local-photos.js";
|
|
3
4
|
import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
|
|
4
5
|
import { observePairedNativeIdentities } from './native-provider-identity.js';
|
|
@@ -939,6 +940,27 @@ export async function startAgent({
|
|
|
939
940
|
deviceHeartbeatSucceeded = true;
|
|
940
941
|
connectionStatus.success();
|
|
941
942
|
notifyDesktopStatusChange();
|
|
943
|
+
// Delivery state persistence writes the bounded local outbox. Draining
|
|
944
|
+
// it here reuses the authenticated existing runtime; it never creates
|
|
945
|
+
// a daemon, changes Auto-continue, or blocks the device heartbeat.
|
|
946
|
+
const observabilityPaired = typeof requestedSession.account_id === 'string' && requestedSession.account_id.length > 0 &&
|
|
947
|
+
typeof requestedSession.device_id === 'string' && requestedSession.device_id.length > 0;
|
|
948
|
+
if (observabilityPaired) {
|
|
949
|
+
void syncObservabilityConsent({
|
|
950
|
+
root: sessionRoot,
|
|
951
|
+
apiOrigin,
|
|
952
|
+
apiBaseUrl: requestedSession.api_base_url,
|
|
953
|
+
token: requestedSession.token,
|
|
954
|
+
actor: { accountId: requestedSession.account_id, deviceId: requestedSession.device_id, organizationId: requestedSession.organization_id },
|
|
955
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
956
|
+
}).then(() => observeHeartbeat(sessionRoot)).then(() => flushObservability({
|
|
957
|
+
root: sessionRoot,
|
|
958
|
+
apiOrigin,
|
|
959
|
+
apiBaseUrl: requestedSession.api_base_url,
|
|
960
|
+
token: requestedSession.token,
|
|
961
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
962
|
+
})).catch(() => {});
|
|
963
|
+
}
|
|
942
964
|
// Register the device immediately; provider subprocesses must not delay
|
|
943
965
|
// browser authorization. Publish verified readiness after their probe.
|
|
944
966
|
const publishVerifiedProviders = async () => {
|
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.27";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// State-only feature-improvement transport. It never imports provider clients or
|
|
2
|
+
// serializes conversation content, paths, titles, logs, or credentials.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { link, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { webApiHeaders, webApiUrl } from './web-api.js';
|
|
9
|
+
// Keep this leaf module package-safe: hooks load it before the full CLI contract.
|
|
10
|
+
const CLI_VERSION = '0.7.27';
|
|
11
|
+
|
|
12
|
+
const STORE = 'cli-observability-state.json';
|
|
13
|
+
const LEGACY_CONFIG = 'cli-observability.json';
|
|
14
|
+
const LEGACY_OUTBOX = 'cli-observability-outbox.json';
|
|
15
|
+
const MAX_BYTES = 256 * 1024, MAX_AGE = 7 * 24 * 60 * 60 * 1000, MAX_FLUSH = 50;
|
|
16
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
|
+
const EVENT_TYPES = new Set(['settings_changed', 'execution_requested', 'execution_started', 'execution_ended', 'result_confirmed', 'observation_gap', 'heartbeat']);
|
|
18
|
+
const EXECUTION = new Set(['idle', 'start_requested', 'started', 'ended', 'unknown']);
|
|
19
|
+
const RESULTS = new Set(['success', 'failed', 'waiting_human', 'reported_done', 'unknown']);
|
|
20
|
+
const REASONS = new Set(['cancelled', 'needs_user_input', 'timeout', 'provider_error', 'transport_error', 'runtime_recovery', 'queue_overflow', 'sequence_gap', 'clock_skew', 'session_expired']);
|
|
21
|
+
const keyFor = (provider, session) => `${provider}:${session}`;
|
|
22
|
+
const file = (root, name) => join(root, name);
|
|
23
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
24
|
+
|
|
25
|
+
async function processIdentity(pid) {
|
|
26
|
+
if (!Number.isSafeInteger(pid) || pid < 2 || process.platform === 'win32') return null;
|
|
27
|
+
try { const { stdout } = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'lstart=,comm='], { timeout: 500, maxBuffer: 2048 }); return stdout.trim() || ''; }
|
|
28
|
+
catch (error) { return error.code === 1 ? '' : null; }
|
|
29
|
+
}
|
|
30
|
+
function environmentFromOrigin(origin) {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(origin);
|
|
33
|
+
if (url.protocol !== 'https:' || url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null;
|
|
34
|
+
return url.hostname === 'app-dev.simy.one' ? 'dev' : url.hostname === 'app.simy.one' ? 'prod' : null;
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
async function withStoreLock(root, operation) {
|
|
38
|
+
const lock = file(root, '.cli-observability.lock');
|
|
39
|
+
const owner = {
|
|
40
|
+
pid: process.pid,
|
|
41
|
+
identity: await processIdentity(process.pid),
|
|
42
|
+
token: randomUUID(),
|
|
43
|
+
};
|
|
44
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
45
|
+
const candidate = `${lock}.${owner.token}.candidate`;
|
|
46
|
+
await writeFile(candidate, JSON.stringify(owner), { mode: 0o600, flag: 'wx' });
|
|
47
|
+
try {
|
|
48
|
+
for (let i = 0; i < 40; i++) {
|
|
49
|
+
try {
|
|
50
|
+
// link(2) publishes an already-complete owner file atomically.
|
|
51
|
+
await link(candidate, lock);
|
|
52
|
+
try {
|
|
53
|
+
return await operation();
|
|
54
|
+
} finally {
|
|
55
|
+
try {
|
|
56
|
+
const held = JSON.parse(await readFile(lock, 'utf8'));
|
|
57
|
+
if (held.token === owner.token) await rm(lock, { force: true });
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code !== 'EEXIST') throw error;
|
|
62
|
+
let prior;
|
|
63
|
+
try {
|
|
64
|
+
prior = JSON.parse(await readFile(lock, 'utf8'));
|
|
65
|
+
const current = await processIdentity(prior?.pid);
|
|
66
|
+
if (current !== '' && !(current && prior?.identity && current !== prior.identity)) {
|
|
67
|
+
await sleep(10 + Math.floor(Math.random() * 10));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Move the exact stale inode away. This avoids a stale contender
|
|
71
|
+
// deleting a replacement lock published by another process.
|
|
72
|
+
const reclaimed = `${lock}.${owner.token}.reclaimed`;
|
|
73
|
+
await rename(lock, reclaimed);
|
|
74
|
+
const moved = JSON.parse(await readFile(reclaimed, 'utf8'));
|
|
75
|
+
if (moved?.token !== prior?.token) {
|
|
76
|
+
// A release/reacquire raced us. Restore only if nobody has already
|
|
77
|
+
// published a replacement; never remove a lock we do not own.
|
|
78
|
+
try { await link(reclaimed, lock); } catch {}
|
|
79
|
+
}
|
|
80
|
+
await rm(reclaimed, { force: true });
|
|
81
|
+
} catch {}
|
|
82
|
+
await sleep(10 + Math.floor(Math.random() * 10));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
await rm(candidate, { force: true });
|
|
87
|
+
}
|
|
88
|
+
throw new Error('Observability store is busy.');
|
|
89
|
+
}
|
|
90
|
+
async function readJson(path, fallback) { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return fallback; throw error; } }
|
|
91
|
+
async function saveJson(path, value) { await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const tmp = `${path}.${randomUUID()}.tmp`; try { await writeFile(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 }); await rename(tmp, path); } finally { await rm(tmp, { force: true }); } }
|
|
92
|
+
function blankConfig() { return { schemaVersion: 1, enabled: false, consentGeneration: null, environment: null, actor: null, conversations: {}, retry: { attempts: 0, at: 0 }, lost: false }; }
|
|
93
|
+
function blankState() { return { schemaVersion: 1, config: blankConfig(), outbox: { schemaVersion: 1, events: [] } }; }
|
|
94
|
+
function validConversation(value) { return value && ['codex', 'claude'].includes(value.provider) && typeof value.session === 'string' && /^[A-Za-z0-9_-]{1,256}$/.test(value.session) && UUID.test(String(value.conversationRef)) && ['dev', 'prod'].includes(value.environment); }
|
|
95
|
+
function validGeneration(value) { return UUID.test(String(value)); }
|
|
96
|
+
async function readStore(root) {
|
|
97
|
+
const existing = await readJson(file(root, STORE), null);
|
|
98
|
+
if (existing?.schemaVersion === 1 && existing.config?.conversations && Array.isArray(existing.outbox?.events)) return existing;
|
|
99
|
+
const oldConfig = await readJson(file(root, LEGACY_CONFIG), null), oldOutbox = await readJson(file(root, LEGACY_OUTBOX), null);
|
|
100
|
+
if (!oldConfig && !oldOutbox) return blankState();
|
|
101
|
+
const state = blankState(); state.config = { ...state.config, ...oldConfig, conversations: oldConfig?.conversations || oldConfig?.scopes || {}, lost: !(oldConfig && oldOutbox) };
|
|
102
|
+
state.outbox = oldOutbox || { schemaVersion: 1, events: [] }; return state;
|
|
103
|
+
}
|
|
104
|
+
async function saveStore(root, state) { await saveJson(file(root, STORE), state); }
|
|
105
|
+
function status(state) { return { enabled: state.config.enabled === true, conversations: Object.values(state.config.conversations).map(({ provider, session, sequence }) => ({ provider, session, sequence })), queued: state.outbox.events.length }; }
|
|
106
|
+
export async function readObservability(root) { return (await readStore(root)).config; }
|
|
107
|
+
export async function observabilityStatus(root) { return status(await readStore(root)); }
|
|
108
|
+
function stateFromRecord(record, now = Date.now()) {
|
|
109
|
+
const raw = record?.run?.state;
|
|
110
|
+
const fresh = Number.isFinite(record?.runtime?.observedAt) && now >= record.runtime.observedAt && now - record.runtime.observedAt <= 45_000;
|
|
111
|
+
const executionState = fresh && record.runtime?.isRunning === true ? 'started' : fresh && record.runtime?.isRunning === false ? 'idle' : ['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(raw) ? 'ended' : 'unknown';
|
|
112
|
+
return { autoContinue: record?.autoContinue === true ? 'on' : record?.autoContinue === false ? 'off' : 'unknown', executionState,
|
|
113
|
+
result: raw === 'reported_done' ? 'reported_done' : 'unknown', ...(fresh && record.runtime?.isRunning === true ? { runtimeObservedAt: new Date(record.runtime.observedAt).toISOString() } : {}),
|
|
114
|
+
...({ stopped: 'cancelled', budget_exhausted: 'timeout', paused: 'needs_user_input', blocked: 'needs_user_input', recovery_required: 'runtime_recovery' }[raw] ? { reasonCode: ({ stopped: 'cancelled', budget_exhausted: 'timeout', paused: 'needs_user_input', blocked: 'needs_user_input', recovery_required: 'runtime_recovery' })[raw] } : {}) };
|
|
115
|
+
}
|
|
116
|
+
export const observabilityStateFromRecord = stateFromRecord;
|
|
117
|
+
function transition(previous, next) {
|
|
118
|
+
if (!previous || previous.autoContinue !== next.autoContinue) return previous ? 'settings_changed' : null;
|
|
119
|
+
if (previous.executionState !== next.executionState) return next.executionState === 'started' ? 'execution_started' : next.executionState === 'ended' ? 'execution_ended' : 'observation_gap';
|
|
120
|
+
return previous.result !== next.result && next.result !== 'unknown' ? 'result_confirmed' : null;
|
|
121
|
+
}
|
|
122
|
+
function event(conversation, key, type, state, now) {
|
|
123
|
+
if (!EVENT_TYPES.has(type) || !EXECUTION.has(state.executionState) || !RESULTS.has(state.result)) throw new Error('Invalid observability event.');
|
|
124
|
+
const request = { schema_version: 1, event_id: randomUUID(), consent_generation: conversation.consentGeneration, provider: conversation.provider, environment: conversation.environment, conversation_ref: conversation.conversationRef, sequence: conversation.sequence, event_type: type, occurred_at: new Date(now).toISOString(), auto_continue: state.autoContinue, execution_state: state.executionState, result: state.result, cli_version: CLI_VERSION };
|
|
125
|
+
if (state.reasonCode && REASONS.has(state.reasonCode)) request.reason_code = state.reasonCode;
|
|
126
|
+
if (type === 'execution_started' && state.runtimeObservedAt) request.runtime_observed_at = state.runtimeObservedAt;
|
|
127
|
+
if (state.missing === true) request.missing = true;
|
|
128
|
+
return { key, createdAt: now, request };
|
|
129
|
+
}
|
|
130
|
+
function limits(options = {}) { return { maxBytes: options.maxBytes ?? MAX_BYTES, maxAge: options.maxAge ?? MAX_AGE }; }
|
|
131
|
+
function trim(state, now, options) {
|
|
132
|
+
const dropped = new Map(), { maxBytes, maxAge } = limits(options), remember = (item, reason) => { if (item?.key && !dropped.has(item.key)) dropped.set(item.key, reason); };
|
|
133
|
+
const retained = []; for (const item of state.outbox.events) { if (!Number.isFinite(item.createdAt) || item.createdAt < now - maxAge) remember(item, 'sequence_gap'); else retained.push(item); } state.outbox.events = retained;
|
|
134
|
+
while (Buffer.byteLength(JSON.stringify(state.outbox), 'utf8') > maxBytes && state.outbox.events.length) remember(state.outbox.events.shift(), 'queue_overflow');
|
|
135
|
+
return dropped;
|
|
136
|
+
}
|
|
137
|
+
function enqueueGap(state, key, reason, now) { const c = state.config.conversations[key]; if (!validConversation(c)) return; c.sequence = Number.isSafeInteger(c.sequence) && c.sequence >= 0 ? c.sequence + 1 : 1; state.outbox.events.push(event(c, key, 'observation_gap', { autoContinue: c.last?.autoContinue || 'unknown', executionState: 'unknown', result: 'unknown', reasonCode: reason, missing: true }, now)); }
|
|
138
|
+
function materializeGaps(state, dropped, now, options) {
|
|
139
|
+
for (const [key, reason] of dropped) enqueueGap(state, key, reason, now);
|
|
140
|
+
// Gap markers are compact; preserve them in preference to ordinary events.
|
|
141
|
+
const { maxBytes } = limits(options);
|
|
142
|
+
const lateDrops = new Map();
|
|
143
|
+
while (Buffer.byteLength(JSON.stringify(state.outbox), 'utf8') > maxBytes) {
|
|
144
|
+
const index = state.outbox.events.findIndex(item => item.request?.event_type !== 'observation_gap');
|
|
145
|
+
if (index < 0) break;
|
|
146
|
+
const [removed] = state.outbox.events.splice(index, 1);
|
|
147
|
+
if (removed?.key && !dropped.has(removed.key) && !lateDrops.has(removed.key)) lateDrops.set(removed.key, 'queue_overflow');
|
|
148
|
+
}
|
|
149
|
+
// A marker may itself be the only remaining item and be larger than an
|
|
150
|
+
// artificially tiny test limit; production's 10 MiB limit easily holds it.
|
|
151
|
+
for (const [key, reason] of lateDrops) enqueueGap(state, key, reason, now);
|
|
152
|
+
}
|
|
153
|
+
function resetForIdentity(state, actor) { state.config = { ...blankConfig(), actor }; state.outbox = { schemaVersion: 1, events: [] }; }
|
|
154
|
+
function sameActor(a, b) { return a && b && a.origin === b.origin && a.accountId === b.accountId && a.deviceId === b.deviceId && a.organizationId === b.organizationId; }
|
|
155
|
+
export async function syncObservabilityConsent({ root, apiOrigin, apiBaseUrl, token, actor, fetchImpl = globalThis.fetch }) {
|
|
156
|
+
const environment = environmentFromOrigin(apiOrigin);
|
|
157
|
+
// Collection is defined only for canonical SIMY dev/prod origins. Fixtures,
|
|
158
|
+
// loopback hosts, and arbitrary origins stay inert without a network call.
|
|
159
|
+
if (!environment) {
|
|
160
|
+
const preflight = await readStore(root);
|
|
161
|
+
if (!preflight.config.enabled && preflight.outbox.events.length === 0) return status(preflight);
|
|
162
|
+
return withStoreLock(root, async () => {
|
|
163
|
+
const state = await readStore(root);
|
|
164
|
+
state.config = blankConfig();
|
|
165
|
+
state.outbox = { schemaVersion: 1, events: [] };
|
|
166
|
+
await saveStore(root, state);
|
|
167
|
+
return status(state);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const response = await fetchImpl(webApiUrl('devices/heartbeat/?observability=consent', { webOrigin: apiOrigin, apiBaseUrl }), { method: 'POST', headers: webApiHeaders({ token }), body: '{}', signal: AbortSignal.timeout(5_000) });
|
|
171
|
+
const payload = await response.json().catch(() => null); if (!response.ok) throw new Error('SIMY Web could not read observability consent.');
|
|
172
|
+
const identity = { origin: apiOrigin, accountId: actor?.accountId || null, deviceId: actor?.deviceId || null, organizationId: actor?.organizationId || null };
|
|
173
|
+
const generation = payload?.consent_generation;
|
|
174
|
+
const active = payload?.enabled === true && validGeneration(generation) && typeof identity.accountId === 'string' && identity.accountId.length > 0 && typeof identity.deviceId === 'string' && identity.deviceId.length > 0;
|
|
175
|
+
// A negative first response is deliberately side-effect free. The periodic
|
|
176
|
+
// device heartbeat can ask the server for consent, but default OFF must not
|
|
177
|
+
// create a collector directory that races ordinary temporary-root cleanup.
|
|
178
|
+
const preflight = await readStore(root);
|
|
179
|
+
const needsCleanup = preflight.config.enabled || validGeneration(preflight.config.consentGeneration) ||
|
|
180
|
+
preflight.outbox.events.length > 0 || Object.keys(preflight.config.conversations).length > 0;
|
|
181
|
+
if (!active && !needsCleanup) return status(preflight);
|
|
182
|
+
return withStoreLock(root, async () => {
|
|
183
|
+
const state = await readStore(root);
|
|
184
|
+
if (!active) {
|
|
185
|
+
if (state.config.actor && !sameActor(state.config.actor, identity)) resetForIdentity(state, identity); else state.config.actor = identity;
|
|
186
|
+
state.outbox.events = [];
|
|
187
|
+
for (const c of Object.values(state.config.conversations)) { c.sequence = 0; c.last = null; }
|
|
188
|
+
state.config.enabled = false;
|
|
189
|
+
state.config.environment = environment;
|
|
190
|
+
state.config.consentGeneration = null;
|
|
191
|
+
state.config.retry = { attempts: 0, at: 0 };
|
|
192
|
+
await saveStore(root, state);
|
|
193
|
+
return status(state);
|
|
194
|
+
}
|
|
195
|
+
if (state.config.actor && !sameActor(state.config.actor, identity)) resetForIdentity(state, identity); else state.config.actor = identity;
|
|
196
|
+
if (state.config.consentGeneration && state.config.consentGeneration !== generation) {
|
|
197
|
+
state.outbox.events = [];
|
|
198
|
+
for (const c of Object.values(state.config.conversations)) { c.sequence = 0; c.last = null; }
|
|
199
|
+
}
|
|
200
|
+
state.config.enabled = true;
|
|
201
|
+
state.config.environment = environment;
|
|
202
|
+
state.config.consentGeneration = generation;
|
|
203
|
+
state.config.retry = { attempts: 0, at: 0 };
|
|
204
|
+
await saveStore(root, state);
|
|
205
|
+
return status(state);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// Called only after authoritative record persistence. The lock covers every
|
|
209
|
+
// state/outbox write; it is intentionally never held while network I/O runs.
|
|
210
|
+
export async function observeDeliveryRecord(root, record, now = Date.now(), options = {}) {
|
|
211
|
+
if (!record || !['codex', 'claude'].includes(record.provider) || typeof record.session !== 'string') return false;
|
|
212
|
+
// Default OFF must be inert: do not create a lock, directory, or queue until
|
|
213
|
+
// the paired device has recorded active consent. Recheck under the lock so a
|
|
214
|
+
// concurrent consent change cannot publish an event under a stale generation.
|
|
215
|
+
const preflight = (await readStore(root)).config;
|
|
216
|
+
if (!preflight.enabled || !validGeneration(preflight.consentGeneration) || !['dev', 'prod'].includes(preflight.environment)) return false;
|
|
217
|
+
return withStoreLock(root, async () => { const state = await readStore(root), config = state.config; if (!config.enabled || !validGeneration(config.consentGeneration) || !['dev', 'prod'].includes(config.environment)) return false;
|
|
218
|
+
const key = keyFor(record.provider, record.session); let c = config.conversations[key];
|
|
219
|
+
if (!c) { c = config.conversations[key] = { provider: record.provider, session: record.session, environment: config.environment, conversationRef: randomUUID(), consentGeneration: config.consentGeneration, sequence: 0, last: stateFromRecord(record, now), lastObservedAt: now };
|
|
220
|
+
if (config.lost) { enqueueGap(state, key, 'sequence_gap', now); config.lost = false; await saveStore(root, state); return true; }
|
|
221
|
+
await saveStore(root, state); return false; // baseline is never a historical event
|
|
222
|
+
}
|
|
223
|
+
if (!validConversation(c)) return false; c.consentGeneration = config.consentGeneration; c.environment = config.environment;
|
|
224
|
+
const next = stateFromRecord(record, now), type = transition(c.last, next); c.last = next; c.lastObservedAt = now; if (!type) { await saveStore(root, state); return false; }
|
|
225
|
+
c.sequence = Number.isSafeInteger(c.sequence) && c.sequence >= 0 ? c.sequence + 1 : 1; state.outbox.events.push(event(c, key, type, next, now)); materializeGaps(state, trim(state, now, options), now, options); await saveStore(root, state); return true; });
|
|
226
|
+
}
|
|
227
|
+
export async function observeHeartbeat(root, now = Date.now(), options = {}) {
|
|
228
|
+
const preflight = (await readStore(root)).config;
|
|
229
|
+
if (!preflight.enabled || !validGeneration(preflight.consentGeneration)) return false;
|
|
230
|
+
return withStoreLock(root, async () => {
|
|
231
|
+
const state = await readStore(root), config = state.config;
|
|
232
|
+
if (!config.enabled || !validGeneration(config.consentGeneration)) return false;
|
|
233
|
+
const active = Object.entries(config.conversations).filter(([, conversation]) =>
|
|
234
|
+
validConversation(conversation) && Number.isFinite(conversation.lastObservedAt) &&
|
|
235
|
+
now - conversation.lastObservedAt >= 0 && now - conversation.lastObservedAt <= 90_000);
|
|
236
|
+
if (!active.length) return false;
|
|
237
|
+
const start = Number.isSafeInteger(config.heartbeatCursor) ? config.heartbeatCursor % active.length : 0;
|
|
238
|
+
const selected = Array.from({ length: Math.min(25, active.length) }, (_, index) => active[(start + index) % active.length]);
|
|
239
|
+
config.heartbeatCursor = (start + selected.length) % active.length;
|
|
240
|
+
for (const [key, conversation] of selected) {
|
|
241
|
+
// A newer heartbeat supersedes an unsent one for this conversation.
|
|
242
|
+
state.outbox.events = state.outbox.events.filter(item => !(item.key === key && item.request?.event_type === 'heartbeat'));
|
|
243
|
+
conversation.consentGeneration = config.consentGeneration;
|
|
244
|
+
conversation.sequence = Number.isSafeInteger(conversation.sequence) && conversation.sequence >= 0 ? conversation.sequence + 1 : 1;
|
|
245
|
+
state.outbox.events.push(event(conversation, key, 'heartbeat', conversation.last || { autoContinue: 'unknown', executionState: 'unknown', result: 'unknown' }, now));
|
|
246
|
+
}
|
|
247
|
+
materializeGaps(state, trim(state, now, options), now, options);
|
|
248
|
+
await saveStore(root, state);
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function retryAt(now, attempts) { return now + Math.min(60_000, 1_000 * 2 ** Math.min(attempts, 5)) + Math.floor(Math.random() * 500); }
|
|
253
|
+
export async function flushObservability({ root, apiOrigin, apiBaseUrl, token, fetchImpl = globalThis.fetch, now = Date.now() }) {
|
|
254
|
+
const preflight = (await readStore(root)).config;
|
|
255
|
+
if (!preflight.enabled || !validGeneration(preflight.consentGeneration)) return { sent: 0, queued: 0 };
|
|
256
|
+
const snapshot = await withStoreLock(root, async () => { const state = await readStore(root); if (!state.config.enabled || !validGeneration(state.config.consentGeneration) || state.config.retry.at > now) return null; if (state.config.actor?.origin !== apiOrigin) return null; return { generation: state.config.consentGeneration, actor: state.config.actor, events: [...state.outbox.events].sort((left, right) => Number(left.request?.event_type === 'heartbeat') - Number(right.request?.event_type === 'heartbeat')).slice(0, MAX_FLUSH) }; });
|
|
257
|
+
if (!snapshot?.events.length) return { sent: 0, queued: snapshot ? 0 : (await observabilityStatus(root)).queued, ...(snapshot ? {} : { retryPending: true }) };
|
|
258
|
+
const delivered = new Set(), rejected = new Set(); let failed = false, resync = false;
|
|
259
|
+
for (const item of snapshot.events) { try { const response = await fetchImpl(webApiUrl('devices/heartbeat/?observability=event', { webOrigin: apiOrigin, apiBaseUrl }), { method: 'POST', headers: webApiHeaders({ token }, { 'Content-Type': 'application/json' }), body: JSON.stringify(item.request), signal: AbortSignal.timeout(5_000) }); if (response.ok) { delivered.add(item.request.event_id); continue; } if ([400, 409, 422].includes(response.status)) { rejected.add(item.request.event_id); continue; } if ([401, 403].includes(response.status)) resync = true; failed = true; break; } catch { failed = true; break; } }
|
|
260
|
+
return withStoreLock(root, async () => { const state = await readStore(root); if (!state.config.enabled || state.config.consentGeneration !== snapshot.generation || !sameActor(state.config.actor, snapshot.actor)) { state.outbox.events = []; await saveStore(root, state); return { sent: 0, queued: 0 }; }
|
|
261
|
+
state.outbox.events = state.outbox.events.filter(item => !delivered.has(item.request.event_id) && !rejected.has(item.request.event_id)); for (const item of snapshot.events) if (rejected.has(item.request.event_id)) enqueueGap(state, item.key, 'sequence_gap', now); if (resync) state.config.enabled = false; state.config.retry = failed ? { attempts: state.config.retry.attempts + 1, at: retryAt(now, state.config.retry.attempts) } : { attempts: 0, at: 0 }; await saveStore(root, state); return { sent: delivered.size, queued: state.outbox.events.length, ...(failed ? { retryPending: true } : {}) }; });
|
|
262
|
+
}
|
|
@@ -20,6 +20,8 @@ import { homedir } from 'node:os';
|
|
|
20
20
|
import { atomicSave, readState } from './update-state-store.mjs';
|
|
21
21
|
import { captureHookOwner, fenceHook, rootFromDelivery, readDispatchGate, readTaskAdmission } from './update-dispatch-gate.mjs';
|
|
22
22
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
23
|
+
const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')
|
|
24
|
+
.then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);
|
|
23
25
|
import { execFile } from 'node:child_process';
|
|
24
26
|
import { promisify } from 'node:util';
|
|
25
27
|
import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
|
|
@@ -116,6 +118,13 @@ export async function linkedProvider(root, provider) {
|
|
|
116
118
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
117
119
|
const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
118
120
|
const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
|
|
121
|
+
// Candidate discovery reserves an association ID even when no Action exists.
|
|
122
|
+
// Only a confirmed no-match snapshot can distinguish that reservation from
|
|
123
|
+
// existing ownership; missing, bound and unlinked snapshots remain protected.
|
|
124
|
+
export function associationBlocksFollowup(record) {
|
|
125
|
+
return Boolean(record?.associationTaskId) &&
|
|
126
|
+
!(record.actionAssociation?.status === 'no_match' && record.actionAssociation.binding === null);
|
|
127
|
+
}
|
|
119
128
|
export function canUpgradeDelivery(run, { automatic = false } = {}) {
|
|
120
129
|
return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
|
|
121
130
|
Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
|
|
@@ -262,7 +271,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
262
271
|
// Recovery requires positive process-death evidence and a serialized second
|
|
263
272
|
// observation. Unknown/legacy owners are never reclaimed by age.
|
|
264
273
|
const lockToken = await waitForStateLock(lock, inspectProcess);
|
|
265
|
-
let temporary;
|
|
274
|
+
let temporary, observation, result;
|
|
266
275
|
try {
|
|
267
276
|
const stored = await read(file, MAX_STATE_BYTES);
|
|
268
277
|
const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
|
|
@@ -294,9 +303,29 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
294
303
|
await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
|
|
295
304
|
}
|
|
296
305
|
}
|
|
297
|
-
|
|
298
|
-
|
|
306
|
+
// This is intentionally post-persistence and best-effort: visibility
|
|
307
|
+
// recording cannot make an existing delivery state update fail or cause
|
|
308
|
+
// a task to start, stop, or retry.
|
|
309
|
+
// Runtime identity is revalidated by the caller that has a fresh direct
|
|
310
|
+
// observation. Persistence alone is never treated as execution evidence.
|
|
311
|
+
observation = { ...next, runtime: null };
|
|
312
|
+
}
|
|
313
|
+
result = next;
|
|
299
314
|
} finally { if (temporary) await rm(temporary, { force: true }); await releaseStateLock(lock, lockToken); }
|
|
315
|
+
// Do not wait on process inspection or the collector while holding the
|
|
316
|
+
// delivery state lock. A matching fresh identity is required for start.
|
|
317
|
+
if (observation) void (async () => {
|
|
318
|
+
const runtime = observation.runtime || result?.runtime;
|
|
319
|
+
const verified = result?.autoContinue === true && result.run?.managed &&
|
|
320
|
+
runtime?.isRunning === true && runtime.process?.identity &&
|
|
321
|
+
Number.isSafeInteger(runtime.process?.pid) &&
|
|
322
|
+
await inspectProcess(runtime.process.pid).catch(() => null) === runtime.process.identity;
|
|
323
|
+
await observeDeliveryRecord(dirname(root), {
|
|
324
|
+
...observation,
|
|
325
|
+
runtime: verified ? runtime : null,
|
|
326
|
+
});
|
|
327
|
+
})().catch(() => {});
|
|
328
|
+
return result;
|
|
300
329
|
}
|
|
301
330
|
export async function readRecord(root = rootDirectory(), provider, session) {
|
|
302
331
|
const path = recordPath(root, provider, session);
|
|
@@ -863,7 +892,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
863
892
|
if (following && text(objective) === prior.objective) throw new Error('Follow-up requires a new objective; use the original task controls for unfinished work.');
|
|
864
893
|
if (hasPendingUserStop(root, previous) || previous.providerArchived || previous.automaticStartBlocked || previous.autoContinue === false && previous.autoContinueEpoch ||
|
|
865
894
|
prior.managed?.resumeLease || prior.managed?.liveWorkspaceLease || prior.managed?.outcomeAudit?.lease || prior.managed?.executionUncertain || prior.managed?.pendingApproval || prior.managed?.nativeWait ||
|
|
866
|
-
['pending', 'transferred', 'pending_received'].includes(prior.managed?.handoff?.status) || previous
|
|
895
|
+
['pending', 'transferred', 'pending_received'].includes(prior.managed?.handoff?.status) || associationBlocksFollowup(previous)) throw new Error('Resolve the existing stop, opt-out, ownership, approval or uncertain result before enrolling follow-up work.');
|
|
867
896
|
const gateRoot = rootFromDelivery(root), gate = readDispatchGate(gateRoot);
|
|
868
897
|
const fences = Object.values(gate.taskFences?.records || {}).filter(item => item.taskId === (prior.managed?.taskId || prior.id) && item.identity.provider === provider && item.identity.session === session);
|
|
869
898
|
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.');
|
|
@@ -20,6 +20,7 @@ import { routeLegacyHooks } from './legacy-hook-routing.js';
|
|
|
20
20
|
import { activateLocalDesktop, watchLocalRuntime, installRuntimeWatchdog } from './runtime-watchdog.js';
|
|
21
21
|
import { desktopRuntimeStatus, installLocalDesktop } from './desktop-runtime.js';
|
|
22
22
|
import { consistencyMonitorStatus, consistencyFindings } from './consistency-monitor.js';
|
|
23
|
+
import { observabilityStatus } from '../cli-observability.js';
|
|
23
24
|
|
|
24
25
|
async function readCheckpointInput(input) {
|
|
25
26
|
const chunks = [];
|
|
@@ -45,6 +46,12 @@ function parseDeliveryOptions(args, allowed) {
|
|
|
45
46
|
|
|
46
47
|
export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), webOrigin, input = process.stdin, output = console.log } = {}) {
|
|
47
48
|
const [action, ...args] = argv;
|
|
49
|
+
if (action === 'observability') {
|
|
50
|
+
const [operation, ...flags] = args;
|
|
51
|
+
if (operation !== 'status' || flags.length) throw new Error('Use simy delivery observability status. Change the single device-consent checkbox in SIMY settings.');
|
|
52
|
+
const sessionRoot = dirname(root);
|
|
53
|
+
output(JSON.stringify(await observabilityStatus(sessionRoot), null, 2)); return 0;
|
|
54
|
+
}
|
|
48
55
|
if (action === 'history-recovery') {
|
|
49
56
|
const [operation,...flags] = args;
|
|
50
57
|
const options = parseDeliveryOptions(flags,new Set(['audit','digest']));
|
|
@@ -211,6 +218,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
|
|
|
211
218
|
}
|
|
212
219
|
if (action === 'activity') return runActivityCommand(args, { root, output });
|
|
213
220
|
if (!action || action === '--help') {
|
|
221
|
+
output('simy delivery observability status — show the state-only telemetry status for this device. The single default-OFF consent checkbox is in SIMY settings; disabling it stops uploads and discards pending telemetry. It never enables Auto-continue or starts a task.');
|
|
214
222
|
output('simy delivery followup --provider codex --session ID --previous-run-id ID --objective TEXT — explicitly supervise the current goal after an exact finished run. A new goal archives the prior run; the same ordinary reported_done goal retains its identity and budgets for read-only reconciliation. Uses the existing supervisor; never reopens stopped, failed or budget-exhausted work.');
|
|
215
223
|
output('simy delivery auto on|off|status --provider codex|claude — inspect or override CLI automatic start for this project directory (on by default). The next nonempty prompt in a conversation without a loop starts checkpoint tracking. Existing loops are preserved; provider hooks must already be enabled.');
|
|
216
224
|
output('simy delivery redelegate-native --provider codex --session ID --task-id ID --consent-ref REF — explicitly delegate current native local edit/test permissions to an exact legacy task. Preserves enrollment, history, budgets and stops; does not resume or grant external actions.');
|
package/src/desktop-host.js
CHANGED
|
@@ -763,7 +763,6 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
763
763
|
scope: requestedScope,
|
|
764
764
|
deviceSession: localCliApiSession(),
|
|
765
765
|
});
|
|
766
|
-
stopNotifier.observe(records);
|
|
767
766
|
const projection = await actionAssociations.beginProjection?.({ records });
|
|
768
767
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
769
768
|
await actionAssociations.decorate(status.deliveryHooks, projection);
|
|
@@ -822,7 +821,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
822
821
|
status = { ...structuredClone(latestConnectionStatus), deliveryHooks: status.deliveryHooks, sessionSupervision: status.sessionSupervision };
|
|
823
822
|
}
|
|
824
823
|
status.teamThreads = teamThreads?.snapshot();
|
|
825
|
-
if (!closed && !relay)
|
|
824
|
+
if (!closed && !relay) {
|
|
825
|
+
// Native stop admission waits for this authorized projection, so an old
|
|
826
|
+
// running frame cannot suppress a newly stopped conversation.
|
|
827
|
+
stopNotifier.observe(records);
|
|
828
|
+
emitStatus(status);
|
|
829
|
+
}
|
|
826
830
|
}).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
827
831
|
return publishing;
|
|
828
832
|
};
|
|
@@ -7,7 +7,7 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
|
|
|
7
7
|
import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
8
8
|
import { realpath } from 'node:fs/promises';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
-
import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
10
|
+
import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, associationBlocksFollowup, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
11
11
|
import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
|
|
12
12
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
13
13
|
import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
@@ -336,7 +336,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
336
336
|
// implicit reset through the auto-continue preference.
|
|
337
337
|
followup: !archived && supported && item.provider === 'codex' &&
|
|
338
338
|
run?.state === 'reported_done' && !run.managed && run.id && !record.automaticStartBlocked &&
|
|
339
|
-
!record
|
|
339
|
+
!associationBlocksFollowup(record) && !(record.autoContinue === false && record.autoContinueEpoch)
|
|
340
340
|
? { previousRunId: run.id } : null,
|
|
341
341
|
...autoContinueState(record, isRunning, availability),
|
|
342
342
|
lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { deliveryStop } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
3
|
|
|
4
|
+
const pendingManualChecks = run => (run.managed.acceptanceCriteria || []).filter(item =>
|
|
5
|
+
item.check?.type === 'manual' && !Object.hasOwn(run.managed.verified || {}, item.id));
|
|
6
|
+
|
|
4
7
|
// Auto-continue works while nobody is watching, so a person must learn when it
|
|
5
8
|
// stops. Map a stopped run to a notification kind; the native window owns copy.
|
|
6
9
|
export function stopNotificationKind(run, now = Date.now()) {
|
|
7
10
|
if (!run?.managed?.autoContinueEpoch && !run?.managed?.repairRequest) return null;
|
|
11
|
+
if (run.stopReason?.actor === 'user') return null;
|
|
8
12
|
// The clock keeps aging while paused; the persisted human decision takes
|
|
9
13
|
// precedence over a threshold that was reached only after that decision.
|
|
10
|
-
if (run.state === 'paused') return
|
|
14
|
+
if (run.state === 'paused') return /^(Paused by user\.|Automatic continuation paused by user\.|User interrupted the provider\.|Stopped by user\.)$/.test(run.reason || '') ? null : deliveryStop(run, now) ? 'paused' : null;
|
|
11
15
|
if (run.state === 'auto_resuming' && !run.managed.resumeLease?.owner) return 'needs_decision';
|
|
12
|
-
if (run.state === 'blocked'
|
|
16
|
+
if (run.state === 'blocked') return run.blocker === 'external_dependency' || run.managed.dependencyWait ? null : 'needs_decision';
|
|
17
|
+
if (run.state === 'verification_pending') return pendingManualChecks(run).length ? 'needs_decision' : null;
|
|
13
18
|
if (run.state === 'recovery_required' && run.managed.autoRecovery !== true) return 'needs_decision';
|
|
14
19
|
if (run.state === 'failed' || run.state === 'budget_exhausted') return 'failed';
|
|
15
20
|
if (run.state === 'completed') return 'finished';
|
|
@@ -19,24 +24,35 @@ export function stopNotificationKind(run, now = Date.now()) {
|
|
|
19
24
|
// The first observation is a baseline: restarting SIMY must not replay alerts
|
|
20
25
|
// for runs that stopped earlier. Each distinct stop is announced once.
|
|
21
26
|
export function createStopNotifier({ emit, now = Date.now } = {}) {
|
|
22
|
-
// Retain one stop fingerprint per
|
|
23
|
-
//
|
|
27
|
+
// Retain one stop fingerprint per task for this process. A temporarily empty
|
|
28
|
+
// discovery result must not turn the same historical stop into a new alert.
|
|
24
29
|
const seen = new Map();
|
|
25
30
|
let baseline = true;
|
|
26
31
|
return {
|
|
27
32
|
observe(records) {
|
|
28
33
|
for (const record of records) {
|
|
29
|
-
|
|
34
|
+
if (record.providerArchived || record.localHidden) continue;
|
|
35
|
+
const run = record?.run;
|
|
36
|
+
const kind = stopNotificationKind(run, now());
|
|
30
37
|
const key = JSON.stringify([record.provider, record.session, run?.managed?.taskId]);
|
|
31
|
-
if (!kind)
|
|
32
|
-
|
|
38
|
+
if (!kind) {
|
|
39
|
+
if (run?.state === 'active' || run?.state === 'auto_resuming') seen.delete(key);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Revisions and generated summaries change during maintenance without
|
|
43
|
+
// creating a new human task. Only a different stop/explicit request does.
|
|
44
|
+
const request = run.managed.pendingApproval || run.managed.understandingQuestion;
|
|
45
|
+
const id = createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId,
|
|
46
|
+
run.id, run.managed.autoContinueEpoch, kind, run.blocker || null, request?.id || null,
|
|
47
|
+
run.managed.userQuestion || null])).digest('hex').slice(0, 32);
|
|
33
48
|
if (seen.get(key) === id) continue;
|
|
34
49
|
seen.set(key, id);
|
|
50
|
+
const detail = request?.text || request?.summary || run.managed.userQuestion ||
|
|
51
|
+
(run.state === 'verification_pending' ? pendingManualChecks(run).map(item => item.description).filter(Boolean).join('; ') : null);
|
|
35
52
|
if (!baseline) emit({ type: 'notify', id, kind, provider: record.provider, session: record.session,
|
|
36
|
-
objective: String(run.objective || '').slice(0, 200)
|
|
53
|
+
objective: String(run.objective || '').slice(0, 200),
|
|
54
|
+
...(detail ? { detail: String(detail).slice(0, 500) } : {}) });
|
|
37
55
|
}
|
|
38
|
-
// Temporary provider absence or a diagnostic pass is not a new stop.
|
|
39
|
-
// Retain fingerprints until a materially different outcome is observed.
|
|
40
56
|
baseline = false;
|
|
41
57
|
},
|
|
42
58
|
};
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -28,7 +28,14 @@ export async function claudePluginFiles() {
|
|
|
28
28
|
for (const name of ["loop-kernel.mjs", "loop-kernel/index.mjs", "loop-kernel/policy.json", "loop-kernel/pin.json", "loop-kernel/outcome-review.mjs", "loop-kernel/work-frontier.mjs", "loop-kernel/task-pool.mjs"]) {
|
|
29
29
|
files.set(`hooks/${name}`, await readFile(path.join(SOURCE, "hooks", name), "utf8"));
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
const collector = await readFile(new URL("../cli-observability.js", import.meta.url), "utf8");
|
|
32
|
+
files.set("hooks/cli-observability.mjs", collector.replace("./web-api.js", "./web-api.mjs"));
|
|
33
|
+
files.set("hooks/web-api.mjs", await readFile(new URL("../web-api.js", import.meta.url), "utf8"));
|
|
34
|
+
const deliveryLoop = await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8");
|
|
35
|
+
files.set("hooks/delivery-loop.mjs", deliveryLoop.replace(
|
|
36
|
+
"const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')\n .then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);",
|
|
37
|
+
"import { observeDeliveryRecord } from './cli-observability.mjs';",
|
|
38
|
+
));
|
|
32
39
|
files.set("hooks/recovery-incident.mjs", await readFile(path.join(SOURCE, "hooks/recovery-incident.mjs"), "utf8"));
|
|
33
40
|
files.set("hooks/minimal-sufficient.mjs", await readFile(path.join(SOURCE, "hooks/minimal-sufficient.mjs"), "utf8"));
|
|
34
41
|
files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
|