@awak-app/simy-cli 0.5.54 → 0.5.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.54",
3
+ "version": "0.5.55",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.54";
16
+ export const CLI_VERSION = "0.5.55";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -121,6 +121,13 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
121
121
  return next;
122
122
  } finally { if (temporary) await rm(temporary, { force: true }); await releaseStateLock(lock, lockToken); }
123
123
  }
124
+ export async function readRecord(root = rootDirectory(), provider, session) {
125
+ const path = recordPath(root, provider, session);
126
+ try {
127
+ const record = await read(path);
128
+ return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? record : null;
129
+ } catch { return null; } // Match listRecords' treatment of unreadable records.
130
+ }
124
131
  export async function listRecords(root = rootDirectory()) {
125
132
  let names; try { names = await readdir(root); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
126
133
  const results = await Promise.all(names.filter(n => /^[a-f0-9]{64}\.json$/.test(n)).slice(0, 500).map(async n => {
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname } from 'node:path';
4
- import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
5
  import { resolveBackendExecutable } from '../backend-executable.js';
6
6
  import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
7
7
  import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
@@ -20,7 +20,7 @@ function providerFailureReason(code) {
20
20
  }
21
21
  async function assertActiveLease(root, provider, session, leaseId, signal, currentTime = Date.now) {
22
22
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
23
- const current = (await listRecords(root)).find(item => item.provider === provider && item.session === session);
23
+ const current = await readRecord(root, provider, session);
24
24
  if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
25
25
  throw new Error('Managed recovery lease changed before provider dispatch.');
26
26
  }
@@ -95,8 +95,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
95
95
  };
96
96
  signal?.addEventListener('abort', terminate, { once: true });
97
97
  const watch = setInterval(() => {
98
- void listRecords(root).then(records => {
99
- const current = records.find(item => item.provider === record.provider && item.session === record.session);
98
+ void readRecord(root, record.provider, record.session).then(current => {
100
99
  if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
101
100
  }).catch(terminate);
102
101
  }, 500);
@@ -123,9 +122,8 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
123
122
  const tickWallStart = Date.now();
124
123
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
125
124
  if (signal?.aborted) return;
126
- await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}) });
125
+ const records = await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}) });
127
126
  if (signal?.aborted) return;
128
- const records = await listRecords(root);
129
127
  for (let record of records) {
130
128
  if (signal?.aborted) break;
131
129
  const claimNow = currentTime();
@@ -212,8 +210,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
212
210
  else signal?.addEventListener('abort', abort, { once: true });
213
211
  const timeout = setTimeout(abort, Math.max(1, claimed.run.deadline - currentTime()));
214
212
  const watch = setInterval(() => {
215
- void listRecords(root).then(records => {
216
- const current = records.find(item => item.provider === record.provider && item.session === record.session);
213
+ void readRecord(root, record.provider, record.session).then(current => {
217
214
  if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
218
215
  managedBudgetReason(current.run, currentTime()) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
219
216
  }).catch(() => controller.abort());
@@ -181,13 +181,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
181
181
  return;
182
182
  }
183
183
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
184
+ const projection = await actionAssociations.beginProjection?.();
184
185
  await actionAssociations.decorate(status.deliveryHooks);
185
186
  const discovery = refreshSessions();
186
187
  automaticContinuation?.tick(discovery.sessions);
187
188
  const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
188
- await actionAssociations.decorateSessions?.(sessions);
189
+ await actionAssociations.decorateSessions?.(sessions, projection);
189
190
  for (const session of sessions) {
190
- const context = await actionAssociations.deadlineContext?.(session);
191
+ const context = await actionAssociations.deadlineContext?.(session, projection);
191
192
  if (context) session.actionDeadline = await deadlines.reconcile(context);
192
193
  else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
193
194
  const run = status.deliveryHooks.runs?.find(item => item.provider === session.provider && item.session === session.session);
@@ -1,5 +1,5 @@
1
1
  import { createOrdinaryAssociationStore } from './association-store.js';
2
- import { listRecords, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { listRecords, readRecord, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
3
  import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
4
4
  import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
5
5
  settleActionAssociation, cancelActionAssociation } from './action-association.js';
@@ -28,7 +28,7 @@ const deadlineBlocker = (record, candidate) => {
28
28
  // Association is optional metadata. Network failures never control the task's
29
29
  // work state; provider turns and their budgets remain owned by the supervisor.
30
30
  export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
31
- now = Date.now, refreshMs = 60_000, list = listRecords, onChange = () => {} } = {}) {
31
+ now = Date.now, refreshMs = 60_000, list = listRecords, read = readRecord, onChange = () => {} } = {}) {
32
32
  const abort = new AbortController(), views = new Map();
33
33
  const ordinary = createOrdinaryAssociationStore({ root, api, webOrigin, now, onChange });
34
34
  let auth = null, closed = false, running = null, serial = Promise.resolve();
@@ -52,7 +52,10 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
52
52
  }
53
53
  return next;
54
54
  };
55
- const fresh = async target => (await list(root)).find(record => eligible(record) && record.provider === target.provider && record.session === target.session && record.run.managed.taskId === target.taskId);
55
+ const fresh = async target => {
56
+ const record = await read(root, target.provider, target.session);
57
+ return eligible(record || {}) && record.run.managed.taskId === target.taskId ? record : null;
58
+ };
56
59
  const valid = async expected => !closed && expected && await current() === expected;
57
60
  const importEnvelope = async (record, envelope, expected, userCorrection = false) => {
58
61
  if (!await valid(expected)) throw new ActionAssociationApiError('account_changed');
@@ -159,19 +162,28 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
159
162
  }));
160
163
  }
161
164
  return {
162
- async decorateSessions(sessions) {
163
- await ordinary.decorate(sessions);
165
+ // A projection is private to one desktop frame. Mutations and deadline
166
+ // validation still reread the exact record and current authentication.
167
+ async beginProjection() {
168
+ await current();
164
169
  const records = await list(root);
165
- const status = { runs: sessions.filter(item => records.some(record => record.provider === item.provider && record.session === item.session && record.run?.managed)).map(item => {
166
- const record = records.find(record => record.provider === item.provider && record.session === item.session);
170
+ return { records, byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
171
+ },
172
+ async decorateSessions(sessions, projection) {
173
+ await ordinary.decorate(sessions, projection);
174
+ const records = projection?.byKey || new Map((await list(root)).map(record => [`${record.provider}:${record.session}`, record]));
175
+ const status = { runs: sessions.filter(item => records.get(`${item.provider}:${item.session}`)?.run?.managed).map(item => {
176
+ const record = records.get(`${item.provider}:${item.session}`);
167
177
  return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
168
178
  }) };
169
179
  await this.decorate(status);
170
180
  for (const run of status.runs) run.candidate.actionAssociation = run.actionAssociation;
171
181
  return sessions;
172
182
  },
173
- async deadlineContext(candidate) {
174
- const expected = await current(), record = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
183
+ async deadlineContext(candidate, projection) {
184
+ const expected = await current(), record = projection
185
+ ? projection.byKey.get(`${candidate.provider}:${candidate.session}`)
186
+ : await read(root, candidate.provider, candidate.session);
175
187
  const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
176
188
  if (!expected || local?.authRevision !== expected || !candidate.actionAssociation?.canCorrect) return null;
177
189
  const binding = candidate.actionAssociation.binding;
@@ -181,7 +193,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
181
193
  plan: record.run?.managed?.plan || candidate.analysis?.plan || null, blocker: deadlineBlocker(record, candidate), online: true,
182
194
  validate: async () => {
183
195
  if (!await valid(expected)) return false;
184
- const latest = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
196
+ const latest = await read(root, candidate.provider, candidate.session);
185
197
  const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
186
198
  return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
187
199
  } };
@@ -47,10 +47,11 @@ export function createActionDeadlineStore({ root }) {
47
47
  }
48
48
  return next === undefined ? previous : next;
49
49
  });
50
- queues.set(
51
- path,
52
- result.catch(() => {}),
53
- );
50
+ const tail = result.catch(() => {}).finally(() => {
51
+ // A newer transaction owns the queue until it too has settled.
52
+ if (queues.get(path) === tail) queues.delete(path);
53
+ });
54
+ queues.set(path, tail);
54
55
  return result;
55
56
  },
56
57
  };
@@ -153,7 +153,6 @@ export function createActionDeadlineService({
153
153
  async function reconcile(context) {
154
154
  const key = identity(context),
155
155
  action = context.binding?.action;
156
- const dueInstant = actionDueInstant(action?.dueAt, action?.timeZone);
157
156
  const bindingRevision =
158
157
  context.binding?.revision ?? context.bindingRevision ?? null;
159
158
  const fingerprint = hash([
@@ -176,6 +175,10 @@ export function createActionDeadlineService({
176
175
  : previous.bindingRevision > bindingRevision)
177
176
  )
178
177
  return previous;
178
+ // An unchanged valid deadline already stores its exact due instant.
179
+ // Avoid rebuilding Intl formatters and repeating the timezone search.
180
+ if (previous?.fingerprint === fingerprint && previous.status !== 'unavailable') return previous;
181
+ const dueInstant = actionDueInstant(action?.dueAt, action?.timeZone);
179
182
  // A pre-JST cache may mark the same date-only value unavailable. Repair
180
183
  // that entry once without invalidating already valid saved decisions.
181
184
  if (previous?.fingerprint === fingerprint && !(previous.status === "unavailable" && dueInstant !== null)) return previous;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import {
3
3
  listRecords,
4
+ readRecord,
4
5
  updateRecord,
5
6
  } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
7
  import { associationRevision } from './action-association.js';
@@ -54,12 +55,7 @@ export function createOrdinaryAssociationStore({
54
55
  abort = new AbortController();
55
56
  let serial = Promise.resolve(),
56
57
  closed = false;
57
- const find = async (candidate) =>
58
- (await listRecords(root)).find(
59
- (item) =>
60
- item.provider === candidate.provider &&
61
- item.session === candidate.session,
62
- );
58
+ const find = candidate => readRecord(root, candidate.provider, candidate.session);
63
59
  async function ensure(candidate, known) {
64
60
  if (
65
61
  !['codex', 'claude'].includes(candidate.provider) ||
@@ -189,9 +185,9 @@ export function createOrdinaryAssociationStore({
189
185
  }
190
186
  }
191
187
  return {
192
- async decorate(sessions) {
188
+ async decorate(sessions, snapshot) {
193
189
  const records = new Map(
194
- (await listRecords(root)).map((record) => [
190
+ (snapshot?.records || await listRecords(root)).map((record) => [
195
191
  `${record.provider}:${record.session}`,
196
192
  record,
197
193
  ]),
@@ -157,12 +157,24 @@ export function codexThreadMetadata(thread) {
157
157
  };
158
158
  }
159
159
 
160
- export async function listCodexSessions({ client, maxPages = 40, pageSize = 5, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
160
+ export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = 200, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
161
161
  const sessions = [];
162
- let cursor = null;
163
- for (let page = 0; page < maxPages; page++) {
164
- const response = await client.request('thread/list', { cursor, limit: pageSize, sourceKinds: SOURCE_KINDS, archived: false });
165
- for (const thread of response?.data || []) {
162
+ const windowSize = Math.min(maxSessions, maxPages * pageSize);
163
+ let cursor = null, listed = 0, pages = 0, batchSize = pageSize;
164
+ while (pages < Math.max(maxPages, Math.ceil(windowSize / batchSize)) && listed < windowSize) {
165
+ const limit = Math.min(batchSize, windowSize - listed);
166
+ let response;
167
+ try { response = await client.request('thread/list', { cursor, limit, sourceKinds: SOURCE_KINDS, archived: false }); }
168
+ catch (error) {
169
+ // Keep the transport's byte limit. Retry the same cursor in smaller
170
+ // batches rather than dropping discovery for large metadata envelopes.
171
+ if (error?.code !== 'response_too_large' || limit <= 1) throw error;
172
+ batchSize = Math.max(1, Math.floor(limit / 2));
173
+ continue;
174
+ }
175
+ const threads = (response?.data || []).slice(0, limit);
176
+ listed += threads.length; pages++;
177
+ for (const thread of threads) {
166
178
  const metadata = codexThreadMetadata(thread);
167
179
  if (metadata) sessions.push(metadata);
168
180
  }
@@ -1,6 +1,6 @@
1
1
  import { realpath } from 'node:fs/promises';
2
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
+ import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
4
  import { listCodexSessions, openCodexAppServer, readCodexSession } from './codex-app-server.js';
5
5
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
6
6
  import { originalSessionTarget } from './original-session-target.js';
@@ -133,7 +133,7 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
133
133
  }
134
134
 
135
135
  export async function readSessionAutoContinue({ root, provider, session }) {
136
- const record = (await listRecords(root)).find(item => item.provider === provider && item.session === session);
136
+ const record = await readRecord(root, provider, session);
137
137
  return { enabled: record?.autoContinue === true, epoch: record?.autoContinueEpoch || null,
138
138
  settings: record?.autoContinueSettings, hasRun: Boolean(record?.run) };
139
139
  }
@@ -190,14 +190,14 @@ export async function enrollSession({ root, provider, session, objective, accept
190
190
  let metadata;
191
191
  if (provider === 'claude') {
192
192
  metadata = await readClaude(session);
193
- const record = (await listRecords(root)).find(item => item.provider === provider && item.session === session);
193
+ const record = await readRecord(root, provider, session);
194
194
  if (!await liveClaudeHook(record, metadata, inspectProcess)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
195
195
  if (record.run) throw new Error('This session already has a Delivery Loop.');
196
196
  await monitorSession({ root, provider, session, monitor: true, readClaude: async () => metadata, now });
197
197
  // A second check narrows the time between metadata admission and the
198
198
  // original provider's next Stop boundary. Process exit still remains a
199
199
  // manual recovery condition; SIMY never starts claude -r.
200
- if (!await liveClaudeHook((await listRecords(root)).find(item => item.provider === provider && item.session === session), metadata, inspectProcess)) throw new Error('Claude provider process exited before enrollment.');
200
+ if (!await liveClaudeHook(await readRecord(root, provider, session), metadata, inspectProcess)) throw new Error('Claude provider process exited before enrollment.');
201
201
  } else {
202
202
  metadata = await monitorSession({ root, provider, session, monitor: true, openClient, now });
203
203
  if (!metadata.model) throw new Error('Codex did not expose this conversation’s model; automatic continuation is unavailable.');
@@ -205,7 +205,7 @@ export async function enrollSession({ root, provider, session, objective, accept
205
205
  const cwd = await realpath(metadata.cwd);
206
206
  const initialPlan = initialDiagnosisPlan(objective.trim(), contextBootstrap);
207
207
  const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
208
- const liveRecord = provider === 'claude' ? (await listRecords(root)).find(item => item.provider === provider && item.session === session) : null;
208
+ const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
209
209
  const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
210
210
  maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess, autoContinueEpoch,
211
211
  liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,