@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.
@@ -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
- prioritySessions: records.filter(record => record.provider === 'codex' && (record.autoContinue || record.monitor || record.tracked || record.run)).map(record => record.session) }), signal); }
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 }); } catch { claudeError = 'Claude conversation discovery unavailable.'; }
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.monitor || record.run)) byKey.set(key, {
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
- // Historical sessions must not fan out hundreds of process-inspection
81
- // subprocesses. Preserve input ordering while limiting live metadata probes.
82
- const items = [...byKey.values()], projected = new Array(items.length);
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
- // Keep existing bindings visible during a truncated discovery or provider
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, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, ...options }) {
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
- const settings = { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3,
159
- ...previous.autoContinueSettings,
160
- ...(continuationPolicy === undefined ? {} : { continuationPolicy }),
161
- ...(hardMaxMinutes === undefined ? {} : { hardMaxMinutes }),
162
- ...(hardMaxContinuations === undefined ? {} : { hardMaxContinuations }),
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
- for (const [key, maximum] of [['hardMaxMinutes', 1440], ['hardMaxContinuations', 50], ['providerTokenCeiling', 15_000_000]]) {
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
- maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, autoContinueEpoch, openClient = openCodexAppServer,
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
- maxMinutes, maxContinuations, maxFailures, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, now, admission, inspectProcess, autoContinueEpoch,
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, maxTokens, approvalScopes: ['task'], approvalPolicy: 'wait',
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, deadline: run.run.deadline };
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
+ }