@awak-app/simy-cli 0.5.42 → 0.5.44

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.
@@ -0,0 +1,81 @@
1
+ import { continueSession, readSessionAutoContinue } from './index.js';
2
+ import { readContinuationContext } from './continuation-analysis.js';
3
+
4
+ // Shared by explicit Continue and automatic enrollment. Analysis alone never
5
+ // dispatches work: the exact current provider context is checked again here.
6
+ export async function continueAnalyzedSession({ candidate, manager, root, settings = {}, signal,
7
+ readContext = readContinuationContext, enroll = continueSession, beforeEnroll = () => {} }) {
8
+ const recommendation = manager.requireRecommendation(candidate);
9
+ if (candidate.isRunning || candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
10
+ const context = await readContext(candidate, { signal });
11
+ if (context.awaitingTurn || context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
12
+ await manager.invalidate(candidate);
13
+ throw new Error('This conversation changed; wait for its updated analysis.');
14
+ }
15
+ manager.requireRecommendation(candidate);
16
+ await beforeEnroll();
17
+ if (signal?.aborted) throw new Error('Automatic continuation was cancelled before enrollment.');
18
+ return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings });
19
+ }
20
+
21
+ // One pending enrollment per identity. A failed attempt is retried only after
22
+ // context/opt-in changes; an existing (including terminal) run is never reset.
23
+ export function createAutomaticContinuation({ root, getManager, selectCandidate,
24
+ readSetting = readSessionAutoContinue, readContext = readContinuationContext, enroll = continueSession,
25
+ onChange = () => {} }) {
26
+ const pending = new Map(), attempted = new Map(), errors = new Map();
27
+ let closed = false;
28
+ const tick = candidates => {
29
+ if (closed) return;
30
+ for (const candidate of candidates) {
31
+ if (pending.size >= 3) break;
32
+ if (candidate.autoContinue !== true || !candidate.autoContinueEpoch || candidate.isRunning ||
33
+ candidate.capabilities?.canAutoContinue !== true || candidate.capabilities?.canContinue !== true) continue;
34
+ const key = `${candidate.provider}:${candidate.session}`;
35
+ if (pending.has(key)) continue;
36
+ let recommendation;
37
+ const manager = getManager();
38
+ try { recommendation = manager.requireRecommendation(candidate); } catch { continue; }
39
+ const attempt = `${candidate.autoContinueEpoch}:${recommendation.contextDigest}`;
40
+ if (attempted.get(key) === attempt) continue;
41
+ attempted.set(key, attempt);
42
+ errors.delete(key);
43
+ const controller = new AbortController();
44
+ const guard = async () => {
45
+ const setting = await readSetting({ root, provider: candidate.provider, session: candidate.session });
46
+ if (closed || controller.signal.aborted || !setting.enabled || setting.epoch !== candidate.autoContinueEpoch || setting.hasRun) throw new Error('Automatic continuation was disabled or already enrolled.');
47
+ return setting;
48
+ };
49
+ const job = Promise.resolve().then(async () => {
50
+ const setting = await guard();
51
+ const fresh = await selectCandidate(candidate);
52
+ if (!fresh.autoContinue || fresh.autoContinueEpoch !== candidate.autoContinueEpoch) throw new Error('Automatic continuation setting changed.');
53
+ await continueAnalyzedSession({ root, candidate: fresh, manager, signal: controller.signal,
54
+ readContext, enroll, beforeEnroll: guard,
55
+ settings: { ...setting.settings, autoContinueEpoch: candidate.autoContinueEpoch } });
56
+ }).catch(error => {
57
+ if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
58
+ errors.set(key, { epoch: candidate.autoContinueEpoch, reason: 'Could not start auto-continue. Check the original conversation and retry.' });
59
+ }
60
+ }).finally(() => { pending.delete(key); if (!closed) onChange(); });
61
+ pending.set(key, { job, controller });
62
+ }
63
+ };
64
+ return { tick,
65
+ retry(provider, session) {
66
+ const key = `${provider}:${session}`;
67
+ if (!closed && !pending.has(key)) { attempted.delete(key); errors.delete(key); }
68
+ },
69
+ project(candidate) {
70
+ const error = errors.get(`${candidate.provider}:${candidate.session}`);
71
+ if (candidate.autoContinue === true && candidate.autoContinueEpoch === error?.epoch && candidate.capabilities?.canContinue === true &&
72
+ !['no_action', 'needs_user', 'waiting', 'uncertain'].includes(candidate.analysis?.state)) return {
73
+ ...candidate, autoContinueStatus: 'error', autoContinueReason: error.reason,
74
+ };
75
+ return candidate;
76
+ },
77
+ revoke(provider, session) { pending.get(`${provider}:${session}`)?.controller.abort(); },
78
+ async whenIdle() { await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
79
+ async close() { closed = true; for (const entry of pending.values()) entry.controller.abort(); await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
80
+ };
81
+ }
@@ -1,9 +1,26 @@
1
1
  import { realpath } from 'node:fs/promises';
2
- import { controlDelivery, listRecords, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
4
  import { listCodexSessions, openCodexAppServer, readCodexSession } from './codex-app-server.js';
4
5
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
5
6
  import { originalSessionTarget } from './original-session-target.js';
6
7
 
8
+ function autoContinueState(record, isRunning, supported) {
9
+ if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
10
+ const state = record.run?.state;
11
+ if (state === 'budget_exhausted') return { autoContinueStatus: 'limit', autoContinueReason: 'This conversation reached its continuation limit. Its budgets cannot be reset.' };
12
+ if (['completed', 'reported_done'].includes(state)) return { autoContinueStatus: 'finished', autoContinueReason: 'Bounded continuation finished for this conversation.' };
13
+ const managed = record.run?.managed;
14
+ const recoverableCause = record.run?.recoveryCause === 'process_exit' || managed?.resumeChannel === 'codex_app_server' && ['session_supervision', 'approved_action'].includes(record.run?.recoveryCause);
15
+ if (state === 'recovery_required' && (!managed?.autoRecovery || !recoverableCause || managed.pendingApproval)) return {
16
+ autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.',
17
+ };
18
+ if (['paused', 'blocked', 'stopped', 'failed', 'verification_pending'].includes(state)) return { autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.' };
19
+ if (!supported) return { autoContinueStatus: 'unavailable', autoContinueReason: 'The original provider continuation channel is unavailable.' };
20
+ return { autoContinueStatus: record.run ? 'supervising' : isRunning ? 'waiting' : 'watching',
21
+ autoContinueReason: record.run ? 'Continuing within the existing task budget.' : isRunning ? 'Waiting for the original turn to finish.' : 'Checking settled requests for worthwhile remaining work.' };
22
+ }
23
+
7
24
  function initialDiagnosisPlan(objective, contextBootstrap = false) {
8
25
  const action = { kind: 'read', scope: 'task', summary: contextBootstrap ? 'Understand the original user request and current progress without changing files' : 'Inspect the selected conversation and acceptance evidence without changing files' };
9
26
  return {
@@ -36,7 +53,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
36
53
  const records = await listRecords(root);
37
54
  let discovered = [], codexError = null, claude = [], claudeError = null;
38
55
  try { discovered = await withClient(openClient, client => listCodexSessions({ client, inspectState: true,
39
- prioritySessions: records.filter(record => record.provider === 'codex' && (record.monitor || record.tracked || record.run)).map(record => record.session) })); }
56
+ prioritySessions: records.filter(record => record.provider === 'codex' && (record.autoContinue || record.monitor || record.tracked || record.run)).map(record => record.session) })); }
40
57
  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.'; }
41
58
  try { claude = await listClaude(); } catch { claudeError = 'Claude conversation discovery unavailable.'; }
42
59
  const byKey = new Map();
@@ -70,13 +87,18 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
70
87
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
71
88
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
72
89
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
90
+ autoContinue: record?.autoContinue === true,
91
+ autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
92
+ ...autoContinueState(record, isRunning, supported || liveHook),
73
93
  lastActivityAt: item.lastActivityAt, observation: item.observation, availability: item.availability,
74
- binding: { mode, taskId: run?.managed?.taskId || null },
94
+ taskId: run?.managed?.taskId || record?.associationTaskId || null,
95
+ binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
75
96
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
76
97
  continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
77
98
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
78
99
  canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
79
- canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run, canAutoContinue: supported || liveHook },
100
+ canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
101
+ canAutoContinue: (supported || liveHook) && (!run || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
80
102
  reason: item.provider === 'claude' ? liveHook ? 'Live SIMY hook can continue this Claude session at its next Stop boundary; cold CLI takeover is unavailable.' :
81
103
  process.platform === 'win32' ? 'Claude live hook enrollment is unavailable on Windows because provider process identity cannot be verified; monitoring remains available.' :
82
104
  'Claude enrollment needs a live matching SIMY SessionStart hook and provider process; cold CLI takeover is unavailable.' :
@@ -85,11 +107,37 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
85
107
  }))).sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
86
108
  // Keep existing bindings visible during a truncated discovery or provider
87
109
  // outage, even when newer unbound conversations fill the candidate window.
88
- const bound = projected.filter(item => item.binding.mode !== 'unmanaged');
89
- const sessions = [...bound, ...projected.filter(item => item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
110
+ const bound = projected.filter(item => item.autoContinue || item.binding.mode !== 'unmanaged');
111
+ const sessions = [...bound, ...projected.filter(item => !item.autoContinue && item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
90
112
  return { sessions, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
91
113
  }
92
114
 
115
+ // Explicit execution authority: legacy monitor/tracked records stay read-only.
116
+ export async function setSessionAutoContinue({ root, provider, session, enabled, ...options }) {
117
+ if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
118
+ if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
119
+ return updateRecord(root, provider, session, async previous => {
120
+ if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
121
+ if (enabled && previous.run && !previous.run.managed) throw new Error('Automatic continuation is unavailable for this conversation.');
122
+ if (enabled && TERMINAL_RUNS.has(previous.run?.state)) throw new Error('Bounded continuation finished or reached its limit; budgets cannot be reset.');
123
+ if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
124
+ if (enabled && provider === 'claude' && !await liveClaudeHook(previous,
125
+ { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
126
+ const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
127
+ const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
128
+ ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3 } } : { tracked: false, monitor: false }) };
129
+ // Adopt existing supervision without restarting it or renewing its budgets.
130
+ if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
131
+ return next;
132
+ });
133
+ }
134
+
135
+ export async function readSessionAutoContinue({ root, provider, session }) {
136
+ const record = (await listRecords(root)).find(item => item.provider === provider && item.session === session);
137
+ return { enabled: record?.autoContinue === true, epoch: record?.autoContinueEpoch || null,
138
+ settings: record?.autoContinueSettings, hasRun: Boolean(record?.run) };
139
+ }
140
+
93
141
  // Tracking is local observation metadata. It neither starts nor stops the
94
142
  // original provider task and never grants automatic continuation authority.
95
143
  export async function trackSession({ root, provider, session, tracked = true, openClient = openCodexAppServer, readClaude = readClaudeSession, now = Date.now() }) {
@@ -133,7 +181,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
133
181
  }
134
182
 
135
183
  export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
136
- maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, openClient = openCodexAppServer,
184
+ maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, autoContinueEpoch, openClient = openCodexAppServer,
137
185
  readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
138
186
  if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
139
187
  if (maxTokens !== undefined && provider !== 'codex') throw new Error('Token-budgeted supervision requires host-observed Codex usage; Claude live hooks cannot enforce this budget.');
@@ -159,7 +207,7 @@ export async function enrollSession({ root, provider, session, objective, accept
159
207
  const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
160
208
  const liveRecord = provider === 'claude' ? (await listRecords(root)).find(item => item.provider === provider && item.session === session) : null;
161
209
  const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
162
- maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess,
210
+ maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess, autoContinueEpoch,
163
211
  liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
164
212
  managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, maxTokens, approvalScopes: ['task'], approvalPolicy: 'wait',
165
213
  initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),