@khorsheed/dsh-local-agent-codex 0.1.0-rc.6
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/README.en.md +136 -0
- package/README.i18n.yaml +6 -0
- package/README.md +136 -0
- package/cordis.patch.yml +42 -0
- package/lib/client.js +1066 -0
- package/lib/index.js +3550 -0
- package/lib/invariant.js +31 -0
- package/lib/types/client/SettingsCard.d.ts +76 -0
- package/lib/types/client/SettingsCard.js +200 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +43 -0
- package/lib/types/client/index.js +88 -0
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/locales.js +55 -0
- package/lib/types/codex-cli-provider.d.ts +347 -0
- package/lib/types/codex-cli-provider.js +1225 -0
- package/lib/types/index.d.ts +70 -0
- package/lib/types/index.js +272 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +33 -0
- package/lib/types/live-driver.d.ts +229 -0
- package/lib/types/live-driver.js +1279 -0
- package/lib/types/live-switch.d.ts +70 -0
- package/lib/types/live-switch.js +90 -0
- package/lib/types/model-broker.d.ts +102 -0
- package/lib/types/model-broker.js +187 -0
- package/lib/types/model-catalog.d.ts +44 -0
- package/lib/types/model-catalog.js +220 -0
- package/lib/types/provision.d.ts +80 -0
- package/lib/types/provision.js +213 -0
- package/lib/types/records.d.ts +120 -0
- package/lib/types/records.js +505 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +106 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex session records adapter: scans the scoped home's `sessions/`
|
|
3
|
+
* rollout files (one JSONL per session under `sessions/YYYY/MM/DD/`).
|
|
4
|
+
* The LISTING reads only the head of each file — the first `session_meta`
|
|
5
|
+
* line carries the id, cwd, and started-at timestamp it needs. The READ-BACK
|
|
6
|
+
* path ({@link codexRolloutRoundFacts}) instead locates one run's file (by
|
|
7
|
+
* thread id, working directory, or a time window) and reads that round's
|
|
8
|
+
* usage, model, and codex build out of it — the facts the exec `--json` wire
|
|
9
|
+
* either omits entirely (the model) or drops on a non-completed round (the
|
|
10
|
+
* usage). Files are treated as append-only with torn-tail tolerance, and
|
|
11
|
+
* contents are always untrusted input.
|
|
12
|
+
* @module @khorsheed/dsh-local-agent-codex/records
|
|
13
|
+
*/
|
|
14
|
+
import { open, readFile, readdir } from 'node:fs/promises';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
/** Rollout files live under this directory inside the scoped home. */
|
|
17
|
+
const SESSIONS_ROOT = 'sessions';
|
|
18
|
+
/** Bound on the head prefix read from one rollout file. */
|
|
19
|
+
const HEAD_BYTES = 64 * 1024;
|
|
20
|
+
/** Bound on the tail read from one rollout file for the usage scan. */
|
|
21
|
+
const TAIL_BYTES = 64 * 1024;
|
|
22
|
+
/** Bound on the full-file fallback read when the tail scan finds no token_count. */
|
|
23
|
+
const FULL_BYTES = 8 * 1024 * 1024;
|
|
24
|
+
/**
|
|
25
|
+
* How far back a candidate file's start may precede the run's start and
|
|
26
|
+
* still count as "this run" (clock skew + pre-spawn session creation slack).
|
|
27
|
+
*/
|
|
28
|
+
const ROLLOUT_WINDOW_SLACK_MS = 5 * 60_000;
|
|
29
|
+
/** Extra future slack for the window end (file timestamps may skew ahead). */
|
|
30
|
+
const ROLLOUT_WINDOW_FUTURE_MS = 60_000;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the scoped home holds usable Codex credentials: the `auth.json`
|
|
33
|
+
* file exists. Absent means the device-code flow has not completed.
|
|
34
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
35
|
+
* @returns true when credentials are present.
|
|
36
|
+
*/
|
|
37
|
+
export async function codexAuthenticated(homeDir) {
|
|
38
|
+
try {
|
|
39
|
+
await readFile(join(homeDir, 'auth.json'));
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
// No auth file yet: not authenticated.
|
|
44
|
+
if (error.code === 'ENOENT')
|
|
45
|
+
return false;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse the first line of a rollout file for the listing fields. Unknown or
|
|
51
|
+
* malformed values yield an undefined record rather than failing the list.
|
|
52
|
+
* @param text - the raw rollout file content (only the head is read).
|
|
53
|
+
* @returns the record, or undefined when the head is not a session_meta line.
|
|
54
|
+
*/
|
|
55
|
+
export function rolloutRecord(text) {
|
|
56
|
+
const line = text.split('\n')[0];
|
|
57
|
+
if (line === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
let event;
|
|
60
|
+
try {
|
|
61
|
+
event = JSON.parse(line);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (event.type !== 'session_meta' || typeof event.payload !== 'object' || event.payload === null) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const meta = event.payload;
|
|
70
|
+
const sessionId = typeof meta.id === 'string' && meta.id !== ''
|
|
71
|
+
? meta.id
|
|
72
|
+
: typeof meta.session_id === 'string' && meta.session_id !== ''
|
|
73
|
+
? meta.session_id
|
|
74
|
+
: undefined;
|
|
75
|
+
if (sessionId === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (typeof meta.cwd !== 'string')
|
|
78
|
+
return undefined;
|
|
79
|
+
const record = { id: sessionId, workDir: meta.cwd };
|
|
80
|
+
const startedAt = typeof meta.timestamp === 'string' ? Date.parse(meta.timestamp) : undefined;
|
|
81
|
+
if (startedAt !== undefined && Number.isFinite(startedAt))
|
|
82
|
+
record.startedAt = startedAt;
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read the scoped home's rollout files. The listing walks
|
|
87
|
+
* `sessions/YYYY/MM/DD/` and reads each `rollout-*.jsonl` head; a torn or
|
|
88
|
+
* malformed file is skipped rather than failing the whole list.
|
|
89
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
90
|
+
* @returns the parsed records, empty when no sessions exist yet.
|
|
91
|
+
*/
|
|
92
|
+
export async function listCodexSessions(homeDir) {
|
|
93
|
+
const records = [];
|
|
94
|
+
let years;
|
|
95
|
+
try {
|
|
96
|
+
years = await readdir(join(homeDir, SESSIONS_ROOT));
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// No sessions root yet: nothing to list.
|
|
100
|
+
if (error.code === 'ENOENT')
|
|
101
|
+
return [];
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
for (const year of years) {
|
|
105
|
+
if (!/^\d{4}$/.test(year))
|
|
106
|
+
continue;
|
|
107
|
+
let months;
|
|
108
|
+
try {
|
|
109
|
+
months = await readdir(join(homeDir, SESSIONS_ROOT, year));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
for (const month of months) {
|
|
115
|
+
if (!/^\d{2}$/.test(month))
|
|
116
|
+
continue;
|
|
117
|
+
let days;
|
|
118
|
+
try {
|
|
119
|
+
days = await readdir(join(homeDir, SESSIONS_ROOT, year, month));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const day of days) {
|
|
125
|
+
if (!/^\d{2}$/.test(day))
|
|
126
|
+
continue;
|
|
127
|
+
let files;
|
|
128
|
+
try {
|
|
129
|
+
files = await readdir(join(homeDir, SESSIONS_ROOT, year, month, day));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const name of files) {
|
|
135
|
+
if (!name.startsWith('rollout-') || !name.endsWith('.jsonl'))
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
// Only the head matters; read a bounded prefix rather than the
|
|
139
|
+
// whole (potentially long) event log.
|
|
140
|
+
const handle = await open(join(homeDir, SESSIONS_ROOT, year, month, day, name), 'r');
|
|
141
|
+
let text;
|
|
142
|
+
try {
|
|
143
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
144
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
145
|
+
text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await handle.close();
|
|
149
|
+
}
|
|
150
|
+
const record = rolloutRecord(text);
|
|
151
|
+
if (record !== undefined)
|
|
152
|
+
records.push(record);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return records;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Map codex's token-count payload onto the shared usage contract — the
|
|
165
|
+
* SINGLE caliber for both the exec `turn.completed` usage and the rollout
|
|
166
|
+
* `token_count` recovery, so the two sources can never drift. Codex's
|
|
167
|
+
* `input_tokens` is the TOTAL input including cache hits (OpenAI-style
|
|
168
|
+
* accounting, confirmed against `total_tokens` in the rollout token_count),
|
|
169
|
+
* and `cached_input_tokens` is the cache-read subset — so the uncached
|
|
170
|
+
* bucket subtracts the cached portion to avoid double counting. There is no
|
|
171
|
+
* cache-write concept, so that bucket is omitted (the shared four-bucket
|
|
172
|
+
* contract: uncached input, output, cache read, cache write — codex fills
|
|
173
|
+
* the first three).
|
|
174
|
+
* @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
|
|
175
|
+
* rollout `token_count` `info.last_token_usage`/`total_token_usage`).
|
|
176
|
+
* @returns the shared usage record.
|
|
177
|
+
*/
|
|
178
|
+
export function usageFromCodex(usage) {
|
|
179
|
+
const raw = usage;
|
|
180
|
+
const input = Number(raw.input_tokens);
|
|
181
|
+
const cached = Number(raw.cached_input_tokens);
|
|
182
|
+
const output = Number(raw.output_tokens);
|
|
183
|
+
const uncached = Number.isFinite(input) && Number.isFinite(cached)
|
|
184
|
+
? Math.max(0, input - cached)
|
|
185
|
+
: Number.isFinite(input)
|
|
186
|
+
? input
|
|
187
|
+
: 0;
|
|
188
|
+
const usageRecord = {
|
|
189
|
+
inputTokens: uncached,
|
|
190
|
+
outputTokens: Number.isFinite(output) ? output : 0,
|
|
191
|
+
};
|
|
192
|
+
if (Number.isFinite(cached) && cached > 0)
|
|
193
|
+
usageRecord.cacheReadTokens = cached;
|
|
194
|
+
return usageRecord;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Scan rollout-file text for the LAST `token_count` event and map its usage
|
|
198
|
+
* onto the shared contract. A token_count entry rides an
|
|
199
|
+
* `{"type":"event_msg","payload":{"type":"token_count","info":{…}}}` line
|
|
200
|
+
* (verified against codex-cli 0.144.0 rollout files); the per-turn
|
|
201
|
+
* `info.last_token_usage` is the same caliber as `turn.completed`'s usage,
|
|
202
|
+
* with the session-wide `total_token_usage` as fallback. Torn or malformed
|
|
203
|
+
* lines are skipped; an absent event yields undefined.
|
|
204
|
+
* @param text - raw rollout file content (the tail scan reads a bounded suffix).
|
|
205
|
+
* @returns the mapped usage of the last token_count event, or undefined.
|
|
206
|
+
*/
|
|
207
|
+
export function codexRolloutTokenUsage(text) {
|
|
208
|
+
let info;
|
|
209
|
+
for (const raw of text.split('\n')) {
|
|
210
|
+
const line = raw.trim();
|
|
211
|
+
if (line === '')
|
|
212
|
+
continue;
|
|
213
|
+
let event;
|
|
214
|
+
try {
|
|
215
|
+
event = JSON.parse(line);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (event.type !== 'event_msg' || typeof event.payload !== 'object' || event.payload === null)
|
|
221
|
+
continue;
|
|
222
|
+
const payload = event.payload;
|
|
223
|
+
if (payload.type !== 'token_count')
|
|
224
|
+
continue;
|
|
225
|
+
if (typeof payload.info === 'object' && payload.info !== null)
|
|
226
|
+
info = payload.info;
|
|
227
|
+
}
|
|
228
|
+
if (info === undefined)
|
|
229
|
+
return undefined;
|
|
230
|
+
const entry = info;
|
|
231
|
+
const usage = entry.last_token_usage ?? entry.total_token_usage;
|
|
232
|
+
return usage === undefined ? undefined : usageFromCodex(usage);
|
|
233
|
+
}
|
|
234
|
+
/** Parse the ISO timestamp embedded in a `rollout-<ISO>-<uuid>.jsonl` name. */
|
|
235
|
+
function rolloutFilenameTimestamp(name) {
|
|
236
|
+
// `rollout-2026-07-04T19-58-19-019f2cfe….jsonl`: the time part uses dashes
|
|
237
|
+
// instead of colons; normalize before Date.parse.
|
|
238
|
+
const match = /^rollout-(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})/.exec(name);
|
|
239
|
+
if (match === null)
|
|
240
|
+
return undefined;
|
|
241
|
+
const parsed = Date.parse(`${match[1]}:${match[2]}:${match[3]}`);
|
|
242
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The codex build that wrote one rollout file, from its `session_meta` head
|
|
246
|
+
* (`payload.cli_version`, present since codex 0.14x). This is the AUTHORITY
|
|
247
|
+
* for the version a delegation round actually ran: it is codex's own record of
|
|
248
|
+
* itself, written by the process that served the round, so it cannot drift
|
|
249
|
+
* from the binary the way a later `--version` probe can.
|
|
250
|
+
* @param text - the raw rollout head.
|
|
251
|
+
* @returns the version, or undefined when the head names none.
|
|
252
|
+
*/
|
|
253
|
+
export function rolloutCliVersion(text) {
|
|
254
|
+
const line = text.split('\n')[0];
|
|
255
|
+
if (line === undefined)
|
|
256
|
+
return undefined;
|
|
257
|
+
let event;
|
|
258
|
+
try {
|
|
259
|
+
event = JSON.parse(line);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
if (event.type !== 'session_meta' || typeof event.payload !== 'object' || event.payload === null) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
const version = event.payload.cli_version;
|
|
268
|
+
return typeof version === 'string' && version !== '' ? version : undefined;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* How far before the run's spawn moment a `turn_context` timestamp may sit and
|
|
272
|
+
* still count as THIS run's turn (sub-second ordering between the host's
|
|
273
|
+
* spawn clock and the CLI's own stamps).
|
|
274
|
+
*/
|
|
275
|
+
const ROLLOUT_CONTEXT_SLACK_MS = 2_000;
|
|
276
|
+
/**
|
|
277
|
+
* Locate ONE run's rollout file. Walks the scoped home's `sessions/YYYY/MM/DD/`
|
|
278
|
+
* tree reading each file's head (the same bounded walk as
|
|
279
|
+
* {@link listCodexSessions}), then prefers the file whose session id equals
|
|
280
|
+
* `locator.threadId`; otherwise the file whose start falls in the window
|
|
281
|
+
* around `locator.windowStart`, newest first.
|
|
282
|
+
*
|
|
283
|
+
* **Concurrency**: the window alone cannot separate two runs started seconds
|
|
284
|
+
* apart, so when the locator names a `cwd` the window set is restricted to
|
|
285
|
+
* files whose head records that same directory. A window that holds candidates
|
|
286
|
+
* but none in the run's directory resolves to NOTHING rather than to a
|
|
287
|
+
* neighbour's file — unless the window holds exactly one candidate, where
|
|
288
|
+
* there is no other run to confuse it with and the mismatch is path skew (a
|
|
289
|
+
* symlinked temp root) rather than ambiguity. A misattributed model is a
|
|
290
|
+
* worse answer than an absent one: the evaluation fails a run loud on
|
|
291
|
+
* declared ≠ observed.
|
|
292
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
293
|
+
* @param locator - the thread id, working directory, and/or start-time window.
|
|
294
|
+
* @returns the located candidate, or undefined when nothing locates.
|
|
295
|
+
*/
|
|
296
|
+
async function locateCodexRollout(homeDir, locator) {
|
|
297
|
+
const candidates = [];
|
|
298
|
+
let years;
|
|
299
|
+
try {
|
|
300
|
+
years = await readdir(join(homeDir, SESSIONS_ROOT));
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (error.code === 'ENOENT')
|
|
304
|
+
return undefined;
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
for (const year of years) {
|
|
308
|
+
if (!/^\d{4}$/.test(year))
|
|
309
|
+
continue;
|
|
310
|
+
let months;
|
|
311
|
+
try {
|
|
312
|
+
months = await readdir(join(homeDir, SESSIONS_ROOT, year));
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
for (const month of months) {
|
|
318
|
+
if (!/^\d{2}$/.test(month))
|
|
319
|
+
continue;
|
|
320
|
+
let days;
|
|
321
|
+
try {
|
|
322
|
+
days = await readdir(join(homeDir, SESSIONS_ROOT, year, month));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
for (const day of days) {
|
|
328
|
+
if (!/^\d{2}$/.test(day))
|
|
329
|
+
continue;
|
|
330
|
+
let files;
|
|
331
|
+
try {
|
|
332
|
+
files = await readdir(join(homeDir, SESSIONS_ROOT, year, month, day));
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
for (const name of files) {
|
|
338
|
+
if (!name.startsWith('rollout-') || !name.endsWith('.jsonl'))
|
|
339
|
+
continue;
|
|
340
|
+
const path = join(homeDir, SESSIONS_ROOT, year, month, day, name);
|
|
341
|
+
try {
|
|
342
|
+
const handle = await open(path, 'r');
|
|
343
|
+
let text;
|
|
344
|
+
try {
|
|
345
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
346
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
347
|
+
text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await handle.close();
|
|
351
|
+
}
|
|
352
|
+
const record = rolloutRecord(text);
|
|
353
|
+
candidates.push({
|
|
354
|
+
path,
|
|
355
|
+
id: record?.id,
|
|
356
|
+
startedAt: record?.startedAt ?? rolloutFilenameTimestamp(name),
|
|
357
|
+
workDir: record?.workDir,
|
|
358
|
+
cliVersion: rolloutCliVersion(text),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (candidates.length === 0)
|
|
369
|
+
return undefined;
|
|
370
|
+
const threadMatch = locator.threadId === undefined
|
|
371
|
+
? undefined
|
|
372
|
+
: candidates.find(candidate => candidate.id === locator.threadId);
|
|
373
|
+
if (threadMatch !== undefined)
|
|
374
|
+
return threadMatch;
|
|
375
|
+
if (locator.windowStart === undefined)
|
|
376
|
+
return undefined;
|
|
377
|
+
const windowStart = locator.windowStart - ROLLOUT_WINDOW_SLACK_MS;
|
|
378
|
+
const windowEnd = Date.now() + ROLLOUT_WINDOW_FUTURE_MS;
|
|
379
|
+
const inWindow = candidates
|
|
380
|
+
.filter(candidate => candidate.startedAt !== undefined
|
|
381
|
+
&& candidate.startedAt >= windowStart && candidate.startedAt <= windowEnd)
|
|
382
|
+
.sort((left, right) => (right.startedAt ?? 0) - (left.startedAt ?? 0));
|
|
383
|
+
if (locator.cwd === undefined)
|
|
384
|
+
return inWindow[0];
|
|
385
|
+
const wanted = resolve(locator.cwd);
|
|
386
|
+
const sameCwd = inWindow.filter(candidate => candidate.workDir !== undefined && resolve(candidate.workDir) === wanted);
|
|
387
|
+
if (sameCwd.length > 0)
|
|
388
|
+
return sameCwd[0];
|
|
389
|
+
return inWindow.length === 1 ? inWindow[0] : undefined;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Scan rollout text for this run's two read-back facts: the LAST `token_count`
|
|
393
|
+
* usage, and the model of the LAST `turn_context` whose top-level `timestamp`
|
|
394
|
+
* falls inside the run's window (`windowStart` onward) — the turn codex
|
|
395
|
+
* actually started for this round, so a resumed thread's earlier rounds are
|
|
396
|
+
* never misread and a round killed mid-turn still observes the model it
|
|
397
|
+
* started with. A torn line is skipped.
|
|
398
|
+
*/
|
|
399
|
+
function scanRolloutText(text, windowStart) {
|
|
400
|
+
const usage = codexRolloutTokenUsage(text);
|
|
401
|
+
const scan = usage === undefined ? {} : { usage };
|
|
402
|
+
for (const raw of text.split('\n')) {
|
|
403
|
+
const line = raw.trim();
|
|
404
|
+
if (line === '')
|
|
405
|
+
continue;
|
|
406
|
+
let event;
|
|
407
|
+
try {
|
|
408
|
+
event = JSON.parse(line);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (event.type !== 'turn_context' || typeof event.payload !== 'object' || event.payload === null)
|
|
414
|
+
continue;
|
|
415
|
+
if (windowStart !== undefined) {
|
|
416
|
+
const stamped = typeof event.timestamp === 'string' ? Date.parse(event.timestamp) : undefined;
|
|
417
|
+
if (stamped === undefined || !Number.isFinite(stamped) || stamped < windowStart)
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const candidate = event.payload.model;
|
|
421
|
+
if (typeof candidate === 'string' && candidate !== '')
|
|
422
|
+
scan.model = candidate;
|
|
423
|
+
}
|
|
424
|
+
return scan;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Read one located rollout file for the round's usage and model.
|
|
428
|
+
*
|
|
429
|
+
* The tail is read first because it is where `token_count` lives (codex writes
|
|
430
|
+
* one per turn boundary, so the last is at the end). `turn_context` is the
|
|
431
|
+
* opposite: codex writes it when the turn STARTS, so a round that then
|
|
432
|
+
* produces more than {@link TAIL_BYTES} of events pushes it out of the tail
|
|
433
|
+
* entirely — which is exactly how a real evaluation run reported a null model
|
|
434
|
+
* against rollout files that named it plainly, while a short smoke round (a
|
|
435
|
+
* whole file smaller than the tail window) read back fine. So whenever the
|
|
436
|
+
* tail leaves either fact missing, a bounded full read from offset 0 fills it
|
|
437
|
+
* in.
|
|
438
|
+
*/
|
|
439
|
+
async function readRolloutFacts(path, windowStart) {
|
|
440
|
+
const handle = await open(path, 'r');
|
|
441
|
+
try {
|
|
442
|
+
const { size } = await handle.stat();
|
|
443
|
+
if (size === 0)
|
|
444
|
+
return {};
|
|
445
|
+
const tailLength = Math.min(TAIL_BYTES, size);
|
|
446
|
+
const tail = Buffer.alloc(tailLength);
|
|
447
|
+
const tailRead = await handle.read(tail, 0, tailLength, size - tailLength);
|
|
448
|
+
const scan = scanRolloutText(tail.subarray(0, tailRead.bytesRead).toString('utf8'), windowStart);
|
|
449
|
+
if ((scan.usage !== undefined && scan.model !== undefined) || size <= TAIL_BYTES)
|
|
450
|
+
return scan;
|
|
451
|
+
const full = Buffer.alloc(Math.min(FULL_BYTES, size));
|
|
452
|
+
const fullRead = await handle.read(full, 0, full.length, 0);
|
|
453
|
+
const wide = scanRolloutText(full.subarray(0, fullRead.bytesRead).toString('utf8'), windowStart);
|
|
454
|
+
// The tail's answers are the file's LAST ones; the full read only fills
|
|
455
|
+
// what the tail did not carry (its own "last" stops at FULL_BYTES).
|
|
456
|
+
const usage = scan.usage ?? wide.usage;
|
|
457
|
+
const model = scan.model ?? wide.model;
|
|
458
|
+
return {
|
|
459
|
+
...usage === undefined ? {} : { usage },
|
|
460
|
+
...model === undefined ? {} : { model },
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
await handle.close();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Read ONE run's facts back from its rollout file: the token usage a
|
|
469
|
+
* non-completed round never streamed, the model the exec `--json` wire does
|
|
470
|
+
* not carry at all (codex 0.144.0), and the codex build that served it. One
|
|
471
|
+
* locate and one file open serve all three.
|
|
472
|
+
*
|
|
473
|
+
* Every field is absent on a miss — no rollout file, an unreadable home, a
|
|
474
|
+
* hard kill before any turn boundary, a window with no turn_context. Absence
|
|
475
|
+
* is recorded, never guessed.
|
|
476
|
+
* @param homeDir - the `codex` harness's scoped home.
|
|
477
|
+
* @param locator - the thread id, working directory, and/or start-time window.
|
|
478
|
+
* @returns the round's read-back facts.
|
|
479
|
+
*/
|
|
480
|
+
export async function codexRolloutRoundFacts(homeDir, locator) {
|
|
481
|
+
let located;
|
|
482
|
+
try {
|
|
483
|
+
located = await locateCodexRollout(homeDir, locator);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return {};
|
|
487
|
+
}
|
|
488
|
+
if (located === undefined)
|
|
489
|
+
return {};
|
|
490
|
+
const windowStart = locator.windowStart === undefined
|
|
491
|
+
? undefined
|
|
492
|
+
: locator.windowStart - ROLLOUT_CONTEXT_SLACK_MS;
|
|
493
|
+
let scan = {};
|
|
494
|
+
try {
|
|
495
|
+
scan = await readRolloutFacts(located.path, windowStart);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// An unreadable file still yields the head fact the locator already read.
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
...scan.usage === undefined ? {} : { usage: scan.usage },
|
|
502
|
+
...scan.model === undefined ? {} : { model: scan.model },
|
|
503
|
+
...located.cliVersion === undefined ? {} : { cliVersion: located.cliVersion },
|
|
504
|
+
};
|
|
505
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@khorsheed/dsh-local-agent-codex",
|
|
3
|
+
"description": "Codex harness for dsh-local-agent: scoped CODEX_HOME, one-shot codex exec delegation visible in the 子代理 surface, rollout-file session records",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"dsh",
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"cordis",
|
|
8
|
+
"local-agent",
|
|
9
|
+
"codex"
|
|
10
|
+
],
|
|
11
|
+
"version": "0.1.0-rc.6",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"types": "lib/types/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./lib/types/index.d.ts",
|
|
18
|
+
"default": "./lib/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./invariant": {
|
|
21
|
+
"types": "./lib/types/invariant.d.ts",
|
|
22
|
+
"default": "./lib/invariant.js"
|
|
23
|
+
},
|
|
24
|
+
"./records": {
|
|
25
|
+
"types": "./lib/types/records.d.ts",
|
|
26
|
+
"default": "./lib/records.js"
|
|
27
|
+
},
|
|
28
|
+
"./client": {
|
|
29
|
+
"types": "./lib/types/client/index.d.ts",
|
|
30
|
+
"default": "./lib/client.js"
|
|
31
|
+
},
|
|
32
|
+
"./src/*": "./src/*",
|
|
33
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
34
|
+
"./package.json": "./package.json",
|
|
35
|
+
"./locale/*.json": "./locale/*.json"
|
|
36
|
+
},
|
|
37
|
+
"dsh": {
|
|
38
|
+
"references": [
|
|
39
|
+
"@khorsheed/dsh-bundle-local-agent"
|
|
40
|
+
],
|
|
41
|
+
"client": {
|
|
42
|
+
"inject": [
|
|
43
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
44
|
+
"@deepseek-ai/dsh-client-locale",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-primitives",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
48
|
+
],
|
|
49
|
+
"platform": "web"
|
|
50
|
+
},
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
},
|
|
54
|
+
"compat": {
|
|
55
|
+
"minHost": "0.1.5-rc.1",
|
|
56
|
+
"notes": "Live output uses the local-agent transient Remote and public Conversation nodes, with incremental durable recovery checkpoints; native final messages retain their existing transcript and usage semantics. End-to-end latency acceptance is tracked by the room coordinator proposal.",
|
|
57
|
+
"verifiedHost": "0.1.5-rc.1"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"files": [
|
|
61
|
+
"lib",
|
|
62
|
+
"cordis.patch.yml",
|
|
63
|
+
"locale/*.json"
|
|
64
|
+
],
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@khorsheed/dsh-local-agent": "^0.1.0-rc.6",
|
|
68
|
+
"@khorsheed/dsh-local-agent-tool-subagent": "^0.1.0-rc.6"
|
|
69
|
+
},
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
72
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
|
|
73
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
77
|
+
"@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
|
|
78
|
+
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
|
79
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
80
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
|
81
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
82
|
+
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.6",
|
|
83
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6",
|
|
84
|
+
"react": "^18.2.0"
|
|
85
|
+
},
|
|
86
|
+
"peerDependenciesMeta": {
|
|
87
|
+
"@deepseek-ai/dsh-api-remotes": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"@deepseek-ai/dsh-client-locale": {
|
|
91
|
+
"optional": true
|
|
92
|
+
},
|
|
93
|
+
"@deepseek-ai/dsh-client-ui-primitives": {
|
|
94
|
+
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"@deepseek-ai/dsh-client-ui-settings": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"@deepseek-ai/dsh-client-ui-slots": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"react": {
|
|
103
|
+
"optional": true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|