@awak-app/simy-cli 0.6.2 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/package.json +3 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +16 -22
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +76 -101
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +10 -0
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +1 -1
- package/src/delivery-hooks/command.js +12 -16
- package/src/delivery-hooks/desktop.js +6 -14
- package/src/delivery-hooks/supervisor.js +7 -31
- package/src/desktop-host.js +15 -14
- package/src/session-supervision/action-association-host.js +7 -3
- package/src/session-supervision/action-association.js +2 -2
- package/src/session-supervision/association-store.js +12 -3
- package/src/session-supervision/claude-sessions.js +36 -20
- package/src/session-supervision/codex-app-server.js +8 -22
- package/src/session-supervision/discovery-window.js +15 -0
- package/src/session-supervision/handoff.js +2 -1
- package/src/session-supervision/index.js +25 -27
- package/src/session-supervision/stop-notifications.js +43 -0
|
@@ -5,6 +5,7 @@ import { listCodexSessions, openCodexAppServer, readCodexSession, CodexAppServer
|
|
|
5
5
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
6
6
|
import { originalSessionTarget } from './original-session-target.js';
|
|
7
7
|
import { readCodexTitles, sessionTitle } from './session-titles.js';
|
|
8
|
+
import { RECENT_SESSION_LIMIT, recentSessionWindow, retainedSession } from './discovery-window.js';
|
|
8
9
|
|
|
9
10
|
function autoContinueState(record, isRunning, supported) {
|
|
10
11
|
if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
|
|
@@ -58,28 +59,34 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
58
59
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
59
60
|
assertActive();
|
|
60
61
|
const records = await listRecords(root);
|
|
62
|
+
const priorities = records.filter(retainedSession);
|
|
61
63
|
let discovered = [], codexError = null, claude = [], claudeError = null;
|
|
62
64
|
try { discovered = await withClient(openClient, client => listCodexSessions({ client, inspectState: true,
|
|
63
|
-
|
|
65
|
+
maxSessions: RECENT_SESSION_LIMIT,
|
|
66
|
+
prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) }), signal); }
|
|
64
67
|
catch (error) { codexError = error?.code === 'unavailable' ? 'Codex executable unavailable.' : error?.code === 'unsupported' ? 'This Codex version lacks a verified app-server writer lock; update before enrollment.' : 'Codex conversation discovery unavailable.'; }
|
|
65
68
|
assertActive();
|
|
66
|
-
try { claude = await listClaude({ signal
|
|
69
|
+
try { claude = await listClaude({ signal, limit: RECENT_SESSION_LIMIT,
|
|
70
|
+
prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
|
|
71
|
+
catch { claudeError = 'Claude conversation discovery unavailable.'; }
|
|
67
72
|
assertActive();
|
|
68
73
|
const byKey = new Map();
|
|
69
74
|
const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
|
|
70
75
|
for (const item of [...discovered, ...claude]) byKey.set(`${item.provider}:${item.session}`, item);
|
|
71
76
|
for (const record of records) {
|
|
72
77
|
const key = `${record.provider}:${record.session}`;
|
|
73
|
-
if (!byKey.has(key) && (record.observedAt || record.admission || record
|
|
78
|
+
if (!byKey.has(key) && (record.observedAt || record.admission || retainedSession(record))) byKey.set(key, {
|
|
74
79
|
provider: record.provider, session: record.session, cwd: record.cwd, title: '', source: record.admission?.source || 'hook',
|
|
75
80
|
lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
|
|
76
81
|
observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
|
|
77
82
|
availability: 'unknown', model: record.admission?.model || null,
|
|
78
83
|
});
|
|
79
84
|
}
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
const items = [...byKey.values()],
|
|
85
|
+
// Select recent history before live probes, keeping explicit supervision
|
|
86
|
+
// visible even during a truncated discovery or provider outage.
|
|
87
|
+
const items = recentSessionWindow([...byKey.values()], {
|
|
88
|
+
priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
|
|
89
|
+
}), projected = new Array(items.length);
|
|
83
90
|
const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
|
|
84
91
|
assertActive();
|
|
85
92
|
let nextItem = 0;
|
|
@@ -136,15 +143,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
136
143
|
}));
|
|
137
144
|
assertActive();
|
|
138
145
|
projected.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
139
|
-
|
|
140
|
-
// outage, even when newer unbound conversations fill the candidate window.
|
|
141
|
-
const bound = projected.filter(item => item.autoContinue || item.binding.mode !== 'unmanaged');
|
|
142
|
-
const sessions = [...bound, ...projected.filter(item => !item.autoContinue && item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
|
|
143
|
-
return { sessions, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
146
|
+
return { sessions: projected, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
// Explicit execution authority: legacy monitor/tracked records stay read-only.
|
|
147
|
-
export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy,
|
|
150
|
+
export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, ...options }) {
|
|
148
151
|
if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
|
|
149
152
|
if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
|
|
150
153
|
return updateRecord(root, provider, session, async previous => {
|
|
@@ -155,18 +158,13 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
155
158
|
if (enabled && provider === 'claude') await requireClaudeChannel(previous,
|
|
156
159
|
{ session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity);
|
|
157
160
|
const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
...(
|
|
163
|
-
...(providerTokenCeiling === undefined ? {} : { providerTokenCeiling }) };
|
|
161
|
+
// Settings persisted by older versions may carry removed time, turn and
|
|
162
|
+
// token caps; only the fields that still govern a task are forwarded.
|
|
163
|
+
const { autonomyPolicy = 'balanced', maxFailures = 3, continuationPolicy: savedPolicy } = previous.autoContinueSettings || {};
|
|
164
|
+
const settings = { autonomyPolicy, maxFailures,
|
|
165
|
+
...(continuationPolicy ?? savedPolicy) === undefined ? {} : { continuationPolicy: continuationPolicy ?? savedPolicy } };
|
|
164
166
|
if (settings.continuationPolicy !== undefined && !['bounded', 'progress'].includes(settings.continuationPolicy)) throw new Error('Choose a continuation policy.');
|
|
165
|
-
|
|
166
|
-
if (settings[key] !== undefined && (!Number.isSafeInteger(settings[key]) || settings[key] < 1 || settings[key] > maximum)) throw new Error('Invalid continuation ceiling.');
|
|
167
|
-
}
|
|
168
|
-
if (providerTokenCeiling !== undefined && provider !== 'codex') throw new Error('Token ceilings require host-observed Codex usage.');
|
|
169
|
-
if (previous.run && [continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling].some(value => value !== undefined)) throw new Error('Change an existing task through its explicit continuation controls.');
|
|
167
|
+
if (previous.run && continuationPolicy !== undefined) throw new Error('Change an existing task through its explicit continuation controls.');
|
|
170
168
|
const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
|
|
171
169
|
...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
|
|
172
170
|
// Adopt existing supervision without restarting it or renewing its budgets.
|
|
@@ -224,7 +222,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
224
222
|
}
|
|
225
223
|
|
|
226
224
|
export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
|
|
227
|
-
|
|
225
|
+
maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, openClient = openCodexAppServer,
|
|
228
226
|
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
|
|
229
227
|
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
230
228
|
if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
|
|
@@ -249,13 +247,13 @@ export async function enrollSession({ root, provider, session, objective, accept
|
|
|
249
247
|
const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
|
|
250
248
|
const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
|
|
251
249
|
const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
252
|
-
|
|
250
|
+
maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
|
|
253
251
|
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
254
|
-
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes,
|
|
252
|
+
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
255
253
|
initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),
|
|
256
254
|
autoRecovery: provider === 'codex', resumeChannel: provider === 'codex' ? 'codex_app_server' : null,
|
|
257
255
|
resumeModel: metadata.model } });
|
|
258
|
-
return { provider, session, taskId: run.run.managed.taskId, state: run.run.state
|
|
256
|
+
return { provider, session, taskId: run.run.managed.taskId, state: run.run.state };
|
|
259
257
|
}
|
|
260
258
|
|
|
261
259
|
export async function continueSession(options) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { deliveryStop } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
|
|
4
|
+
// Auto-continue works while nobody is watching, so a person must learn when it
|
|
5
|
+
// stops. Map a stopped run to a notification kind; the native window owns copy.
|
|
6
|
+
export function stopNotificationKind(run, now = Date.now()) {
|
|
7
|
+
if (!run?.managed?.autoContinueEpoch) return null;
|
|
8
|
+
// The clock keeps aging while paused; the persisted human decision takes
|
|
9
|
+
// precedence over a threshold that was reached only after that decision.
|
|
10
|
+
if (run.state === 'paused') return run.reason === 'Paused by user.' ? null : deliveryStop(run, now) ? 'paused' : null;
|
|
11
|
+
if (run.state === 'blocked' || run.state === 'verification_pending') return 'needs_decision';
|
|
12
|
+
if (run.state === 'recovery_required' && run.managed.autoRecovery !== true) return 'needs_decision';
|
|
13
|
+
if (run.state === 'failed' || run.state === 'budget_exhausted') return 'failed';
|
|
14
|
+
if (run.state === 'completed' || run.state === 'reported_done') return 'finished';
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The first observation is a baseline: restarting SIMY must not replay alerts
|
|
19
|
+
// for runs that stopped earlier. Each distinct stop is announced once.
|
|
20
|
+
export function createStopNotifier({ emit, now = Date.now } = {}) {
|
|
21
|
+
// Retain one stop fingerprint per currently observed task, rather than an
|
|
22
|
+
// event cache whose eviction could replay the entire historical baseline.
|
|
23
|
+
const seen = new Map();
|
|
24
|
+
let baseline = true;
|
|
25
|
+
return {
|
|
26
|
+
observe(records) {
|
|
27
|
+
const present = new Set();
|
|
28
|
+
for (const record of records) {
|
|
29
|
+
const run = record?.run, kind = stopNotificationKind(run, now());
|
|
30
|
+
const key = JSON.stringify([record.provider, record.session, run?.managed?.taskId]);
|
|
31
|
+
present.add(key);
|
|
32
|
+
if (!kind) { seen.delete(key); continue; }
|
|
33
|
+
const id = createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId, run.state, run.revision, run.reason])).digest('hex').slice(0, 32);
|
|
34
|
+
if (seen.get(key) === id) continue;
|
|
35
|
+
seen.set(key, id);
|
|
36
|
+
if (!baseline) emit({ type: 'notify', id, kind, provider: record.provider, session: record.session,
|
|
37
|
+
objective: String(run.objective || '').slice(0, 200) });
|
|
38
|
+
}
|
|
39
|
+
for (const key of seen.keys()) if (!present.has(key)) seen.delete(key);
|
|
40
|
+
baseline = false;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|