@awak-app/simy-cli 0.5.54 → 0.5.56

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.56",
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.56";
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
  ]),
@@ -4,12 +4,14 @@ import { realpath } from 'node:fs/promises';
4
4
  import { inspectBackendExecutable } from '../backend-executable.js';
5
5
  import { assessedCheckpointContract, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
6
  import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
7
+ import { JsonDataUrlFilter } from './json-data-url-filter.js';
7
8
 
8
9
  const REQUEST_TIMEOUT = 15_000;
9
10
  const MAX_LINE = 256 * 1024;
10
11
  const ANALYSIS_MAX_LINE = 1024 * 1024;
11
12
  const ANALYSIS_MAX_TURNS = 16;
12
13
  const ANALYSIS_PAGE_SIZE = 6;
14
+ const ANALYSIS_MAX_WIRE_LINE = 8 * 1024 * 1024;
13
15
  const SOURCE_KINDS = ['cli', 'vscode', 'exec', 'appServer', 'unknown'];
14
16
 
15
17
  export class CodexAppServerError extends Error {
@@ -21,7 +23,7 @@ export class CodexAppServerError extends Error {
21
23
  }
22
24
 
23
25
  export class CodexAppServerClient {
24
- constructor(child, { maxLineBytes = MAX_LINE } = {}) {
26
+ constructor(child, { maxLineBytes = MAX_LINE, omitInlineAttachments = false } = {}) {
25
27
  this.maxLineBytes = Number.isInteger(maxLineBytes) ? Math.max(MAX_LINE, Math.min(maxLineBytes, 8 * 1024 * 1024)) : MAX_LINE;
26
28
  this.child = child;
27
29
  this.nextId = 0;
@@ -30,15 +32,21 @@ export class CodexAppServerClient {
30
32
  this.closed = false;
31
33
  // Provider output can include private text. Hold only one bounded JSON-RPC
32
34
  // line at a time, and discard overlong lines before parsing them.
33
- let pending = Buffer.alloc(0), discard = false;
35
+ let pending = Buffer.alloc(0), discard = false, wireBytes = 0;
36
+ let filter = omitInlineAttachments ? new JsonDataUrlFilter() : null;
34
37
  child.stdout.on('data', chunk => {
35
38
  let offset = 0;
36
39
  while (offset < chunk.length) {
37
40
  const newline = chunk.indexOf(10, offset);
38
41
  const end = newline < 0 ? chunk.length : newline;
39
- const piece = chunk.subarray(offset, end);
42
+ let piece = chunk.subarray(offset, end);
40
43
  if (!discard) {
41
- if (pending.length + piece.length > this.maxLineBytes) {
44
+ wireBytes += piece.length;
45
+ // Analysis does not use attachment bytes. Remove inline data URLs
46
+ // before its retained-byte limit, with a separate finite wire cap.
47
+ const wireLimit = omitInlineAttachments ? ANALYSIS_MAX_WIRE_LINE : this.maxLineBytes;
48
+ if (wireBytes <= wireLimit && filter) piece = filter.push(piece);
49
+ if (wireBytes > wireLimit || pending.length + piece.length > this.maxLineBytes) {
42
50
  pending = Buffer.alloc(0); discard = true;
43
51
  this.rejectPending(new CodexAppServerError('response_too_large', 'Codex response exceeded the bounded context limit.'));
44
52
  }
@@ -46,7 +54,9 @@ export class CodexAppServerClient {
46
54
  }
47
55
  if (newline >= 0) {
48
56
  if (!discard && pending.length) this.consume(pending.toString('utf8'));
49
- pending = Buffer.alloc(0); discard = false; offset = newline + 1;
57
+ pending = Buffer.alloc(0); discard = false; wireBytes = 0;
58
+ filter = omitInlineAttachments ? new JsonDataUrlFilter() : null;
59
+ offset = newline + 1;
50
60
  } else offset = end;
51
61
  }
52
62
  });
@@ -124,7 +134,7 @@ export function supportsCodexWriterLock(version) {
124
134
  return parts[0] > 0 || parts[1] > 158 || parts[1] === 158 && parts[2] >= 0;
125
135
  }
126
136
 
127
- export async function openCodexAppServer({ inspectExecutable = inspectBackendExecutable, spawnImpl = spawn, signal, maxLineBytes } = {}) {
137
+ export async function openCodexAppServer({ inspectExecutable = inspectBackendExecutable, spawnImpl = spawn, signal, maxLineBytes, omitInlineAttachments = false } = {}) {
128
138
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex app-server was cancelled.');
129
139
  const inspection = await inspectExecutable('codex');
130
140
  if (!inspection?.available || !inspection.executable) throw new CodexAppServerError('unavailable', 'Codex executable is unavailable.');
@@ -134,7 +144,7 @@ export async function openCodexAppServer({ inspectExecutable = inspectBackendExe
134
144
  // restore a temporary read-only override. The caller's signal is checked
135
145
  // before spawn and by the turn watcher below.
136
146
  const child = spawnImpl(inspection.executable, ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
137
- const client = new CodexAppServerClient(child, { maxLineBytes });
147
+ const client = new CodexAppServerClient(child, { maxLineBytes, omitInlineAttachments });
138
148
  try {
139
149
  await client.request('initialize', { clientInfo: { name: 'simy_session_supervision', title: 'SIMY Session Supervision', version: '1.0.0' } });
140
150
  client.notify('initialized');
@@ -157,12 +167,24 @@ export function codexThreadMetadata(thread) {
157
167
  };
158
168
  }
159
169
 
160
- export async function listCodexSessions({ client, maxPages = 40, pageSize = 5, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
170
+ export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = 200, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
161
171
  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 || []) {
172
+ const windowSize = Math.min(maxSessions, maxPages * pageSize);
173
+ let cursor = null, listed = 0, pages = 0, batchSize = pageSize;
174
+ while (pages < Math.max(maxPages, Math.ceil(windowSize / batchSize)) && listed < windowSize) {
175
+ const limit = Math.min(batchSize, windowSize - listed);
176
+ let response;
177
+ try { response = await client.request('thread/list', { cursor, limit, sourceKinds: SOURCE_KINDS, archived: false }); }
178
+ catch (error) {
179
+ // Keep the transport's byte limit. Retry the same cursor in smaller
180
+ // batches rather than dropping discovery for large metadata envelopes.
181
+ if (error?.code !== 'response_too_large' || limit <= 1) throw error;
182
+ batchSize = Math.max(1, Math.floor(limit / 2));
183
+ continue;
184
+ }
185
+ const threads = (response?.data || []).slice(0, limit);
186
+ listed += threads.length; pages++;
187
+ for (const thread of threads) {
166
188
  const metadata = codexThreadMetadata(thread);
167
189
  if (metadata) sessions.push(metadata);
168
190
  }
@@ -244,7 +266,7 @@ export async function readCodexSession(client, session) {
244
266
 
245
267
  export async function readCodexConversation(candidate, { openClient = openCodexAppServer, signal } = {}) {
246
268
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
247
- const client = await openClient({ signal, maxLineBytes: ANALYSIS_MAX_LINE });
269
+ const client = await openClient({ signal, maxLineBytes: ANALYSIS_MAX_LINE, omitInlineAttachments: true });
248
270
  const cancelled = () => { void client.close(); };
249
271
  signal?.addEventListener('abort', cancelled, { once: true });
250
272
  try {
@@ -257,22 +279,28 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
257
279
  const terminal = turn => ['completed', 'interrupted', 'failed'].includes(turn?.status);
258
280
  const turns = [];
259
281
  const turnIds = new Set(), cursors = new Set();
282
+ let pageSize = ANALYSIS_PAGE_SIZE;
283
+ const readPage = async (cursor, maximum = ANALYSIS_PAGE_SIZE) => {
284
+ let limit = Math.min(pageSize, maximum);
285
+ for (;;) {
286
+ if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
287
+ try {
288
+ const page = await client.request('thread/turns/list', { threadId: candidate.session, cursor, limit, sortDirection: 'desc', itemsView: 'summary' });
289
+ if (!Array.isArray(page?.data) || page.data.length > limit || page.nextCursor != null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 2048)) {
290
+ throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid bounded conversation page.');
291
+ }
292
+ return page;
293
+ } catch (error) {
294
+ if (unsupportedMethod(error)) throw new CodexAppServerError('unsupported', 'This Codex version does not support bounded conversation history.');
295
+ if (error?.code !== 'response_too_large' || limit <= 1) throw error;
296
+ pageSize = limit = Math.max(1, Math.floor(limit / 2));
297
+ }
298
+ }
299
+ };
260
300
  let cursor = null;
261
301
  do {
262
302
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
263
- const limit = Math.min(ANALYSIS_PAGE_SIZE, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
264
- let page;
265
- try {
266
- page = await client.request('thread/turns/list', { threadId: candidate.session, cursor, limit, sortDirection: 'desc', itemsView: 'summary' });
267
- } catch (error) {
268
- if (String(error?.code) === '-32601' || /unknown method|method.*not found|unrecognized method/i.test(String(error?.message || ''))) {
269
- throw new CodexAppServerError('unsupported', 'This Codex version does not support bounded conversation history.');
270
- }
271
- throw error;
272
- }
273
- if (!Array.isArray(page?.data) || page.data.length > limit || page.nextCursor != null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 2048)) {
274
- throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid bounded conversation page.');
275
- }
303
+ const page = await readPage(cursor, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
276
304
  for (const turn of page.data) {
277
305
  if (typeof turn?.id !== 'string' || !turn.id || turnIds.has(turn.id)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
278
306
  turnIds.add(turn.id); turns.push(turn);
@@ -286,13 +314,18 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
286
314
  // to this read-only snapshot, but a newly settled turn must invalidate it.
287
315
  const settled = turns.filter(terminal).slice(0, ANALYSIS_MAX_TURNS);
288
316
  const boundary = settled[0];
289
- const verification = await client.request('thread/turns/list', {
290
- threadId: candidate.session, cursor: null, limit: ANALYSIS_PAGE_SIZE, sortDirection: 'desc', itemsView: 'summary',
291
- });
292
- if (!Array.isArray(verification?.data) || verification.data.length > ANALYSIS_PAGE_SIZE) {
293
- throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid snapshot check.');
317
+ let verification = await readPage(null);
318
+ const verificationTurns = [...verification.data];
319
+ const verificationCursors = new Set();
320
+ // A reduced first page may contain only the running turn. Follow bounded
321
+ // pages to the terminal boundary rather than mistaking it for a new one.
322
+ while (!verificationTurns.some(terminal) && verification.nextCursor && verificationTurns.length < ANALYSIS_MAX_TURNS + ANALYSIS_PAGE_SIZE) {
323
+ if (!verification.data.length || verificationCursors.has(verification.nextCursor)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous snapshot pagination.');
324
+ verificationCursors.add(verification.nextCursor);
325
+ verification = await readPage(verification.nextCursor);
326
+ verificationTurns.push(...verification.data);
294
327
  }
295
- const latestBoundary = verification.data.find(terminal);
328
+ const latestBoundary = verificationTurns.find(terminal);
296
329
  // Compare the immutable settled summary, never the running turn's prose.
297
330
  if (JSON.stringify(boundary) !== JSON.stringify(latestBoundary)) {
298
331
  throw new CodexAppServerError('turn_settling', 'The latest turn settled during the snapshot read.');
@@ -310,7 +343,7 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
310
343
  // A display summary omits intermediate reasoning/tool context even when
311
344
  // every turn fits. Never represent it as the complete conversation; the
312
345
  // classifier must establish a self-contained objective from retained text.
313
- const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verification.data].some(turn => ['inProgress', 'in_progress'].includes(turn?.status));
346
+ const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verificationTurns].some(turn => ['inProgress', 'in_progress'].includes(turn?.status));
314
347
  let incomplete = !settled.length || turns.some(turn => !terminal(turn) && !['inProgress', 'in_progress'].includes(turn?.status)), truncated = true;
315
348
  for (const [index, turn] of settled.toReversed().entries()) {
316
349
  if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
@@ -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,
@@ -0,0 +1,66 @@
1
+ // JSON remains validated by JSON.parse after this lexical filter. Only inline
2
+ // URL values are removed; prose, identities, cursors and escaped quotes retain
3
+ // their original bytes. Latin-1 here preserves split UTF-8 sequences verbatim.
4
+ export class JsonDataUrlFilter {
5
+ constructor() {
6
+ this.inString = false;
7
+ this.escaped = false;
8
+ this.token = '';
9
+ this.lastString = null;
10
+ this.valueKey = null;
11
+ this.probe = null;
12
+ this.dropping = false;
13
+ }
14
+
15
+ push(bytes) {
16
+ let output = '';
17
+ for (const char of bytes.toString('latin1')) {
18
+ if (!this.inString) {
19
+ output += char;
20
+ if (char === '"') {
21
+ this.inString = true;
22
+ this.token = '';
23
+ this.escaped = false;
24
+ this.probe = ['url', 'imageUrl', 'image_url'].includes(this.valueKey) ? '' : null;
25
+ this.dropping = false;
26
+ this.valueKey = null;
27
+ } else if (char === ':') {
28
+ this.valueKey = this.lastString;
29
+ this.lastString = null;
30
+ } else if (!/\s/.test(char)) {
31
+ this.lastString = null;
32
+ this.valueKey = null;
33
+ }
34
+ continue;
35
+ }
36
+ const closing = char === '"' && !this.escaped;
37
+ if (closing) {
38
+ if (this.probe !== null) output += this.probe;
39
+ output += char;
40
+ this.inString = false;
41
+ this.lastString = null;
42
+ if (this.token !== null && !this.dropping) {
43
+ try { this.lastString = JSON.parse(Buffer.from(`"${this.token}"`, 'latin1').toString('utf8')); } catch { /* Final JSON validation rejects malformed input. */ }
44
+ }
45
+ this.probe = null;
46
+ this.dropping = false;
47
+ continue;
48
+ }
49
+ if (this.token !== null) this.token = this.token.length < 128 ? this.token + char : null;
50
+ if (this.escaped) this.escaped = false;
51
+ else if (char === '\\') this.escaped = true;
52
+ if (this.dropping) continue;
53
+ if (this.probe !== null) {
54
+ this.probe += char;
55
+ if (!'data:'.startsWith(this.probe)) {
56
+ output += this.probe;
57
+ this.probe = null;
58
+ } else if (this.probe === 'data:') {
59
+ this.dropping = true;
60
+ this.probe = null;
61
+ }
62
+ } else output += char;
63
+ }
64
+ return Buffer.from(output, 'latin1');
65
+ }
66
+ }