@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.
@@ -0,0 +1,220 @@
1
+ /** Native app-server model/list discovery. Shared core cache owns refresh,
2
+ * stale data and subscriptions; this provider owns protocol and pagination. */
3
+ import { dirname, join } from 'node:path';
4
+ import { StringDecoder } from 'node:string_decoder';
5
+ import { ModelDirectoryCache, modelDirectoryContextKey, delegationEnv } from '@khorsheed/dsh-local-agent';
6
+ import { DEFAULT_DISPOSE_GRACE_MS } from './codex-cli-provider.js';
7
+ /** Default overall bound for one catalog probe (spawn → model/list answer). */
8
+ export const DEFAULT_CATALOG_PROBE_TIMEOUT_MS = 5_000;
9
+ /** Default lifetime of a completed probe's cached answer, per scoped home. */
10
+ export const DEFAULT_CATALOG_TTL_MS = 5 * 60_000;
11
+ /** Grace between stdin EOF and terminate when the probe tears the process down. */
12
+ const PROBE_EOF_GRACE_MS = 1_000;
13
+ /** Preserve native metadata, including hidden candidates; reject malformed pages. */
14
+ function catalogEntries(result) {
15
+ const page = result;
16
+ if (!Array.isArray(page?.data))
17
+ throw new Error('model/list returned no model array');
18
+ const entries = [];
19
+ let defaultModel;
20
+ for (const entry of page.data) {
21
+ if (typeof entry !== 'object' || entry === null)
22
+ continue;
23
+ const record = entry;
24
+ const raw = typeof record['id'] === 'string' ? record['id'] : record['model'];
25
+ const value = typeof raw === 'string' ? raw.trim() : '';
26
+ if (value === '')
27
+ continue;
28
+ if (record['isDefault'] === true && defaultModel === undefined)
29
+ defaultModel = value;
30
+ const nativeEfforts = record['supportedReasoningEfforts'];
31
+ const options = Array.isArray(nativeEfforts) ? nativeEfforts.flatMap(option => {
32
+ if (typeof option !== 'object' || option === null)
33
+ return [];
34
+ const effort = option;
35
+ const choice = effort['reasoningEffort'];
36
+ if (typeof choice !== 'string' || choice === '')
37
+ return [];
38
+ return [{ value: choice, label: choice, ...typeof effort['description'] === 'string' ? { description: effort['description'] } : {} }];
39
+ }) : undefined;
40
+ const defaultEffort = record['defaultReasoningEffort'];
41
+ entries.push({
42
+ value, label: typeof record['displayName'] === 'string' ? record['displayName'] : value,
43
+ source: 'native', hidden: record['hidden'] === true,
44
+ ...typeof record['description'] === 'string' ? { description: record['description'] } : {},
45
+ ...options === undefined ? {} : { reasoning: { options, ...typeof defaultEffort === 'string' ? { default: defaultEffort } : {} } },
46
+ });
47
+ }
48
+ return {
49
+ entries, ...defaultModel === undefined ? {} : { defaultModel },
50
+ ...typeof page.nextCursor === 'string' && page.nextCursor !== '' ? { nextCursor: page.nextCursor } : {},
51
+ };
52
+ }
53
+ /** Synchronous compatibility reads plus a rich, subscribable directory. */
54
+ export class CodexModelCatalog {
55
+ deps;
56
+ timeoutMs;
57
+ cache;
58
+ constructor(deps) {
59
+ this.deps = deps;
60
+ this.timeoutMs = deps.timeoutMs ?? DEFAULT_CATALOG_PROBE_TIMEOUT_MS;
61
+ this.cache = new ModelDirectoryCache({
62
+ load: (key, signal) => {
63
+ const context = JSON.parse(key);
64
+ return this.probe(context.homeDir, context.cwd, signal);
65
+ },
66
+ ttlMs: deps.ttlMs ?? DEFAULT_CATALOG_TTL_MS,
67
+ ...deps.now === undefined ? {} : { now: deps.now },
68
+ onError: error => deps.warn?.(`local-agent-codex: the model catalog probe degraded: ${error instanceof Error ? error.message : String(error)}`),
69
+ });
70
+ }
71
+ key(homeDir, cwd = homeDir) {
72
+ const files = [join(homeDir, 'config.toml'), join(homeDir, 'auth.json')];
73
+ for (let directory = cwd;; directory = dirname(directory)) {
74
+ files.push(join(directory, '.codex', 'config.toml'));
75
+ if (dirname(directory) === directory)
76
+ break;
77
+ }
78
+ return modelDirectoryContextKey({ provider: 'codex', homeDir, cwd, cli: ['codex'], files });
79
+ }
80
+ read(homeDir, cwd) {
81
+ return this.directory(homeDir, cwd).entries.filter(entry => !entry.hidden).map(entry => entry.value);
82
+ }
83
+ readDefault(homeDir, cwd) { return this.directory(homeDir, cwd).defaultModel; }
84
+ directory(homeDir, cwd) { return this.cache.read(this.key(homeDir, cwd)); }
85
+ follow(homeDir, signal, cwd) { return this.cache.follow(() => this.key(homeDir, cwd), signal); }
86
+ invalidate(homeDir, cwd) { this.cache.invalidate(this.key(homeDir, cwd)); }
87
+ dispose() { this.cache.dispose(); }
88
+ async refresh(homeDir, cwd) {
89
+ const snapshot = await this.cache.refresh(this.key(homeDir, cwd));
90
+ return snapshot.entries.filter(entry => !entry.hidden).map(entry => entry.value);
91
+ }
92
+ /**
93
+ * One bounded probe: spawn the app-server against the scoped home, answer
94
+ * the initialize handshake exactly as the live driver shapes it, ask
95
+ * `model/list`, then tear the process down (stdin EOF → grace →
96
+ * terminate). The overall timeout kills the process on expiry. Failures propagate to the shared cache, which preserves prior data.
97
+ */
98
+ async probe(homeDir, cwd, signal) {
99
+ const spec = {
100
+ argv: ['codex', 'app-server', '--stdio'],
101
+ cwd,
102
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
103
+ graceMs: DEFAULT_DISPOSE_GRACE_MS,
104
+ env: delegationEnv({ CODEX_HOME: homeDir }),
105
+ };
106
+ const child = this.deps.spawn(spec);
107
+ const decoder = new StringDecoder('utf8');
108
+ let buffer = '';
109
+ const pending = new Map();
110
+ let nextId = 0;
111
+ let done = false;
112
+ const finish = () => {
113
+ if (done)
114
+ return;
115
+ done = true;
116
+ for (const request of pending.values())
117
+ request.reject(new Error('the model catalog probe closed'));
118
+ pending.clear();
119
+ };
120
+ child.stdout?.on('data', (chunk) => {
121
+ buffer += decoder.write(chunk);
122
+ let index = buffer.indexOf('\n');
123
+ while (index >= 0) {
124
+ const line = buffer.slice(0, index);
125
+ buffer = buffer.slice(index + 1);
126
+ index = buffer.indexOf('\n');
127
+ if (line.trim() === '')
128
+ continue;
129
+ let message;
130
+ try {
131
+ message = JSON.parse(line);
132
+ }
133
+ catch {
134
+ continue;
135
+ }
136
+ const id = typeof message.id === 'number' ? message.id : undefined;
137
+ if (id !== undefined && typeof message.method === 'string') {
138
+ // A server→client request: the probe answers nothing but errors.
139
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unsupported' } }) + '\n');
140
+ continue;
141
+ }
142
+ if (id === undefined)
143
+ continue;
144
+ const request = pending.get(id);
145
+ if (request === undefined)
146
+ continue;
147
+ pending.delete(id);
148
+ const wireError = message.error;
149
+ if (wireError !== undefined && wireError !== null) {
150
+ request.reject(new Error(wireError.message ?? 'wire error'));
151
+ }
152
+ else {
153
+ request.resolve(message.result);
154
+ }
155
+ }
156
+ });
157
+ void child.done.then(finish, finish);
158
+ const abort = () => { finish(); child.terminate(); };
159
+ signal.addEventListener('abort', abort, { once: true });
160
+ if (signal.aborted)
161
+ abort();
162
+ const request = (method, params) => {
163
+ if (done)
164
+ return Promise.reject(new Error('the model catalog probe closed'));
165
+ nextId += 1;
166
+ const id = nextId;
167
+ return new Promise((resolve, reject) => {
168
+ pending.set(id, { resolve: result => resolve(result), reject });
169
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
170
+ });
171
+ };
172
+ const teardown = async () => {
173
+ finish();
174
+ child.stdin?.end();
175
+ await Promise.race([child.done.catch(() => { }), new Promise(resolve => setTimeout(resolve, PROBE_EOF_GRACE_MS))]);
176
+ child.terminate();
177
+ await child.waitForExit().catch(() => { });
178
+ };
179
+ let timer;
180
+ try {
181
+ return await Promise.race([
182
+ (async () => {
183
+ await request('initialize', {
184
+ clientInfo: { name: 'dsh-local-agent-codex', title: 'dsh local-agent codex model catalog probe', version: '0.1.0' },
185
+ capabilities: { experimentalApi: false, requestAttestation: false },
186
+ });
187
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized' }) + '\n');
188
+ const entries = new Map();
189
+ const cursors = new Set();
190
+ let cursor;
191
+ let defaultModel;
192
+ do {
193
+ const page = catalogEntries(await request('model/list', { includeHidden: true, ...cursor === undefined ? {} : { cursor } }));
194
+ for (const entry of page.entries)
195
+ if (!entries.has(entry.value))
196
+ entries.set(entry.value, entry);
197
+ defaultModel ??= page.defaultModel;
198
+ cursor = page.nextCursor;
199
+ if (cursor !== undefined) {
200
+ if (cursors.has(cursor))
201
+ throw new Error('model/list repeated a pagination cursor');
202
+ cursors.add(cursor);
203
+ }
204
+ } while (cursor !== undefined);
205
+ return { entries: [...entries.values()], complete: true, customInput: true, ...defaultModel === undefined ? {} : { defaultModel } };
206
+ })(),
207
+ new Promise((_, reject) => {
208
+ timer = setTimeout(() => reject(new Error('the model catalog probe timed out')), this.timeoutMs);
209
+ timer.unref();
210
+ }),
211
+ ]);
212
+ }
213
+ finally {
214
+ if (timer !== undefined)
215
+ clearTimeout(timer);
216
+ signal.removeEventListener('abort', abort);
217
+ await teardown();
218
+ }
219
+ }
220
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Codex scoped-home provisioning: pin file-based credential storage so the
3
+ * device-code login writes `auth.json` inside the scoped home. Codex's
4
+ * `cli_auth_credentials_store` defaults to `auto` — on macOS that resolves
5
+ * to the keychain, which would both leak credentials outside the scoped
6
+ * home and defeat the `auth.json` presence check this package's records
7
+ * adapter uses for authentication. An existing config is respected
8
+ * untouched; sign-out removes the credential file.
9
+ * @module @khorsheed/dsh-local-agent-codex/provision
10
+ */
11
+ /**
12
+ * Provision `<homeDir>/config.toml` unless one already exists. A present
13
+ * config is respected untouched (a person may have edited it); otherwise a
14
+ * minimal config pinning file-based credential storage is written so login
15
+ * lands in the scoped home's `auth.json`.
16
+ * @param homeDir - the `codex` harness's scoped home.
17
+ * @returns true when a config was written, false when one already existed.
18
+ */
19
+ export declare function provisionCodexConfig(homeDir: string): Promise<boolean>;
20
+ /**
21
+ * Read the scoped config's effective custom endpoint: the base_url of the
22
+ * `model_providers` entry named by `model_provider`, or the first custom
23
+ * provider's base_url when no selection exists (a user manually editing the
24
+ * config to route through a custom endpoint typically sets both). Only used
25
+ * for diagnostics — the config is authoritative and a user-edited value is
26
+ * respected untouched.
27
+ * @param homeDir - the `codex` harness's scoped home.
28
+ * @returns the configured custom base URL, or undefined when none is set.
29
+ */
30
+ export declare function readCodexBaseUrl(homeDir: string): Promise<string | undefined>;
31
+ /**
32
+ * Read the scoped config's effective reasoning effort: the top-level
33
+ * `model_reasoning_effort` key (codex reads it for reasoning-capable models).
34
+ * A missing or malformed config, or no key, yields undefined — the honest
35
+ * "unknown", never a guessed default. Read-only: the config is authoritative
36
+ * and a user-edited value is reported as-is. The provider passes no effort
37
+ * override on the argv, so this IS the value every round runs with.
38
+ * @param homeDir - the `codex` harness's scoped home.
39
+ * @returns the configured effort, or undefined when none is set.
40
+ */
41
+ export declare function readCodexReasoningEffort(homeDir: string): Promise<string | undefined>;
42
+ /**
43
+ * Read the scoped config's configured model: the top-level `model` key — the
44
+ * selection a plain `codex exec` round runs with. A missing or malformed
45
+ * config, or no key, yields undefined — the honest "unknown", never a guessed
46
+ * default. Read-only: the config is authoritative and a user-edited value is
47
+ * reported as-is.
48
+ * @param homeDir - the `codex` harness's scoped home.
49
+ * @returns the configured model identifier, or undefined when none is set.
50
+ */
51
+ export declare function readCodexModel(homeDir: string): Promise<string | undefined>;
52
+ /**
53
+ * Every model identifier the scoped config.toml itself names, deduped in file
54
+ * order: the top-level `model` key plus each `[profiles.<name>]` table's own
55
+ * `model` (a profile is how a codex config names a second model at all).
56
+ * `[model_providers.*]` tables are deliberately NOT collected — their keys
57
+ * name providers (endpoint definitions), not models, so offering one as a
58
+ * model choice would be a wrong suggestion, not a discovered one. Absent file
59
+ * or no model keys yields an empty list, never a hardcoded catalog entry.
60
+ * @param homeDir - the `codex` harness's scoped home.
61
+ * @returns the model identifiers the file carries.
62
+ */
63
+ export declare function listCodexConfigModels(homeDir: string): Promise<string[]>;
64
+ /**
65
+ * The credential file's modification stamp (epoch ms), undefined when absent.
66
+ * A completed device-code login rewrites `auth.json`, so the stamp
67
+ * distinguishes a fresh login from a leftover (possibly revoked) credential.
68
+ * @param homeDir - the `codex` harness's scoped home.
69
+ * @returns the marker's mtime, or undefined when no credential exists.
70
+ */
71
+ export declare function codexCredentialStamp(homeDir: string): Promise<number | undefined>;
72
+ /**
73
+ * Sign out of the scoped account: remove the credential file, so `/<name>
74
+ * status` reports not authenticated and the next login authorizes a fresh
75
+ * account. With `cli_auth_credentials_store = "file"` the credentials live
76
+ * solely in `auth.json`, so removing it is the logout path.
77
+ * @param homeDir - the `codex` harness's scoped home.
78
+ */
79
+ export declare function codexLogout(homeDir: string): Promise<void>;
80
+ //# sourceMappingURL=provision.d.ts.map
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Codex scoped-home provisioning: pin file-based credential storage so the
3
+ * device-code login writes `auth.json` inside the scoped home. Codex's
4
+ * `cli_auth_credentials_store` defaults to `auto` — on macOS that resolves
5
+ * to the keychain, which would both leak credentials outside the scoped
6
+ * home and defeat the `auth.json` presence check this package's records
7
+ * adapter uses for authentication. An existing config is respected
8
+ * untouched; sign-out removes the credential file.
9
+ * @module @khorsheed/dsh-local-agent-codex/provision
10
+ */
11
+ import { readFile, rm, stat, writeFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ /** Credential file inside the scoped home; its presence marks auth. */
14
+ const AUTH_FILE = 'auth.json';
15
+ /** Minimal scoped config: keep credentials in the scoped home. */
16
+ const SCOPED_CONFIG = `# Written by dsh-local-agent-codex: keep the device-code
17
+ # credentials inside the scoped home (auth.json) instead of the OS keychain.
18
+ cli_auth_credentials_store = "file"
19
+ `;
20
+ /**
21
+ * Provision `<homeDir>/config.toml` unless one already exists. A present
22
+ * config is respected untouched (a person may have edited it); otherwise a
23
+ * minimal config pinning file-based credential storage is written so login
24
+ * lands in the scoped home's `auth.json`.
25
+ * @param homeDir - the `codex` harness's scoped home.
26
+ * @returns true when a config was written, false when one already existed.
27
+ */
28
+ export async function provisionCodexConfig(homeDir) {
29
+ try {
30
+ await readFile(join(homeDir, 'config.toml'));
31
+ return false;
32
+ }
33
+ catch (error) {
34
+ // No config yet: fall through to provisioning.
35
+ if (error.code !== 'ENOENT')
36
+ throw error;
37
+ }
38
+ // `wx` refuses to overwrite a config that appeared since the read check.
39
+ await writeFile(join(homeDir, 'config.toml'), SCOPED_CONFIG, { flag: 'wx' });
40
+ return true;
41
+ }
42
+ /**
43
+ * Read the scoped config's effective custom endpoint: the base_url of the
44
+ * `model_providers` entry named by `model_provider`, or the first custom
45
+ * provider's base_url when no selection exists (a user manually editing the
46
+ * config to route through a custom endpoint typically sets both). Only used
47
+ * for diagnostics — the config is authoritative and a user-edited value is
48
+ * respected untouched.
49
+ * @param homeDir - the `codex` harness's scoped home.
50
+ * @returns the configured custom base URL, or undefined when none is set.
51
+ */
52
+ export async function readCodexBaseUrl(homeDir) {
53
+ let text;
54
+ try {
55
+ text = await readFile(join(homeDir, 'config.toml'), 'utf8');
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ // Section-aware line scan: key order inside a provider table is the
61
+ // writer's choice, so a base_url that does not immediately follow its
62
+ // header must still be found — a diagnostic that silently reports the
63
+ // default would mislead. model_provider is a top-level key, so it only
64
+ // counts before the first table header.
65
+ let section = '';
66
+ let selected;
67
+ const providers = new Map();
68
+ for (const rawLine of text.split('\n')) {
69
+ const line = rawLine.trim();
70
+ const header = /^\[+([^\]]+)\]+$/.exec(line);
71
+ if (header !== null) {
72
+ section = header[1];
73
+ continue;
74
+ }
75
+ const key = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
76
+ if (key === null)
77
+ continue;
78
+ if (section === '' && key[1] === 'model_provider')
79
+ selected = key[2];
80
+ const provider = /^model_providers\.(".*"|[\w-]+)$/.exec(section);
81
+ if (provider !== null && key[1] === 'base_url') {
82
+ providers.set(provider[1].replace(/^"(.*)"$/, '$1'), key[2]);
83
+ }
84
+ }
85
+ if (selected !== undefined && providers.has(selected))
86
+ return providers.get(selected);
87
+ // No selection: any single custom provider is the endpoint in effect.
88
+ if (providers.size === 1)
89
+ return [...providers.values()][0];
90
+ return undefined;
91
+ }
92
+ /**
93
+ * Read the scoped config's effective reasoning effort: the top-level
94
+ * `model_reasoning_effort` key (codex reads it for reasoning-capable models).
95
+ * A missing or malformed config, or no key, yields undefined — the honest
96
+ * "unknown", never a guessed default. Read-only: the config is authoritative
97
+ * and a user-edited value is reported as-is. The provider passes no effort
98
+ * override on the argv, so this IS the value every round runs with.
99
+ * @param homeDir - the `codex` harness's scoped home.
100
+ * @returns the configured effort, or undefined when none is set.
101
+ */
102
+ export async function readCodexReasoningEffort(homeDir) {
103
+ return readTopLevelConfigString(homeDir, 'model_reasoning_effort');
104
+ }
105
+ /**
106
+ * Read the scoped config's configured model: the top-level `model` key — the
107
+ * selection a plain `codex exec` round runs with. A missing or malformed
108
+ * config, or no key, yields undefined — the honest "unknown", never a guessed
109
+ * default. Read-only: the config is authoritative and a user-edited value is
110
+ * reported as-is.
111
+ * @param homeDir - the `codex` harness's scoped home.
112
+ * @returns the configured model identifier, or undefined when none is set.
113
+ */
114
+ export async function readCodexModel(homeDir) {
115
+ return readTopLevelConfigString(homeDir, 'model');
116
+ }
117
+ /**
118
+ * One top-level string key from the scoped config.toml: counts only before
119
+ * the first table header — the same rule the model_provider scan applies.
120
+ * Absent file or absent key yields undefined, never a guessed default.
121
+ */
122
+ async function readTopLevelConfigString(homeDir, key) {
123
+ let text;
124
+ try {
125
+ text = await readFile(join(homeDir, 'config.toml'), 'utf8');
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ let section = '';
131
+ for (const rawLine of text.split('\n')) {
132
+ const line = rawLine.trim();
133
+ const header = /^\[+([^\]]+)\]+$/.exec(line);
134
+ if (header !== null) {
135
+ section = header[1];
136
+ continue;
137
+ }
138
+ if (section !== '')
139
+ continue;
140
+ const match = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
141
+ if (match !== null && match[1] === key)
142
+ return match[2];
143
+ }
144
+ return undefined;
145
+ }
146
+ /**
147
+ * Every model identifier the scoped config.toml itself names, deduped in file
148
+ * order: the top-level `model` key plus each `[profiles.<name>]` table's own
149
+ * `model` (a profile is how a codex config names a second model at all).
150
+ * `[model_providers.*]` tables are deliberately NOT collected — their keys
151
+ * name providers (endpoint definitions), not models, so offering one as a
152
+ * model choice would be a wrong suggestion, not a discovered one. Absent file
153
+ * or no model keys yields an empty list, never a hardcoded catalog entry.
154
+ * @param homeDir - the `codex` harness's scoped home.
155
+ * @returns the model identifiers the file carries.
156
+ */
157
+ export async function listCodexConfigModels(homeDir) {
158
+ let text;
159
+ try {
160
+ text = await readFile(join(homeDir, 'config.toml'), 'utf8');
161
+ }
162
+ catch {
163
+ return [];
164
+ }
165
+ const found = [];
166
+ const seen = new Set();
167
+ const collect = (value) => {
168
+ if (value === undefined || value.trim() === '' || seen.has(value))
169
+ return;
170
+ seen.add(value);
171
+ found.push(value);
172
+ };
173
+ let section = '';
174
+ for (const rawLine of text.split('\n')) {
175
+ const line = rawLine.trim();
176
+ const header = /^\[+([^\]]+)\]+$/.exec(line);
177
+ if (header !== null) {
178
+ section = header[1];
179
+ continue;
180
+ }
181
+ const match = /^(\w+)\s*=\s*"([^"]*)"$/.exec(line);
182
+ if (match === null || match[1] !== 'model')
183
+ continue;
184
+ if (section === '' || section.startsWith('profiles.'))
185
+ collect(match[2]);
186
+ }
187
+ return found;
188
+ }
189
+ /**
190
+ * The credential file's modification stamp (epoch ms), undefined when absent.
191
+ * A completed device-code login rewrites `auth.json`, so the stamp
192
+ * distinguishes a fresh login from a leftover (possibly revoked) credential.
193
+ * @param homeDir - the `codex` harness's scoped home.
194
+ * @returns the marker's mtime, or undefined when no credential exists.
195
+ */
196
+ export async function codexCredentialStamp(homeDir) {
197
+ try {
198
+ return (await stat(join(homeDir, AUTH_FILE))).mtimeMs;
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ }
204
+ /**
205
+ * Sign out of the scoped account: remove the credential file, so `/<name>
206
+ * status` reports not authenticated and the next login authorizes a fresh
207
+ * account. With `cli_auth_credentials_store = "file"` the credentials live
208
+ * solely in `auth.json`, so removing it is the logout path.
209
+ * @param homeDir - the `codex` harness's scoped home.
210
+ */
211
+ export async function codexLogout(homeDir) {
212
+ await rm(join(homeDir, AUTH_FILE), { force: true });
213
+ }
@@ -0,0 +1,120 @@
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 type { TokenUsage } from '@deepseek-ai/dsh-llm';
15
+ import type { LocalAgentSessionRecord } from '@khorsheed/dsh-local-agent';
16
+ /**
17
+ * Whether the scoped home holds usable Codex credentials: the `auth.json`
18
+ * file exists. Absent means the device-code flow has not completed.
19
+ * @param homeDir - the `codex` harness's scoped home.
20
+ * @returns true when credentials are present.
21
+ */
22
+ export declare function codexAuthenticated(homeDir: string): Promise<boolean>;
23
+ /**
24
+ * Parse the first line of a rollout file for the listing fields. Unknown or
25
+ * malformed values yield an undefined record rather than failing the list.
26
+ * @param text - the raw rollout file content (only the head is read).
27
+ * @returns the record, or undefined when the head is not a session_meta line.
28
+ */
29
+ export declare function rolloutRecord(text: string): LocalAgentSessionRecord | undefined;
30
+ /**
31
+ * Read the scoped home's rollout files. The listing walks
32
+ * `sessions/YYYY/MM/DD/` and reads each `rollout-*.jsonl` head; a torn or
33
+ * malformed file is skipped rather than failing the whole list.
34
+ * @param homeDir - the `codex` harness's scoped home.
35
+ * @returns the parsed records, empty when no sessions exist yet.
36
+ */
37
+ export declare function listCodexSessions(homeDir: string): Promise<readonly LocalAgentSessionRecord[]>;
38
+ /**
39
+ * Map codex's token-count payload onto the shared usage contract — the
40
+ * SINGLE caliber for both the exec `turn.completed` usage and the rollout
41
+ * `token_count` recovery, so the two sources can never drift. Codex's
42
+ * `input_tokens` is the TOTAL input including cache hits (OpenAI-style
43
+ * accounting, confirmed against `total_tokens` in the rollout token_count),
44
+ * and `cached_input_tokens` is the cache-read subset — so the uncached
45
+ * bucket subtracts the cached portion to avoid double counting. There is no
46
+ * cache-write concept, so that bucket is omitted (the shared four-bucket
47
+ * contract: uncached input, output, cache read, cache write — codex fills
48
+ * the first three).
49
+ * @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
50
+ * rollout `token_count` `info.last_token_usage`/`total_token_usage`).
51
+ * @returns the shared usage record.
52
+ */
53
+ export declare function usageFromCodex(usage: unknown): TokenUsage;
54
+ /**
55
+ * Scan rollout-file text for the LAST `token_count` event and map its usage
56
+ * onto the shared contract. A token_count entry rides an
57
+ * `{"type":"event_msg","payload":{"type":"token_count","info":{…}}}` line
58
+ * (verified against codex-cli 0.144.0 rollout files); the per-turn
59
+ * `info.last_token_usage` is the same caliber as `turn.completed`'s usage,
60
+ * with the session-wide `total_token_usage` as fallback. Torn or malformed
61
+ * lines are skipped; an absent event yields undefined.
62
+ * @param text - raw rollout file content (the tail scan reads a bounded suffix).
63
+ * @returns the mapped usage of the last token_count event, or undefined.
64
+ */
65
+ export declare function codexRolloutTokenUsage(text: string): TokenUsage | undefined;
66
+ /**
67
+ * The codex build that wrote one rollout file, from its `session_meta` head
68
+ * (`payload.cli_version`, present since codex 0.14x). This is the AUTHORITY
69
+ * for the version a delegation round actually ran: it is codex's own record of
70
+ * itself, written by the process that served the round, so it cannot drift
71
+ * from the binary the way a later `--version` probe can.
72
+ * @param text - the raw rollout head.
73
+ * @returns the version, or undefined when the head names none.
74
+ */
75
+ export declare function rolloutCliVersion(text: string): string | undefined;
76
+ /** Locator for one run's rollout file, resolved by thread id, cwd, and a time window. */
77
+ export interface CodexRolloutLocator {
78
+ /**
79
+ * The thread id observed from the run's NDJSON stream (`thread.started`);
80
+ * the primary locator — matches the session_meta head id.
81
+ */
82
+ readonly threadId?: string | undefined;
83
+ /**
84
+ * Epoch-ms run start (the spawn moment); the time-window fallback centers
85
+ * on it when no thread id matched (e.g. a kill truncated the stream before
86
+ * `thread.started`).
87
+ */
88
+ readonly windowStart?: number | undefined;
89
+ /**
90
+ * The directory the run's CLI process was spawned in. Only the time-window
91
+ * fallback uses it, and there it is decisive: concurrent delegations put
92
+ * several runs' files inside one window, and the head's `cwd` is what tells
93
+ * them apart (an orchestrator gives every cell its own directory).
94
+ */
95
+ readonly cwd?: string | undefined;
96
+ }
97
+ /** One delegation round's facts, read back from the run's own rollout file. */
98
+ export interface CodexRoundFacts {
99
+ /** The round's last `token_count` usage, in the shared caliber. */
100
+ readonly usage?: TokenUsage;
101
+ /** The model the round's turn ran with (`turn_context.model`). */
102
+ readonly model?: string;
103
+ /** The codex build that served the round (`session_meta.cli_version`). */
104
+ readonly cliVersion?: string;
105
+ }
106
+ /**
107
+ * Read ONE run's facts back from its rollout file: the token usage a
108
+ * non-completed round never streamed, the model the exec `--json` wire does
109
+ * not carry at all (codex 0.144.0), and the codex build that served it. One
110
+ * locate and one file open serve all three.
111
+ *
112
+ * Every field is absent on a miss — no rollout file, an unreadable home, a
113
+ * hard kill before any turn boundary, a window with no turn_context. Absence
114
+ * is recorded, never guessed.
115
+ * @param homeDir - the `codex` harness's scoped home.
116
+ * @param locator - the thread id, working directory, and/or start-time window.
117
+ * @returns the round's read-back facts.
118
+ */
119
+ export declare function codexRolloutRoundFacts(homeDir: string, locator: CodexRolloutLocator): Promise<CodexRoundFacts>;
120
+ //# sourceMappingURL=records.d.ts.map