@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 +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +7 -0
- package/src/delivery-hooks/supervisor.js +5 -8
- package/src/desktop-host.js +3 -2
- package/src/session-supervision/action-association-host.js +22 -10
- package/src/session-supervision/action-deadline-store.js +5 -4
- package/src/session-supervision/action-deadline.js +4 -1
- package/src/session-supervision/association-store.js +4 -8
- package/src/session-supervision/codex-app-server.js +66 -33
- package/src/session-supervision/index.js +5 -5
- package/src/session-supervision/json-data-url-filter.js +66 -0
package/package.json
CHANGED
package/src/cli-contract.js
CHANGED
|
@@ -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.
|
|
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 =
|
|
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
|
|
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
|
|
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());
|
package/src/desktop-host.js
CHANGED
|
@@ -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 =>
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 =
|
|
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
|
-
|
|
42
|
+
let piece = chunk.subarray(offset, end);
|
|
40
43
|
if (!discard) {
|
|
41
|
-
|
|
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;
|
|
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 =
|
|
170
|
+
export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = 200, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
|
|
161
171
|
const sessions = [];
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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 =
|
|
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, ...
|
|
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 =
|
|
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 =
|
|
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(
|
|
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' ?
|
|
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
|
+
}
|