starling-ai 0.0.1
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/dist/index.js +4674 -0
- package/package.json +41 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4674 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command7 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/session.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import chalk2 from "chalk";
|
|
9
|
+
import { spawn, spawnSync } from "child_process";
|
|
10
|
+
import { existsSync as existsSync3, unlinkSync as unlinkSync3 } from "fs";
|
|
11
|
+
|
|
12
|
+
// src/lib/discovery.ts
|
|
13
|
+
import { readdirSync, statSync } from "fs";
|
|
14
|
+
import { join as join2 } from "path";
|
|
15
|
+
|
|
16
|
+
// src/constants.ts
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
var DEFAULT_CONFIG_DIR = join(homedir(), ".config", "starling");
|
|
20
|
+
var DEFAULT_STORE_PATH = join(DEFAULT_CONFIG_DIR, "store.json");
|
|
21
|
+
var STORE_VERSION = 1;
|
|
22
|
+
var DEFAULT_STARLING_HOME = join(homedir(), ".starling");
|
|
23
|
+
var DEFAULT_STARLING_SETTINGS_DIR = join(DEFAULT_STARLING_HOME, "settings");
|
|
24
|
+
var DEFAULT_CLAUDE_SETTINGS_DIR = join(DEFAULT_STARLING_SETTINGS_DIR, "claude");
|
|
25
|
+
var DEFAULT_CODEX_SETTINGS_DIR = join(DEFAULT_STARLING_SETTINGS_DIR, "codex");
|
|
26
|
+
var DEFAULT_CODEX_HOME = join(homedir(), ".codex");
|
|
27
|
+
var CLAUDE_SESSIONS_DIR = join(homedir(), ".claude", "projects");
|
|
28
|
+
var CODEX_SESSIONS_DIR = join(homedir(), ".codex", "sessions");
|
|
29
|
+
var ENV_CONFIG_KEY = "STARLING_CONFIG";
|
|
30
|
+
|
|
31
|
+
// src/lib/session.ts
|
|
32
|
+
import { createReadStream } from "fs";
|
|
33
|
+
import { createInterface } from "readline";
|
|
34
|
+
function isRecord(value) {
|
|
35
|
+
return typeof value === "object" && value !== null;
|
|
36
|
+
}
|
|
37
|
+
function asNumber(value) {
|
|
38
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
39
|
+
if (typeof value === "string") {
|
|
40
|
+
const n = Number(value);
|
|
41
|
+
return Number.isFinite(n) ? n : void 0;
|
|
42
|
+
}
|
|
43
|
+
return void 0;
|
|
44
|
+
}
|
|
45
|
+
function mergeTokenUsage(target, source) {
|
|
46
|
+
if (typeof source.input_tokens === "number") {
|
|
47
|
+
target.input_tokens = source.input_tokens;
|
|
48
|
+
}
|
|
49
|
+
if (typeof source.output_tokens === "number") {
|
|
50
|
+
target.output_tokens = source.output_tokens;
|
|
51
|
+
}
|
|
52
|
+
if (typeof source.total_tokens === "number") {
|
|
53
|
+
target.total_tokens = source.total_tokens;
|
|
54
|
+
}
|
|
55
|
+
if (typeof source.cache_tokens === "number") {
|
|
56
|
+
target.cache_tokens = source.cache_tokens;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function normalizeCacheTokens(raw) {
|
|
60
|
+
const direct = asNumber(raw.cache_tokens) ?? asNumber(raw.cacheTokens);
|
|
61
|
+
if (typeof direct === "number") return direct;
|
|
62
|
+
const fromCreation = asNumber(raw.cache_creation_input_tokens) ?? asNumber(raw.cacheCreationInputTokens);
|
|
63
|
+
const fromRead = asNumber(raw.cache_read_input_tokens) ?? asNumber(raw.cacheReadInputTokens);
|
|
64
|
+
if (typeof fromCreation === "number" || typeof fromRead === "number") {
|
|
65
|
+
return (fromCreation ?? 0) + (fromRead ?? 0);
|
|
66
|
+
}
|
|
67
|
+
return void 0;
|
|
68
|
+
}
|
|
69
|
+
function extractTokenUsageFromValue(value, depth = 0) {
|
|
70
|
+
if (depth > 16) return null;
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
const usage2 = {};
|
|
73
|
+
let found = false;
|
|
74
|
+
for (const item of value) {
|
|
75
|
+
const nestedUsage = extractTokenUsageFromValue(item, depth + 1);
|
|
76
|
+
if (nestedUsage) {
|
|
77
|
+
mergeTokenUsage(usage2, nestedUsage);
|
|
78
|
+
found = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return found ? usage2 : null;
|
|
82
|
+
}
|
|
83
|
+
if (!isRecord(value)) return null;
|
|
84
|
+
const input = asNumber(value.input_tokens) ?? asNumber(value.inputTokens) ?? asNumber(value.prompt_tokens) ?? asNumber(value.promptTokens);
|
|
85
|
+
const output = asNumber(value.output_tokens) ?? asNumber(value.outputTokens) ?? asNumber(value.completion_tokens) ?? asNumber(value.completionTokens);
|
|
86
|
+
const total = asNumber(value.total_tokens) ?? asNumber(value.totalTokens) ?? (typeof input === "number" && typeof output === "number" ? input + output : void 0);
|
|
87
|
+
const cache = normalizeCacheTokens(value);
|
|
88
|
+
const usage = {};
|
|
89
|
+
if (typeof input === "number") usage.input_tokens = input;
|
|
90
|
+
if (typeof output === "number") usage.output_tokens = output;
|
|
91
|
+
if (typeof total === "number") usage.total_tokens = total;
|
|
92
|
+
if (typeof cache === "number") usage.cache_tokens = cache;
|
|
93
|
+
const nestedValues = Object.values(value);
|
|
94
|
+
for (const candidate of nestedValues) {
|
|
95
|
+
const nestedUsage = extractTokenUsageFromValue(candidate);
|
|
96
|
+
if (nestedUsage) {
|
|
97
|
+
mergeTokenUsage(usage, nestedUsage);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (usage.input_tokens === void 0 && usage.output_tokens === void 0 && usage.total_tokens === void 0 && usage.cache_tokens === void 0) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return usage;
|
|
104
|
+
}
|
|
105
|
+
function extractTokenUsage(entry) {
|
|
106
|
+
return extractTokenUsageFromValue(entry);
|
|
107
|
+
}
|
|
108
|
+
async function parseJsonlHead(filePath, maxLines = 500) {
|
|
109
|
+
const entries = [];
|
|
110
|
+
const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
|
|
111
|
+
let count = 0;
|
|
112
|
+
for await (const line of rl) {
|
|
113
|
+
if (!line.trim()) continue;
|
|
114
|
+
try {
|
|
115
|
+
entries.push(JSON.parse(line));
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
count++;
|
|
119
|
+
if (count >= maxLines) break;
|
|
120
|
+
}
|
|
121
|
+
return entries;
|
|
122
|
+
}
|
|
123
|
+
function extractClaudeSessionMeta(entries, filePath, modifiedAt) {
|
|
124
|
+
let sessionId = "";
|
|
125
|
+
let model = "";
|
|
126
|
+
let projectPath = "";
|
|
127
|
+
let firstPrompt = "";
|
|
128
|
+
const tokenUsage = {};
|
|
129
|
+
let hasTokenUsage = false;
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (entry.sessionId && typeof entry.sessionId === "string" && !sessionId) {
|
|
132
|
+
sessionId = entry.sessionId;
|
|
133
|
+
}
|
|
134
|
+
if (!model) {
|
|
135
|
+
let candidate = "";
|
|
136
|
+
if (entry.model && typeof entry.model === "string") {
|
|
137
|
+
candidate = entry.model;
|
|
138
|
+
} else if (entry.message && typeof entry.message === "object") {
|
|
139
|
+
const msgModel = entry.message.model;
|
|
140
|
+
if (msgModel && typeof msgModel === "string") candidate = msgModel;
|
|
141
|
+
}
|
|
142
|
+
if (candidate && !candidate.startsWith("<") && candidate !== "synthetic") {
|
|
143
|
+
model = candidate;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (entry.cwd && typeof entry.cwd === "string" && !projectPath) {
|
|
147
|
+
projectPath = entry.cwd;
|
|
148
|
+
}
|
|
149
|
+
if ((entry.type === "user" || entry.type === "human") && entry.message && typeof entry.message === "object") {
|
|
150
|
+
const msg = entry.message;
|
|
151
|
+
if (!firstPrompt && msg.content) {
|
|
152
|
+
if (typeof msg.content === "string") {
|
|
153
|
+
firstPrompt = msg.content;
|
|
154
|
+
} else if (Array.isArray(msg.content)) {
|
|
155
|
+
for (const part of msg.content) {
|
|
156
|
+
if (typeof part === "object" && part !== null && "text" in part && typeof part.text === "string") {
|
|
157
|
+
firstPrompt = part.text;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const entryUsage = extractTokenUsage(entry);
|
|
165
|
+
if (entryUsage) {
|
|
166
|
+
mergeTokenUsage(tokenUsage, entryUsage);
|
|
167
|
+
hasTokenUsage = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!sessionId) {
|
|
171
|
+
const parts = filePath.split("/");
|
|
172
|
+
const filename = parts[parts.length - 1].replace(".jsonl", "");
|
|
173
|
+
sessionId = filename;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
session_id: sessionId,
|
|
177
|
+
provider: "claude",
|
|
178
|
+
model,
|
|
179
|
+
project_path: projectPath,
|
|
180
|
+
first_prompt: firstPrompt.slice(0, 200),
|
|
181
|
+
file_path: filePath,
|
|
182
|
+
created_at: modifiedAt,
|
|
183
|
+
modified_at: modifiedAt,
|
|
184
|
+
...hasTokenUsage ? { token_usage: tokenUsage } : {}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function extractCodexSessionMeta(entries, filePath, modifiedAt) {
|
|
188
|
+
let sessionId = "";
|
|
189
|
+
let model = "";
|
|
190
|
+
let projectPath = "";
|
|
191
|
+
let firstPrompt = "";
|
|
192
|
+
const tokenUsage = {};
|
|
193
|
+
let hasTokenUsage = false;
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (entry.type === "session_meta" && entry.payload && typeof entry.payload === "object") {
|
|
196
|
+
const p = entry.payload;
|
|
197
|
+
if (p.id && !sessionId) sessionId = p.id;
|
|
198
|
+
if (p.cwd && !projectPath) projectPath = p.cwd;
|
|
199
|
+
if (p.model_provider && !model) model = p.model_provider;
|
|
200
|
+
}
|
|
201
|
+
if (entry.type === "event_msg" && entry.payload && typeof entry.payload === "object") {
|
|
202
|
+
const p = entry.payload;
|
|
203
|
+
if (p.type === "user_message" && p.content && !firstPrompt) {
|
|
204
|
+
firstPrompt = p.content;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (entry.type === "turn_context" && entry.payload && typeof entry.payload === "object") {
|
|
208
|
+
const p = entry.payload;
|
|
209
|
+
if (p.model && model === "openai") model = p.model;
|
|
210
|
+
}
|
|
211
|
+
const entryUsage = extractTokenUsage(entry);
|
|
212
|
+
if (entryUsage) {
|
|
213
|
+
mergeTokenUsage(tokenUsage, entryUsage);
|
|
214
|
+
hasTokenUsage = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!sessionId) {
|
|
218
|
+
const parts = filePath.split("/");
|
|
219
|
+
const filename = parts[parts.length - 1].replace(".jsonl", "");
|
|
220
|
+
sessionId = filename;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
session_id: sessionId,
|
|
224
|
+
provider: "codex",
|
|
225
|
+
model,
|
|
226
|
+
project_path: projectPath,
|
|
227
|
+
first_prompt: firstPrompt.slice(0, 200),
|
|
228
|
+
file_path: filePath,
|
|
229
|
+
created_at: modifiedAt,
|
|
230
|
+
modified_at: modifiedAt,
|
|
231
|
+
...hasTokenUsage ? { token_usage: tokenUsage } : {}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/lib/discovery.ts
|
|
236
|
+
function collectJsonlFilesSorted(dir, limit) {
|
|
237
|
+
let entries;
|
|
238
|
+
try {
|
|
239
|
+
entries = readdirSync(dir);
|
|
240
|
+
} catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const children = entries.map((name) => {
|
|
244
|
+
const full = join2(dir, name);
|
|
245
|
+
try {
|
|
246
|
+
return { name, full, st: statSync(full) };
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}).filter((x) => x !== null);
|
|
251
|
+
children.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
|
|
252
|
+
const results = [];
|
|
253
|
+
for (const child of children) {
|
|
254
|
+
if (results.length >= limit * 3) break;
|
|
255
|
+
if (child.st.isDirectory()) {
|
|
256
|
+
if (child.name === "subagents") continue;
|
|
257
|
+
const nested = collectJsonlFilesSorted(child.full, limit);
|
|
258
|
+
results.push(...nested);
|
|
259
|
+
} else if (child.name.endsWith(".jsonl")) {
|
|
260
|
+
results.push({ path: child.full, mtime: child.st.mtimeMs });
|
|
261
|
+
}
|
|
262
|
+
results.sort((a, b) => b.mtime - a.mtime);
|
|
263
|
+
}
|
|
264
|
+
return results.slice(0, limit * 3);
|
|
265
|
+
}
|
|
266
|
+
var PROVIDER_DIRS = [
|
|
267
|
+
["claude", CLAUDE_SESSIONS_DIR],
|
|
268
|
+
["codex", CODEX_SESSIONS_DIR]
|
|
269
|
+
];
|
|
270
|
+
async function findSessions(limit = 50, providerFilter) {
|
|
271
|
+
const results = [];
|
|
272
|
+
for await (const meta of streamSessions(providerFilter, limit)) {
|
|
273
|
+
results.push(meta);
|
|
274
|
+
if (results.length >= limit) break;
|
|
275
|
+
}
|
|
276
|
+
return results;
|
|
277
|
+
}
|
|
278
|
+
async function* streamSessions(providerFilter, collectLimit = Infinity) {
|
|
279
|
+
const allFiles = [];
|
|
280
|
+
for (const [provider, dir] of PROVIDER_DIRS) {
|
|
281
|
+
if (providerFilter && provider !== providerFilter) continue;
|
|
282
|
+
const files = collectJsonlFilesSorted(dir, collectLimit);
|
|
283
|
+
for (const f of files) allFiles.push({ ...f, provider });
|
|
284
|
+
}
|
|
285
|
+
allFiles.sort((a, b) => b.mtime - a.mtime);
|
|
286
|
+
for (const file of allFiles) {
|
|
287
|
+
try {
|
|
288
|
+
const modifiedAt = new Date(file.mtime).toISOString();
|
|
289
|
+
const entries = await parseJsonlHead(file.path);
|
|
290
|
+
const extract = file.provider === "codex" ? extractCodexSessionMeta : extractClaudeSessionMeta;
|
|
291
|
+
const meta = extract(entries, file.path, modifiedAt);
|
|
292
|
+
if (meta) yield meta;
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function matchSessionId(candidate, sessionId) {
|
|
298
|
+
if (!candidate || !sessionId) return false;
|
|
299
|
+
const normalizedCandidate = candidate.toLowerCase();
|
|
300
|
+
const normalizedSessionId = sessionId.toLowerCase();
|
|
301
|
+
return normalizedCandidate === normalizedSessionId || normalizedCandidate.startsWith(normalizedSessionId) || normalizedCandidate.includes(normalizedSessionId) || normalizedSessionId.startsWith(normalizedCandidate);
|
|
302
|
+
}
|
|
303
|
+
function collectSessionFilesForId(dir, sessionId, accumulator) {
|
|
304
|
+
if (accumulator.length > 5e3) return;
|
|
305
|
+
let entries;
|
|
306
|
+
try {
|
|
307
|
+
entries = readdirSync(dir);
|
|
308
|
+
} catch {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (entry === "subagents") continue;
|
|
313
|
+
const full = join2(dir, entry);
|
|
314
|
+
let st;
|
|
315
|
+
try {
|
|
316
|
+
st = statSync(full);
|
|
317
|
+
} catch {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (st.isDirectory()) {
|
|
321
|
+
collectSessionFilesForId(full, sessionId, accumulator);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
325
|
+
if (!entry.toLowerCase().includes(sessionId.toLowerCase())) continue;
|
|
326
|
+
accumulator.push(full);
|
|
327
|
+
if (accumulator.length > 5e3) return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async function collectSessionCandidatesByFilename(sessionId) {
|
|
331
|
+
const matches = /* @__PURE__ */ new Map();
|
|
332
|
+
const normalizedId = sessionId.toLowerCase();
|
|
333
|
+
const matchedFiles = [];
|
|
334
|
+
for (const [, dir] of PROVIDER_DIRS) {
|
|
335
|
+
collectSessionFilesForId(dir, normalizedId, matchedFiles);
|
|
336
|
+
}
|
|
337
|
+
for (const filePath of matchedFiles) {
|
|
338
|
+
try {
|
|
339
|
+
const fileName = filePath.split("/").pop() ?? "";
|
|
340
|
+
let provider = "claude";
|
|
341
|
+
if (filePath.includes(CODEX_SESSIONS_DIR)) {
|
|
342
|
+
provider = "codex";
|
|
343
|
+
}
|
|
344
|
+
const st = statSync(filePath);
|
|
345
|
+
const modifiedAt = new Date(st.mtimeMs).toISOString();
|
|
346
|
+
const entries = await parseJsonlHead(filePath);
|
|
347
|
+
const extract = provider === "codex" ? extractCodexSessionMeta : extractClaudeSessionMeta;
|
|
348
|
+
const meta = extract(entries, filePath, modifiedAt);
|
|
349
|
+
if (!meta) continue;
|
|
350
|
+
const byId = meta.session_id.toLowerCase();
|
|
351
|
+
if (matchSessionId(byId, normalizedId)) {
|
|
352
|
+
const existing = matches.get(meta.session_id);
|
|
353
|
+
if (!existing || meta.modified_at > existing.modified_at) {
|
|
354
|
+
matches.set(meta.session_id, meta);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (matches.size > 0) {
|
|
362
|
+
return [...matches.values()].sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
363
|
+
}
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
async function findSessionCandidates(sessionId) {
|
|
367
|
+
const filenameMatches = await collectSessionCandidatesByFilename(sessionId);
|
|
368
|
+
if (filenameMatches.length > 0) {
|
|
369
|
+
return filenameMatches;
|
|
370
|
+
}
|
|
371
|
+
const matches = /* @__PURE__ */ new Map();
|
|
372
|
+
const fallbackLimit = 2500;
|
|
373
|
+
for await (const meta of streamSessions(void 0, fallbackLimit)) {
|
|
374
|
+
if (!matchSessionId(meta.session_id, sessionId)) continue;
|
|
375
|
+
const existing = matches.get(meta.session_id);
|
|
376
|
+
if (!existing || meta.modified_at > existing.modified_at) {
|
|
377
|
+
matches.set(meta.session_id, meta);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return [...matches.values()].sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
381
|
+
}
|
|
382
|
+
async function findSessionById(sessionId) {
|
|
383
|
+
const matches = await findSessionCandidates(sessionId);
|
|
384
|
+
return matches.find((m) => m.session_id === sessionId) ?? matches[0] ?? null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/lib/format.ts
|
|
388
|
+
import chalk from "chalk";
|
|
389
|
+
import Table from "cli-table3";
|
|
390
|
+
|
|
391
|
+
// src/lib/sessionDisplay.ts
|
|
392
|
+
var SHORT_SESSION_ID_LENGTH = 13;
|
|
393
|
+
function shortSessionId(sessionId) {
|
|
394
|
+
return sessionId.slice(0, SHORT_SESSION_ID_LENGTH);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/lib/format.ts
|
|
398
|
+
function formatSessionTable(sessions) {
|
|
399
|
+
const formatToken = (value) => {
|
|
400
|
+
return value === void 0 ? "-" : String(value);
|
|
401
|
+
};
|
|
402
|
+
const table = new Table({
|
|
403
|
+
head: [
|
|
404
|
+
chalk.cyan("Session ID"),
|
|
405
|
+
chalk.cyan("Agent"),
|
|
406
|
+
chalk.cyan("Model"),
|
|
407
|
+
chalk.cyan("Project"),
|
|
408
|
+
chalk.cyan("Modified"),
|
|
409
|
+
chalk.cyan("Input"),
|
|
410
|
+
chalk.cyan("Output"),
|
|
411
|
+
chalk.cyan("Total"),
|
|
412
|
+
chalk.cyan("Cache")
|
|
413
|
+
],
|
|
414
|
+
colWidths: [15, 8, 16, 30, 20, 10, 10, 10, 10],
|
|
415
|
+
style: { head: [] }
|
|
416
|
+
});
|
|
417
|
+
for (const s of sessions) {
|
|
418
|
+
const shortId = shortSessionId(s.session_id);
|
|
419
|
+
const agent = s.provider === "codex" ? "codex" : "claude";
|
|
420
|
+
const shortProject = s.project_path ? s.project_path.length > 36 ? "\u2026" + s.project_path.slice(-35) : s.project_path : "-";
|
|
421
|
+
const shortDate = s.modified_at.slice(0, 19).replace("T", " ");
|
|
422
|
+
table.push([
|
|
423
|
+
shortId,
|
|
424
|
+
agent,
|
|
425
|
+
s.model || "-",
|
|
426
|
+
shortProject,
|
|
427
|
+
shortDate,
|
|
428
|
+
formatToken(s.token_usage?.input_tokens),
|
|
429
|
+
formatToken(s.token_usage?.output_tokens),
|
|
430
|
+
formatToken(s.token_usage?.total_tokens),
|
|
431
|
+
formatToken(s.token_usage?.cache_tokens)
|
|
432
|
+
]);
|
|
433
|
+
}
|
|
434
|
+
return table.toString();
|
|
435
|
+
}
|
|
436
|
+
function formatSpaceTree(spaces, bookmarks) {
|
|
437
|
+
if (spaces.length === 0) return chalk.yellow("No catalogs created yet.");
|
|
438
|
+
const childrenMap = /* @__PURE__ */ new Map();
|
|
439
|
+
for (const s of spaces) {
|
|
440
|
+
const parent = s.parent_id ?? null;
|
|
441
|
+
if (!childrenMap.has(parent)) childrenMap.set(parent, []);
|
|
442
|
+
childrenMap.get(parent).push(s);
|
|
443
|
+
}
|
|
444
|
+
const bookmarkBySpace = /* @__PURE__ */ new Map();
|
|
445
|
+
for (const b of bookmarks) {
|
|
446
|
+
for (const sid of b.space_ids) {
|
|
447
|
+
if (!bookmarkBySpace.has(sid)) bookmarkBySpace.set(sid, []);
|
|
448
|
+
bookmarkBySpace.get(sid).push(b);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function renderNode(space, prefix, isLast) {
|
|
452
|
+
const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
|
453
|
+
const lines2 = [];
|
|
454
|
+
const tagStr = space.tags.length > 0 ? chalk.gray(` [${space.tags.join(", ")}]`) : "";
|
|
455
|
+
lines2.push(`${prefix}${connector}${chalk.bold(space.name)}${tagStr}`);
|
|
456
|
+
const childPrefix = prefix + (isLast ? " " : "\u2502 ");
|
|
457
|
+
const bk = bookmarkBySpace.get(space.id) || [];
|
|
458
|
+
for (let i = 0; i < bk.length; i++) {
|
|
459
|
+
const bIsLast = i === bk.length - 1 && !childrenMap.has(space.id);
|
|
460
|
+
const bConn = bIsLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
|
461
|
+
lines2.push(`${childPrefix}${bConn}${chalk.cyan(bk[i].title)} ${chalk.gray(`[${bk[i].session_id}]`)}`);
|
|
462
|
+
}
|
|
463
|
+
const children = childrenMap.get(space.id) || [];
|
|
464
|
+
for (let i = 0; i < children.length; i++) {
|
|
465
|
+
lines2.push(...renderNode(children[i], childPrefix, i === children.length - 1 && bk.length === 0));
|
|
466
|
+
}
|
|
467
|
+
return lines2;
|
|
468
|
+
}
|
|
469
|
+
const roots = childrenMap.get(null) || [];
|
|
470
|
+
const lines = [chalk.bold("starling")];
|
|
471
|
+
for (let i = 0; i < roots.length; i++) {
|
|
472
|
+
lines.push(...renderNode(roots[i], "", i === roots.length - 1));
|
|
473
|
+
}
|
|
474
|
+
return lines.join("\n");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/utils/fs.ts
|
|
478
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdtempSync, chmodSync } from "fs";
|
|
479
|
+
import { dirname, join as join3 } from "path";
|
|
480
|
+
function ensureDir(filePath) {
|
|
481
|
+
const dir = dirname(filePath);
|
|
482
|
+
if (!existsSync(dir)) {
|
|
483
|
+
mkdirSync(dir, { recursive: true });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function atomicWriteJSON(filePath, data) {
|
|
487
|
+
ensureDir(filePath);
|
|
488
|
+
const dir = dirname(filePath);
|
|
489
|
+
const tmpDir = join3(dir, ".starling-tmp");
|
|
490
|
+
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
|
|
491
|
+
const prefix = join3(tmpDir, "starling-");
|
|
492
|
+
const tmpPath = mkdtempSync(prefix) + "/tmp.json";
|
|
493
|
+
try {
|
|
494
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8");
|
|
495
|
+
chmodSync(tmpPath, 384);
|
|
496
|
+
renameSync(tmpPath, filePath);
|
|
497
|
+
} finally {
|
|
498
|
+
if (existsSync(tmpPath)) {
|
|
499
|
+
unlinkSync(tmpPath);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function readJSON(filePath) {
|
|
504
|
+
if (!existsSync(filePath)) return null;
|
|
505
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
506
|
+
return JSON.parse(raw);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/lib/id.ts
|
|
510
|
+
function generateBookmarkId(bookmarks) {
|
|
511
|
+
let max = 0;
|
|
512
|
+
for (const b of bookmarks) {
|
|
513
|
+
const num = parseInt(b.id.replace("starling_", ""), 10);
|
|
514
|
+
if (!isNaN(num) && num > max) max = num;
|
|
515
|
+
}
|
|
516
|
+
return `starling_${String(max + 1).padStart(4, "0")}`;
|
|
517
|
+
}
|
|
518
|
+
function generateSpaceId(spaces) {
|
|
519
|
+
let max = 0;
|
|
520
|
+
for (const s of spaces) {
|
|
521
|
+
const normalizedId = s.id.replace(/^cat_/, "").replace(/^space_/, "");
|
|
522
|
+
const num = parseInt(normalizedId, 10);
|
|
523
|
+
if (!isNaN(num) && num > max) max = num;
|
|
524
|
+
}
|
|
525
|
+
return `cat_${String(max + 1).padStart(4, "0")}`;
|
|
526
|
+
}
|
|
527
|
+
function generateNoteId() {
|
|
528
|
+
return `note_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/lib/store.ts
|
|
532
|
+
function storePath() {
|
|
533
|
+
const env = process.env[ENV_CONFIG_KEY];
|
|
534
|
+
return env ?? DEFAULT_STORE_PATH;
|
|
535
|
+
}
|
|
536
|
+
function loadStore() {
|
|
537
|
+
const path = storePath();
|
|
538
|
+
const data = readJSON(path);
|
|
539
|
+
if (!data) {
|
|
540
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
541
|
+
return {
|
|
542
|
+
version: STORE_VERSION,
|
|
543
|
+
bookmarks: [],
|
|
544
|
+
spaces: [
|
|
545
|
+
{ id: "cat_0001", name: "claude", description: "Claude Code sessions", tags: [], parent_id: null, created_at: now, updated_at: now },
|
|
546
|
+
{ id: "cat_0002", name: "codex", description: "Codex sessions", tags: [], parent_id: null, created_at: now, updated_at: now }
|
|
547
|
+
],
|
|
548
|
+
categories: []
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
let migrated = false;
|
|
552
|
+
const legacyIdMap = /* @__PURE__ */ new Map();
|
|
553
|
+
const usedCatalogIds = new Set(data.spaces.map((space) => space.id).filter((id) => id.startsWith("cat_")));
|
|
554
|
+
let nextId = 1;
|
|
555
|
+
const nextCatalogId = () => {
|
|
556
|
+
while (usedCatalogIds.has(`cat_${String(nextId).padStart(4, "0")}`)) {
|
|
557
|
+
nextId += 1;
|
|
558
|
+
}
|
|
559
|
+
const id = `cat_${String(nextId).padStart(4, "0")}`;
|
|
560
|
+
usedCatalogIds.add(id);
|
|
561
|
+
nextId += 1;
|
|
562
|
+
return id;
|
|
563
|
+
};
|
|
564
|
+
for (const space of data.spaces) {
|
|
565
|
+
if (space.id.startsWith("space_")) {
|
|
566
|
+
const newId = nextCatalogId();
|
|
567
|
+
legacyIdMap.set(space.id, newId);
|
|
568
|
+
space.id = newId;
|
|
569
|
+
migrated = true;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (migrated) {
|
|
573
|
+
for (const bookmark of data.bookmarks) {
|
|
574
|
+
bookmark.space_ids = bookmark.space_ids.map((sid) => legacyIdMap.get(sid) ?? sid);
|
|
575
|
+
}
|
|
576
|
+
for (const space of data.spaces) {
|
|
577
|
+
if (space.parent_id && legacyIdMap.has(space.parent_id)) {
|
|
578
|
+
space.parent_id = legacyIdMap.get(space.parent_id);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (!data.spaces.some((s) => s.name === "claude")) {
|
|
583
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
584
|
+
data.spaces.push({ id: generateSpaceId(data.spaces), name: "claude", description: "Claude Code sessions", tags: [], parent_id: null, created_at: now, updated_at: now });
|
|
585
|
+
migrated = true;
|
|
586
|
+
}
|
|
587
|
+
if (!data.spaces.some((s) => s.name === "codex")) {
|
|
588
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
589
|
+
data.spaces.push({ id: generateSpaceId(data.spaces), name: "codex", description: "Codex sessions", tags: [], parent_id: null, created_at: now, updated_at: now });
|
|
590
|
+
migrated = true;
|
|
591
|
+
}
|
|
592
|
+
if (migrated) {
|
|
593
|
+
saveStore(data);
|
|
594
|
+
}
|
|
595
|
+
return data;
|
|
596
|
+
}
|
|
597
|
+
function saveStore(store) {
|
|
598
|
+
atomicWriteJSON(storePath(), store);
|
|
599
|
+
}
|
|
600
|
+
function addBookmark(bookmark) {
|
|
601
|
+
const store = loadStore();
|
|
602
|
+
store.bookmarks.push(bookmark);
|
|
603
|
+
if (bookmark.category && !store.categories.includes(bookmark.category)) {
|
|
604
|
+
store.categories.push(bookmark.category);
|
|
605
|
+
}
|
|
606
|
+
saveStore(store);
|
|
607
|
+
return bookmark;
|
|
608
|
+
}
|
|
609
|
+
function findBookmark(id) {
|
|
610
|
+
return loadStore().bookmarks.find((b) => b.id === id || b.session_id === id);
|
|
611
|
+
}
|
|
612
|
+
function updateBookmark(id, patch) {
|
|
613
|
+
const store = loadStore();
|
|
614
|
+
const idx = store.bookmarks.findIndex((b) => b.id === id || b.session_id === id);
|
|
615
|
+
if (idx === -1) return null;
|
|
616
|
+
store.bookmarks[idx] = { ...store.bookmarks[idx], ...patch, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
617
|
+
if (patch.category && !store.categories.includes(patch.category)) {
|
|
618
|
+
store.categories.push(patch.category);
|
|
619
|
+
}
|
|
620
|
+
saveStore(store);
|
|
621
|
+
return store.bookmarks[idx];
|
|
622
|
+
}
|
|
623
|
+
function removeBookmark(id) {
|
|
624
|
+
const store = loadStore();
|
|
625
|
+
const idx = store.bookmarks.findIndex((b) => b.id === id || b.session_id === id);
|
|
626
|
+
if (idx === -1) return false;
|
|
627
|
+
store.bookmarks.splice(idx, 1);
|
|
628
|
+
saveStore(store);
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
function listBookmarks(filter) {
|
|
632
|
+
const store = loadStore();
|
|
633
|
+
let result = store.bookmarks;
|
|
634
|
+
if (filter?.category) {
|
|
635
|
+
result = result.filter((b) => b.category === filter.category);
|
|
636
|
+
}
|
|
637
|
+
if (filter?.tag) {
|
|
638
|
+
result = result.filter((b) => b.tags.includes(filter.tag));
|
|
639
|
+
}
|
|
640
|
+
return result;
|
|
641
|
+
}
|
|
642
|
+
function addSpace(space) {
|
|
643
|
+
const store = loadStore();
|
|
644
|
+
store.spaces.push(space);
|
|
645
|
+
saveStore(store);
|
|
646
|
+
return space;
|
|
647
|
+
}
|
|
648
|
+
function findSpaceCandidates(idNameOrPath) {
|
|
649
|
+
const store = loadStore();
|
|
650
|
+
const exactId = store.spaces.find((space) => space.id === idNameOrPath);
|
|
651
|
+
if (exactId) return [exactId];
|
|
652
|
+
if (idNameOrPath.includes("/")) {
|
|
653
|
+
return findSpacePathCandidates(idNameOrPath, store.spaces);
|
|
654
|
+
}
|
|
655
|
+
return store.spaces.filter((space) => space.name === idNameOrPath);
|
|
656
|
+
}
|
|
657
|
+
function findSpacePathCandidates(pathRef, spaces) {
|
|
658
|
+
const parts = pathRef.split("/").map((part) => part.trim()).filter(Boolean);
|
|
659
|
+
if (parts.length === 0) return [];
|
|
660
|
+
let candidates = spaces.filter((space) => space.name === parts[0] && space.parent_id === null);
|
|
661
|
+
for (const part of parts.slice(1)) {
|
|
662
|
+
const parentIds = new Set(candidates.map((space) => space.id));
|
|
663
|
+
candidates = spaces.filter((space) => space.name === part && space.parent_id !== null && parentIds.has(space.parent_id));
|
|
664
|
+
if (candidates.length === 0) return [];
|
|
665
|
+
}
|
|
666
|
+
return candidates;
|
|
667
|
+
}
|
|
668
|
+
function hasSiblingSpaceName(name, parentId, excludeId) {
|
|
669
|
+
return loadStore().spaces.some(
|
|
670
|
+
(space) => space.name === name && space.parent_id === parentId && space.id !== excludeId
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
function updateSpace(id, patch) {
|
|
674
|
+
const store = loadStore();
|
|
675
|
+
const idx = store.spaces.findIndex((s) => s.id === id || s.name === id);
|
|
676
|
+
if (idx === -1) return null;
|
|
677
|
+
store.spaces[idx] = { ...store.spaces[idx], ...patch, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
678
|
+
saveStore(store);
|
|
679
|
+
return store.spaces[idx];
|
|
680
|
+
}
|
|
681
|
+
function removeSpace(id) {
|
|
682
|
+
const store = loadStore();
|
|
683
|
+
const idx = store.spaces.findIndex((s) => s.id === id || s.name === id);
|
|
684
|
+
if (idx === -1) return false;
|
|
685
|
+
const space = store.spaces[idx];
|
|
686
|
+
for (const b of store.bookmarks) {
|
|
687
|
+
b.space_ids = b.space_ids.filter((sid) => sid !== space.id);
|
|
688
|
+
}
|
|
689
|
+
for (const s of store.spaces) {
|
|
690
|
+
if (s.parent_id === space.id) {
|
|
691
|
+
s.parent_id = space.parent_id;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
store.spaces.splice(idx, 1);
|
|
695
|
+
saveStore(store);
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
function listSpaces() {
|
|
699
|
+
return loadStore().spaces;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/lib/catalogResolver.ts
|
|
703
|
+
function resolveCatalogReference(ref) {
|
|
704
|
+
const matches = findSpaceCandidates(ref);
|
|
705
|
+
if (matches.length === 1) {
|
|
706
|
+
return { kind: "found", space: matches[0] };
|
|
707
|
+
}
|
|
708
|
+
if (matches.length === 0) {
|
|
709
|
+
return { kind: "not_found" };
|
|
710
|
+
}
|
|
711
|
+
return { kind: "ambiguous", matches };
|
|
712
|
+
}
|
|
713
|
+
function catalogPath(space, spaces = listSpaces()) {
|
|
714
|
+
const parts = [space.name];
|
|
715
|
+
let current = space;
|
|
716
|
+
const seen = /* @__PURE__ */ new Set();
|
|
717
|
+
while (current.parent_id && !seen.has(current.parent_id)) {
|
|
718
|
+
seen.add(current.parent_id);
|
|
719
|
+
const parent = spaces.find((candidate) => candidate.id === current.parent_id);
|
|
720
|
+
if (!parent) break;
|
|
721
|
+
parts.unshift(parent.name);
|
|
722
|
+
current = parent;
|
|
723
|
+
}
|
|
724
|
+
return parts.join("/");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/lib/sessionIndex.ts
|
|
728
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
729
|
+
import { join as join4 } from "path";
|
|
730
|
+
var SESSION_INDEX_PATH = join4(DEFAULT_STARLING_HOME, "session-index.json");
|
|
731
|
+
async function rebuildSessionIndex(provider) {
|
|
732
|
+
const sessions = [];
|
|
733
|
+
for await (const session of streamSessions(provider, Infinity)) {
|
|
734
|
+
sessions.push(session);
|
|
735
|
+
}
|
|
736
|
+
return writeSessionIndex(sessions);
|
|
737
|
+
}
|
|
738
|
+
function loadSessionIndex() {
|
|
739
|
+
if (!existsSync2(SESSION_INDEX_PATH)) return null;
|
|
740
|
+
try {
|
|
741
|
+
const parsed = JSON.parse(readFileSync2(SESSION_INDEX_PATH, "utf-8"));
|
|
742
|
+
if (!isRecord2(parsed)) return null;
|
|
743
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.sessions)) return null;
|
|
744
|
+
if (typeof parsed.built_at !== "string") return null;
|
|
745
|
+
if (typeof parsed.session_count !== "number") return null;
|
|
746
|
+
if (typeof parsed.project_count !== "number") return null;
|
|
747
|
+
return parsed;
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function loadSessionIndexWithNewFiles(provider) {
|
|
753
|
+
const index = loadSessionIndex();
|
|
754
|
+
if (!index) {
|
|
755
|
+
return rebuildSessionIndex();
|
|
756
|
+
}
|
|
757
|
+
const indexedPaths = new Set(index.sessions.map((session) => session.file_path).filter(Boolean));
|
|
758
|
+
const newFiles = collectSessionFileEntries(provider).filter((entry) => !indexedPaths.has(entry.path));
|
|
759
|
+
if (newFiles.length === 0) return index;
|
|
760
|
+
const sessions = [...index.sessions];
|
|
761
|
+
for (const entry of newFiles) {
|
|
762
|
+
const session = await parseSessionFileEntry(entry);
|
|
763
|
+
if (session) upsertSession(sessions, session);
|
|
764
|
+
}
|
|
765
|
+
return writeSessionIndex(sessions);
|
|
766
|
+
}
|
|
767
|
+
async function refreshIndexedSessionsById(sessionIds, provider) {
|
|
768
|
+
const index = await loadSessionIndexWithNewFiles(provider);
|
|
769
|
+
const wantedIds = new Set(sessionIds.map((sessionId) => sessionId.toLowerCase()));
|
|
770
|
+
if (wantedIds.size === 0) return index;
|
|
771
|
+
const sessions = [...index.sessions];
|
|
772
|
+
let changed = false;
|
|
773
|
+
for (const session of index.sessions) {
|
|
774
|
+
if (provider && session.provider !== provider) continue;
|
|
775
|
+
if (!matchesSessionId(wantedIds, session.session_id)) continue;
|
|
776
|
+
if (!session.file_path) continue;
|
|
777
|
+
try {
|
|
778
|
+
const stat = statSync2(session.file_path);
|
|
779
|
+
if (new Date(stat.mtimeMs).toISOString() <= session.modified_at) continue;
|
|
780
|
+
const refreshed = await parseSessionFileEntry({
|
|
781
|
+
provider: session.provider === "codex" ? "codex" : "claude",
|
|
782
|
+
path: session.file_path,
|
|
783
|
+
mtimeMs: stat.mtimeMs
|
|
784
|
+
});
|
|
785
|
+
if (!refreshed) continue;
|
|
786
|
+
upsertSession(sessions, refreshed);
|
|
787
|
+
changed = true;
|
|
788
|
+
} catch {
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return changed ? writeSessionIndex(sessions) : index;
|
|
792
|
+
}
|
|
793
|
+
function clearSessionIndex() {
|
|
794
|
+
if (!existsSync2(SESSION_INDEX_PATH)) return false;
|
|
795
|
+
unlinkSync2(SESSION_INDEX_PATH);
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
function upsertSessionInIndex(session) {
|
|
799
|
+
const index = loadSessionIndex();
|
|
800
|
+
if (!index) return false;
|
|
801
|
+
const sessions = [...index.sessions];
|
|
802
|
+
upsertSession(sessions, session);
|
|
803
|
+
writeSessionIndex(sessions);
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
function removeSessionFromIndex(sessionId) {
|
|
807
|
+
const index = loadSessionIndex();
|
|
808
|
+
if (!index) return false;
|
|
809
|
+
const normalized = sessionId.toLowerCase();
|
|
810
|
+
const sessions = index.sessions.filter((session) => session.session_id.toLowerCase() !== normalized);
|
|
811
|
+
if (sessions.length === index.sessions.length) return false;
|
|
812
|
+
writeSessionIndex(sessions);
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
function aggregateProjectsFromSessions(sessions, providerFilter) {
|
|
816
|
+
const map = /* @__PURE__ */ new Map();
|
|
817
|
+
for (const meta of sessions) {
|
|
818
|
+
if (providerFilter && meta.provider !== providerFilter) continue;
|
|
819
|
+
const key = meta.project_path || "(unknown)";
|
|
820
|
+
let stats = map.get(key);
|
|
821
|
+
if (!stats) {
|
|
822
|
+
stats = {
|
|
823
|
+
project_path: key,
|
|
824
|
+
session_count: 0,
|
|
825
|
+
agents: {},
|
|
826
|
+
models: {},
|
|
827
|
+
first_active: meta.modified_at,
|
|
828
|
+
last_active: meta.modified_at,
|
|
829
|
+
sessions: []
|
|
830
|
+
};
|
|
831
|
+
map.set(key, stats);
|
|
832
|
+
}
|
|
833
|
+
stats.session_count++;
|
|
834
|
+
stats.agents[meta.provider] = (stats.agents[meta.provider] || 0) + 1;
|
|
835
|
+
const model = meta.model || "-";
|
|
836
|
+
stats.models[model] = (stats.models[model] || 0) + 1;
|
|
837
|
+
if (meta.modified_at < stats.first_active) stats.first_active = meta.modified_at;
|
|
838
|
+
if (meta.modified_at > stats.last_active) stats.last_active = meta.modified_at;
|
|
839
|
+
stats.sessions.push(meta);
|
|
840
|
+
}
|
|
841
|
+
const projects = [...map.values()];
|
|
842
|
+
for (const project of projects) {
|
|
843
|
+
project.sessions.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
844
|
+
}
|
|
845
|
+
projects.sort((a, b) => b.last_active.localeCompare(a.last_active));
|
|
846
|
+
return projects;
|
|
847
|
+
}
|
|
848
|
+
function isRecord2(value) {
|
|
849
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
850
|
+
}
|
|
851
|
+
function writeSessionIndex(sessions) {
|
|
852
|
+
sessions.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
853
|
+
const projects = aggregateProjectsFromSessions(sessions);
|
|
854
|
+
const index = {
|
|
855
|
+
version: 1,
|
|
856
|
+
built_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
857
|
+
session_count: sessions.length,
|
|
858
|
+
project_count: projects.length,
|
|
859
|
+
sessions
|
|
860
|
+
};
|
|
861
|
+
atomicWriteJSON(SESSION_INDEX_PATH, index);
|
|
862
|
+
return index;
|
|
863
|
+
}
|
|
864
|
+
function upsertSession(sessions, session) {
|
|
865
|
+
const existingIndex = sessions.findIndex((entry) => entry.session_id === session.session_id);
|
|
866
|
+
if (existingIndex >= 0) {
|
|
867
|
+
sessions[existingIndex] = session;
|
|
868
|
+
} else {
|
|
869
|
+
sessions.push(session);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
function matchesSessionId(wantedIds, sessionId) {
|
|
873
|
+
const normalizedSessionId = sessionId.toLowerCase();
|
|
874
|
+
if (wantedIds.has(normalizedSessionId)) return true;
|
|
875
|
+
for (const wantedId of wantedIds) {
|
|
876
|
+
if (wantedId && normalizedSessionId.startsWith(wantedId)) return true;
|
|
877
|
+
}
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
async function parseSessionFileEntry(entry) {
|
|
881
|
+
try {
|
|
882
|
+
const entries = await parseJsonlHead(entry.path);
|
|
883
|
+
const modifiedAt = new Date(entry.mtimeMs).toISOString();
|
|
884
|
+
if (entry.provider === "claude") {
|
|
885
|
+
return extractClaudeSessionMeta(entries, entry.path, modifiedAt);
|
|
886
|
+
}
|
|
887
|
+
return extractCodexSessionMeta(entries, entry.path, modifiedAt);
|
|
888
|
+
} catch {
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function collectSessionFileEntries(provider) {
|
|
893
|
+
const roots = [];
|
|
894
|
+
if (!provider || provider === "claude") roots.push({ provider: "claude", path: CLAUDE_SESSIONS_DIR });
|
|
895
|
+
if (!provider || provider === "codex") roots.push({ provider: "codex", path: CODEX_SESSIONS_DIR });
|
|
896
|
+
const files = [];
|
|
897
|
+
for (const root of roots) {
|
|
898
|
+
collectSessionFileEntriesInDir(root.provider, root.path, files);
|
|
899
|
+
}
|
|
900
|
+
return files;
|
|
901
|
+
}
|
|
902
|
+
function collectSessionFileEntriesInDir(provider, dir, files) {
|
|
903
|
+
let entries;
|
|
904
|
+
try {
|
|
905
|
+
entries = readdirSync2(dir);
|
|
906
|
+
} catch {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
for (const entry of entries) {
|
|
910
|
+
if (entry === "subagents") continue;
|
|
911
|
+
const full = join4(dir, entry);
|
|
912
|
+
try {
|
|
913
|
+
const stat = statSync2(full);
|
|
914
|
+
if (stat.isDirectory()) {
|
|
915
|
+
collectSessionFileEntriesInDir(provider, full, files);
|
|
916
|
+
} else if (entry.endsWith(".jsonl")) {
|
|
917
|
+
files.push({ provider, path: full, mtimeMs: stat.mtimeMs });
|
|
918
|
+
}
|
|
919
|
+
} catch {
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// src/commands/session.ts
|
|
925
|
+
function formatSessionLine(s) {
|
|
926
|
+
const agent = s.provider === "codex" ? "codex" : "claude";
|
|
927
|
+
const shortId = shortSessionId(s.session_id);
|
|
928
|
+
const shortProject = s.project_path ? s.project_path.length > 40 ? "\u2026" + s.project_path.slice(-39) : s.project_path : "-";
|
|
929
|
+
const date = s.modified_at.slice(0, 16).replace("T", " ");
|
|
930
|
+
const inputTokens = s.token_usage?.input_tokens ?? "-";
|
|
931
|
+
const outputTokens = s.token_usage?.output_tokens ?? "-";
|
|
932
|
+
const totalTokens = s.token_usage?.total_tokens ?? "-";
|
|
933
|
+
const cacheTokens = s.token_usage?.cache_tokens ?? "-";
|
|
934
|
+
return `${chalk2.cyan(shortId.padEnd(15))} ${chalk2.gray(agent.padEnd(7))} ${(s.model || "-").padEnd(18)} ${shortProject.padEnd(42)} ${chalk2.gray(date)} ${chalk2.yellow(String(inputTokens)).padEnd(10)} ${chalk2.yellow(String(outputTokens)).padEnd(10)} ${chalk2.yellow(String(totalTokens)).padEnd(10)} ${chalk2.yellow(String(cacheTokens)).padEnd(10)}`;
|
|
935
|
+
}
|
|
936
|
+
function registerSessionCommand(program2) {
|
|
937
|
+
const session = new Command("session").description("Discover and manage agent sessions");
|
|
938
|
+
session.command("list").alias("ls").description("List recent agent sessions").option("-n, --limit <number>", "max sessions to show", "20").option("-a, --agent <agent>", "filter by agent: claude | codex").option("--cataloged", "only show sessions assigned to any catalog").option("-c, --catalog <catalog>", "only show sessions assigned to a catalog").option("--all", "list all sessions (streaming with pager)").option("--json", "output as JSON").action(async (opts) => {
|
|
939
|
+
const provider = opts.agent;
|
|
940
|
+
const hasCatalogFilter = Boolean(opts.cataloged || opts.catalog);
|
|
941
|
+
if (opts.all) {
|
|
942
|
+
const filteredSessions = hasCatalogFilter ? await findCatalogSessions(opts.cataloged, opts.catalog, provider) : await collectStreamedSessions(provider);
|
|
943
|
+
if (opts.json) {
|
|
944
|
+
console.log(JSON.stringify(filteredSessions, null, 2));
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const header = `${"SESSION".padEnd(15)} ${"AGENT".padEnd(7)} ${"MODEL".padEnd(18)} ${"PROJECT".padEnd(42)} MODIFIED ${"INPUT".padEnd(10)} ${"OUTPUT".padEnd(10)} ${"TOTAL".padEnd(10)} ${"CACHE".padEnd(10)}
|
|
948
|
+
${"\u2500".repeat(145)}`;
|
|
949
|
+
const usePager = process.stdout.isTTY;
|
|
950
|
+
const pager = usePager ? spawn("less", ["-RFX"], { stdio: ["pipe", "inherit", "inherit"] }) : null;
|
|
951
|
+
let pipeBroken = false;
|
|
952
|
+
if (pager) {
|
|
953
|
+
pager.stdin.on("error", () => {
|
|
954
|
+
pipeBroken = true;
|
|
955
|
+
});
|
|
956
|
+
pager.on("close", () => {
|
|
957
|
+
pipeBroken = true;
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
const out = (line) => {
|
|
961
|
+
if (pipeBroken) return;
|
|
962
|
+
if (pager) {
|
|
963
|
+
pager.stdin.write(line + "\n");
|
|
964
|
+
} else {
|
|
965
|
+
console.log(line);
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
out(header);
|
|
969
|
+
let count = 0;
|
|
970
|
+
for (const meta of filteredSessions) {
|
|
971
|
+
if (pipeBroken) break;
|
|
972
|
+
out(formatSessionLine(meta));
|
|
973
|
+
count++;
|
|
974
|
+
}
|
|
975
|
+
if (!pipeBroken) out(chalk2.gray(`
|
|
976
|
+
Total: ${count} sessions`));
|
|
977
|
+
if (pager && !pipeBroken) {
|
|
978
|
+
pager.stdin.end();
|
|
979
|
+
await new Promise((resolve3) => pager.on("close", () => resolve3()));
|
|
980
|
+
}
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const limit = parseInt(opts.limit, 10) || 20;
|
|
984
|
+
const sessions = hasCatalogFilter ? (await findCatalogSessions(opts.cataloged, opts.catalog, provider)).slice(0, limit) : await findSessions(limit, provider);
|
|
985
|
+
if (sessions.length === 0) {
|
|
986
|
+
console.log(chalk2.yellow("No sessions found."));
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (opts.json) {
|
|
990
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
console.log(formatSessionTable(sessions));
|
|
994
|
+
});
|
|
995
|
+
const index = new Command("index").description("Manage the local session index");
|
|
996
|
+
index.command("status").description("Show session index status").option("--json", "output as JSON").action((opts) => {
|
|
997
|
+
const current = loadSessionIndex();
|
|
998
|
+
const payload = current ? {
|
|
999
|
+
path: SESSION_INDEX_PATH,
|
|
1000
|
+
exists: true,
|
|
1001
|
+
built_at: current.built_at,
|
|
1002
|
+
session_count: current.session_count,
|
|
1003
|
+
project_count: current.project_count
|
|
1004
|
+
} : {
|
|
1005
|
+
path: SESSION_INDEX_PATH,
|
|
1006
|
+
exists: false
|
|
1007
|
+
};
|
|
1008
|
+
if (opts.json) {
|
|
1009
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (!current) {
|
|
1013
|
+
console.log(chalk2.yellow("No session index found."));
|
|
1014
|
+
console.log(chalk2.gray(` Path: ${SESSION_INDEX_PATH}`));
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
console.log(chalk2.green("Session index"));
|
|
1018
|
+
console.log(` Path: ${SESSION_INDEX_PATH}`);
|
|
1019
|
+
console.log(` Built: ${current.built_at}`);
|
|
1020
|
+
console.log(` Sessions: ${current.session_count}`);
|
|
1021
|
+
console.log(` Projects: ${current.project_count}`);
|
|
1022
|
+
});
|
|
1023
|
+
index.command("rebuild").description("Rebuild ~/.starling/session-index.json").option("-a, --agent <agent>", "filter by agent: claude | codex").option("--json", "output as JSON").action(async (opts) => {
|
|
1024
|
+
const provider = opts.agent;
|
|
1025
|
+
const rebuilt = await rebuildSessionIndex(provider);
|
|
1026
|
+
if (opts.json) {
|
|
1027
|
+
console.log(JSON.stringify({ path: SESSION_INDEX_PATH, ...rebuilt }, null, 2));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
console.log(chalk2.green("Rebuilt session index"));
|
|
1031
|
+
console.log(` Path: ${SESSION_INDEX_PATH}`);
|
|
1032
|
+
console.log(` Sessions: ${rebuilt.session_count}`);
|
|
1033
|
+
console.log(` Projects: ${rebuilt.project_count}`);
|
|
1034
|
+
});
|
|
1035
|
+
index.command("clear").description("Remove ~/.starling/session-index.json").action(() => {
|
|
1036
|
+
const removed = clearSessionIndex();
|
|
1037
|
+
console.log(removed ? chalk2.green("Session index removed.") : chalk2.yellow("No session index found."));
|
|
1038
|
+
});
|
|
1039
|
+
session.addCommand(index);
|
|
1040
|
+
session.command("show <session-id>").description("Show session details").option("--json", "output as JSON").action(async (sessionId, opts) => {
|
|
1041
|
+
const meta = await findSessionById(sessionId);
|
|
1042
|
+
if (!meta) {
|
|
1043
|
+
console.error(chalk2.red(`Session not found: ${sessionId}`));
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
const catalogs = findSessionCatalogs(meta.session_id);
|
|
1047
|
+
const metadata = findSessionBookmark(meta.session_id);
|
|
1048
|
+
if (opts.json) {
|
|
1049
|
+
console.log(JSON.stringify({ ...meta, catalogs, metadata: metadata ?? null }, null, 2));
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
console.log(chalk2.bold.cyan(`Session: ${meta.session_id}`));
|
|
1053
|
+
console.log(` Provider: ${meta.provider}`);
|
|
1054
|
+
console.log(` Model: ${meta.model || "-"}`);
|
|
1055
|
+
console.log(` Project: ${meta.project_path || "-"}`);
|
|
1056
|
+
console.log(` File: ${meta.file_path}`);
|
|
1057
|
+
console.log(` Modified: ${meta.modified_at}`);
|
|
1058
|
+
console.log(` Catalogs: ${catalogs.length > 0 ? catalogs.map((catalog2) => `${catalog2.name} (${catalog2.id})`).join(", ") : "-"}`);
|
|
1059
|
+
if (metadata) {
|
|
1060
|
+
console.log(` Title: ${metadata.title || "-"}`);
|
|
1061
|
+
console.log(` Tags: ${metadata.tags.join(", ") || "-"}`);
|
|
1062
|
+
if (metadata.notes.length > 0) {
|
|
1063
|
+
console.log(" Notes:");
|
|
1064
|
+
for (const note of metadata.notes) {
|
|
1065
|
+
console.log(` ${note.id}: ${note.content}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const tokenUsage = meta.token_usage;
|
|
1070
|
+
if (tokenUsage) {
|
|
1071
|
+
console.log(" Token Usage:");
|
|
1072
|
+
console.log(` Input: ${tokenUsage.input_tokens ?? "-"}`);
|
|
1073
|
+
console.log(` Output: ${tokenUsage.output_tokens ?? "-"}`);
|
|
1074
|
+
console.log(` Total: ${tokenUsage.total_tokens ?? "-"}`);
|
|
1075
|
+
console.log(` Cache: ${tokenUsage.cache_tokens ?? "-"}`);
|
|
1076
|
+
}
|
|
1077
|
+
if (meta.first_prompt) {
|
|
1078
|
+
console.log(` First Prompt:`);
|
|
1079
|
+
console.log(` ${meta.first_prompt}`);
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
session.command("resume <session-id>").description("Resume an agent session").action(async (sessionId) => {
|
|
1083
|
+
await resumeSession(sessionId);
|
|
1084
|
+
});
|
|
1085
|
+
session.command("meta <session-id>").description("Create or update session metadata").option("-t, --title <title>", "session title").option("--tags <tags>", "comma-separated tags (replaces existing)").option("--add-tags <tags>", "add tags (appends)").action(async (sessionId, opts) => {
|
|
1086
|
+
const meta = await resolveSessionMeta(sessionId);
|
|
1087
|
+
const bookmark = ensureSessionBookmark(meta);
|
|
1088
|
+
const patch = {};
|
|
1089
|
+
if (opts.title !== void 0) patch.title = opts.title;
|
|
1090
|
+
if (opts.tags !== void 0) patch.tags = parseTags(opts.tags);
|
|
1091
|
+
if (opts.addTags !== void 0) {
|
|
1092
|
+
patch.tags = [.../* @__PURE__ */ new Set([...bookmark.tags, ...parseTags(opts.addTags)])];
|
|
1093
|
+
}
|
|
1094
|
+
if (Object.keys(patch).length === 0) {
|
|
1095
|
+
console.log(chalk2.yellow(`No metadata changes provided for ${bookmark.id}.`));
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const updated = updateBookmark(bookmark.id, patch);
|
|
1099
|
+
console.log(chalk2.green(`Updated session metadata: ${updated?.id ?? bookmark.id}`));
|
|
1100
|
+
});
|
|
1101
|
+
session.command("note <session-id> <content...>").description("Add a note to a session").action(async (sessionId, contentParts) => {
|
|
1102
|
+
const content = contentParts.join(" ").trim();
|
|
1103
|
+
if (!content) {
|
|
1104
|
+
console.error(chalk2.red("Note content is required."));
|
|
1105
|
+
process.exit(1);
|
|
1106
|
+
}
|
|
1107
|
+
const meta = await resolveSessionMeta(sessionId);
|
|
1108
|
+
const bookmark = ensureSessionBookmark(meta);
|
|
1109
|
+
const note = { id: generateNoteId(), content, created_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1110
|
+
const notes = [...bookmark.notes, note];
|
|
1111
|
+
updateBookmark(bookmark.id, { notes });
|
|
1112
|
+
console.log(chalk2.green(`Note added to ${bookmark.id}: ${note.id}`));
|
|
1113
|
+
});
|
|
1114
|
+
session.command("unpin <session-id>").description("Remove Starling metadata for a session without deleting the session file").action((sessionId) => {
|
|
1115
|
+
const bookmark = findSessionBookmark(sessionId);
|
|
1116
|
+
if (!bookmark) {
|
|
1117
|
+
console.log(chalk2.yellow(`Session metadata not found: ${sessionId}`));
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
removeBookmark(bookmark.id);
|
|
1121
|
+
console.log(chalk2.green(`Removed pin metadata for ${shortSessionId(bookmark.session_id)}`));
|
|
1122
|
+
});
|
|
1123
|
+
session.command("delete <session-id>").description("Delete a session file and remove Starling metadata").option("-y, --yes", "confirm deletion").action(async (sessionId, opts) => {
|
|
1124
|
+
if (!opts.yes) {
|
|
1125
|
+
console.error(chalk2.red("Deleting a session file requires --yes."));
|
|
1126
|
+
process.exit(1);
|
|
1127
|
+
}
|
|
1128
|
+
const meta = await resolveSessionMeta(sessionId);
|
|
1129
|
+
if (!meta.file_path) {
|
|
1130
|
+
console.error(chalk2.red(`Session file path is unknown: ${meta.session_id}`));
|
|
1131
|
+
process.exit(1);
|
|
1132
|
+
}
|
|
1133
|
+
if (!existsSync3(meta.file_path)) {
|
|
1134
|
+
console.error(chalk2.red(`Session file not found: ${meta.file_path}`));
|
|
1135
|
+
process.exit(1);
|
|
1136
|
+
}
|
|
1137
|
+
unlinkSync3(meta.file_path);
|
|
1138
|
+
const bookmark = findSessionBookmark(meta.session_id);
|
|
1139
|
+
if (bookmark) {
|
|
1140
|
+
removeBookmark(bookmark.id);
|
|
1141
|
+
}
|
|
1142
|
+
removeSessionFromIndex(meta.session_id);
|
|
1143
|
+
console.log(chalk2.green(`Deleted session ${shortSessionId(meta.session_id)}`));
|
|
1144
|
+
console.log(chalk2.gray(` File: ${meta.file_path}`));
|
|
1145
|
+
if (bookmark) {
|
|
1146
|
+
console.log(chalk2.gray(` Removed pin: ${bookmark.id}`));
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
const catalog = new Command("catalog").description("Manage session catalog assignments");
|
|
1150
|
+
catalog.command("add <session-id> <catalog>").description("Add a session to a catalog").option("-t, --title <title>", "pin title when creating a new pin").option("--tags <tags>", "comma-separated tags when creating a new pin").action(async (sessionId, catalog2, opts) => {
|
|
1151
|
+
const catalogEntry = resolveCatalog(catalog2);
|
|
1152
|
+
const meta = await resolveSessionMeta(sessionId);
|
|
1153
|
+
const bookmark = ensureSessionBookmark(meta, {
|
|
1154
|
+
title: opts.title,
|
|
1155
|
+
tags: opts.tags ? parseTags(opts.tags) : void 0
|
|
1156
|
+
});
|
|
1157
|
+
if (bookmark.space_ids.includes(catalogEntry.id)) {
|
|
1158
|
+
console.log(chalk2.yellow(`Session already in catalog "${catalogEntry.name}".`));
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
updateBookmark(bookmark.id, { space_ids: [...bookmark.space_ids, catalogEntry.id] });
|
|
1162
|
+
console.log(chalk2.green(`Added session ${shortSessionId(bookmark.session_id)} to catalog "${catalogEntry.name}"`));
|
|
1163
|
+
});
|
|
1164
|
+
catalog.command("remove <session-id> <catalog>").alias("rm").description("Remove a session from a catalog").action((sessionId, catalog2) => {
|
|
1165
|
+
const catalogEntry = resolveCatalog(catalog2);
|
|
1166
|
+
const bookmark = findSessionBookmark(sessionId);
|
|
1167
|
+
if (!bookmark) {
|
|
1168
|
+
console.error(chalk2.red(`Session metadata not found: ${sessionId}`));
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
if (!bookmark.space_ids.includes(catalogEntry.id)) {
|
|
1172
|
+
console.log(chalk2.yellow(`Session is not in catalog "${catalogEntry.name}".`));
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
updateBookmark(bookmark.id, {
|
|
1176
|
+
space_ids: bookmark.space_ids.filter((catalogId) => catalogId !== catalogEntry.id)
|
|
1177
|
+
});
|
|
1178
|
+
console.log(chalk2.green(`Removed session ${shortSessionId(bookmark.session_id)} from catalog "${catalogEntry.name}"`));
|
|
1179
|
+
});
|
|
1180
|
+
catalog.command("clear <session-id>").description("Remove a session from all catalogs").action((sessionId) => {
|
|
1181
|
+
const bookmark = findSessionBookmark(sessionId);
|
|
1182
|
+
if (!bookmark) {
|
|
1183
|
+
console.error(chalk2.red(`Session metadata not found: ${sessionId}`));
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
}
|
|
1186
|
+
updateBookmark(bookmark.id, { space_ids: [] });
|
|
1187
|
+
console.log(chalk2.green(`Removed session ${shortSessionId(bookmark.session_id)} from all catalogs`));
|
|
1188
|
+
});
|
|
1189
|
+
session.addCommand(catalog);
|
|
1190
|
+
program2.addCommand(session);
|
|
1191
|
+
}
|
|
1192
|
+
function findSessionCatalogs(sessionId) {
|
|
1193
|
+
const bookmark = findSessionBookmark(sessionId);
|
|
1194
|
+
if (!bookmark) return [];
|
|
1195
|
+
const spaces = listSpaces();
|
|
1196
|
+
return bookmark.space_ids.map((catalogId) => {
|
|
1197
|
+
const catalog = spaces.find((space) => space.id === catalogId);
|
|
1198
|
+
return {
|
|
1199
|
+
id: catalogId,
|
|
1200
|
+
name: catalog?.name ?? catalogId
|
|
1201
|
+
};
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
async function collectStreamedSessions(provider) {
|
|
1205
|
+
const sessions = [];
|
|
1206
|
+
for await (const meta of streamSessions(provider)) {
|
|
1207
|
+
sessions.push(meta);
|
|
1208
|
+
}
|
|
1209
|
+
return sessions;
|
|
1210
|
+
}
|
|
1211
|
+
async function findCatalogSessions(cataloged, catalogRef, provider) {
|
|
1212
|
+
const sessionIds = getCatalogSessionIds(cataloged, catalogRef);
|
|
1213
|
+
const wantedIds = new Set(sessionIds.map((sessionId) => sessionId.toLowerCase()));
|
|
1214
|
+
const index = await refreshIndexedSessionsById(sessionIds, provider);
|
|
1215
|
+
const sessions = index.sessions.filter((session) => {
|
|
1216
|
+
if (provider && session.provider !== provider) return false;
|
|
1217
|
+
return matchesCatalogSessionId(wantedIds, session.session_id);
|
|
1218
|
+
});
|
|
1219
|
+
sessions.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
1220
|
+
return sessions;
|
|
1221
|
+
}
|
|
1222
|
+
function matchesCatalogSessionId(wantedIds, sessionId) {
|
|
1223
|
+
const normalizedSessionId = sessionId.toLowerCase();
|
|
1224
|
+
if (wantedIds.has(normalizedSessionId)) return true;
|
|
1225
|
+
for (const wantedId of wantedIds) {
|
|
1226
|
+
if (wantedId && normalizedSessionId.startsWith(wantedId)) return true;
|
|
1227
|
+
}
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
function getCatalogSessionIds(cataloged, catalogRef) {
|
|
1231
|
+
const bookmarks = listBookmarks();
|
|
1232
|
+
if (catalogRef) {
|
|
1233
|
+
const catalog = resolveCatalog(catalogRef);
|
|
1234
|
+
return unique(
|
|
1235
|
+
bookmarks.filter((bookmark) => bookmark.space_ids.includes(catalog.id)).map((bookmark) => bookmark.session_id)
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
if (cataloged) {
|
|
1239
|
+
return unique(
|
|
1240
|
+
bookmarks.filter((bookmark) => bookmark.space_ids.length > 0).map((bookmark) => bookmark.session_id)
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
return [];
|
|
1244
|
+
}
|
|
1245
|
+
function unique(values) {
|
|
1246
|
+
return [...new Set(values)];
|
|
1247
|
+
}
|
|
1248
|
+
function findSessionBookmark(sessionId) {
|
|
1249
|
+
return listBookmarks().find((entry) => entry.session_id === sessionId);
|
|
1250
|
+
}
|
|
1251
|
+
function resolveCatalog(catalogRef) {
|
|
1252
|
+
const resolution = resolveCatalogReference(catalogRef);
|
|
1253
|
+
if (resolution.kind === "found") {
|
|
1254
|
+
return { id: resolution.space.id, name: resolution.space.name };
|
|
1255
|
+
}
|
|
1256
|
+
if (resolution.kind === "not_found") {
|
|
1257
|
+
console.error(chalk2.red(`Catalog not found: ${catalogRef}`));
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
console.error(chalk2.red(`Ambiguous catalog reference: ${catalogRef}`));
|
|
1261
|
+
console.error(chalk2.red("Use a catalog path like parent/child or the catalog id."));
|
|
1262
|
+
for (const match of resolution.matches) {
|
|
1263
|
+
console.error(chalk2.gray(` ${catalogPath(match, listSpaces())} (${match.id})`));
|
|
1264
|
+
}
|
|
1265
|
+
process.exit(1);
|
|
1266
|
+
}
|
|
1267
|
+
function parseTags(value) {
|
|
1268
|
+
return value.split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
1269
|
+
}
|
|
1270
|
+
async function resolveSessionMeta(input) {
|
|
1271
|
+
const candidates = await findSessionCandidates(input);
|
|
1272
|
+
if (candidates.length === 0) {
|
|
1273
|
+
console.error(chalk2.red(`No session matches: ${input}`));
|
|
1274
|
+
process.exit(1);
|
|
1275
|
+
}
|
|
1276
|
+
if (candidates.length > 1) {
|
|
1277
|
+
const exact = candidates.find((candidate) => candidate.session_id === input);
|
|
1278
|
+
if (exact) return exact;
|
|
1279
|
+
console.error(chalk2.red(`Ambiguous session id: ${input}`));
|
|
1280
|
+
console.error(chalk2.red("Please rerun with full session id."));
|
|
1281
|
+
process.exit(1);
|
|
1282
|
+
}
|
|
1283
|
+
return candidates[0];
|
|
1284
|
+
}
|
|
1285
|
+
function ensureSessionBookmark(meta, defaults = {}) {
|
|
1286
|
+
const existing = findSessionBookmark(meta.session_id);
|
|
1287
|
+
if (existing) return existing;
|
|
1288
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1289
|
+
return addBookmark({
|
|
1290
|
+
id: generateBookmarkId(listBookmarks()),
|
|
1291
|
+
provider: meta.provider || "unknown",
|
|
1292
|
+
session_id: meta.session_id,
|
|
1293
|
+
title: defaults.title ?? meta.first_prompt?.slice(0, 60) ?? meta.session_id.slice(0, 16),
|
|
1294
|
+
category: "",
|
|
1295
|
+
tags: defaults.tags ?? [],
|
|
1296
|
+
project_path: meta.project_path ?? "",
|
|
1297
|
+
first_prompt: meta.first_prompt ?? "",
|
|
1298
|
+
notes: [],
|
|
1299
|
+
space_ids: [],
|
|
1300
|
+
created_at: now,
|
|
1301
|
+
updated_at: now
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
async function resumeSession(sessionId) {
|
|
1305
|
+
const meta = await findSessionById(sessionId);
|
|
1306
|
+
if (!meta) {
|
|
1307
|
+
console.error(chalk2.red(`Session not found: ${sessionId}`));
|
|
1308
|
+
process.exit(1);
|
|
1309
|
+
}
|
|
1310
|
+
const cwd = meta.project_path || void 0;
|
|
1311
|
+
if (meta.provider === "claude") {
|
|
1312
|
+
console.log(chalk2.green(`Resuming claude session: ${shortSessionId(meta.session_id)}\u2026`));
|
|
1313
|
+
if (cwd) console.log(chalk2.gray(` Project: ${cwd}`));
|
|
1314
|
+
const result = spawnSync("claude", ["--resume", meta.session_id], { stdio: "inherit", cwd });
|
|
1315
|
+
if (result.status !== 0) {
|
|
1316
|
+
process.exit(1);
|
|
1317
|
+
}
|
|
1318
|
+
} else if (meta.provider === "codex") {
|
|
1319
|
+
console.log(chalk2.green(`Resuming codex session: ${shortSessionId(meta.session_id)}\u2026`));
|
|
1320
|
+
if (cwd) console.log(chalk2.gray(` Project: ${cwd}`));
|
|
1321
|
+
const result = spawnSync("codex", ["resume", meta.session_id], { stdio: "inherit", cwd });
|
|
1322
|
+
if (result.status !== 0) {
|
|
1323
|
+
process.exit(1);
|
|
1324
|
+
}
|
|
1325
|
+
} else {
|
|
1326
|
+
console.error(chalk2.red(`Unknown provider: ${meta.provider}`));
|
|
1327
|
+
process.exit(1);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// src/commands/pin.ts
|
|
1332
|
+
import { Command as Command2 } from "commander";
|
|
1333
|
+
import chalk3 from "chalk";
|
|
1334
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
1335
|
+
import { stdin, stdout } from "process";
|
|
1336
|
+
function registerPinCommand(program2) {
|
|
1337
|
+
const pin = new Command2("pin").description("Pin and annotate agent sessions").argument("[session-id]", "session ID to pin").option("-t, --title <title>", "pin title").option("--tags <tags>", "comma-separated tags").option("--to <catalog>", "add pin to a catalog").option("--current", "pin the most recent session").action(async (sessionId, opts) => {
|
|
1338
|
+
if (!sessionId && !opts.current) {
|
|
1339
|
+
pin.help();
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
let targetSessionId = sessionId;
|
|
1343
|
+
if (opts.current && !targetSessionId) {
|
|
1344
|
+
const sessions = await findSessions(1);
|
|
1345
|
+
if (sessions.length === 0) {
|
|
1346
|
+
console.error(chalk3.red("No sessions found."));
|
|
1347
|
+
process.exit(1);
|
|
1348
|
+
}
|
|
1349
|
+
targetSessionId = sessions[0].session_id;
|
|
1350
|
+
}
|
|
1351
|
+
if (!targetSessionId) {
|
|
1352
|
+
console.error(chalk3.red("Please provide a session-id or use --current"));
|
|
1353
|
+
process.exit(1);
|
|
1354
|
+
}
|
|
1355
|
+
const { sessionId: resolvedSessionId, meta: existingMeta } = await resolveSessionOrSelect(targetSessionId);
|
|
1356
|
+
const meta = existingMeta;
|
|
1357
|
+
let resolvedCatalog;
|
|
1358
|
+
const existing = findBookmark(resolvedSessionId);
|
|
1359
|
+
if (existing) {
|
|
1360
|
+
if (opts.to) {
|
|
1361
|
+
const space = resolveCatalogRef(opts.to);
|
|
1362
|
+
if (!existing.space_ids.includes(space.id)) {
|
|
1363
|
+
existing.space_ids.push(space.id);
|
|
1364
|
+
updateBookmark(existing.id, { space_ids: existing.space_ids });
|
|
1365
|
+
console.log(chalk3.green(`Added ${existing.id} to catalog "${space.name}" (${space.id})`));
|
|
1366
|
+
} else {
|
|
1367
|
+
console.log(chalk3.yellow(`Already in catalog "${space.name}".`));
|
|
1368
|
+
}
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
console.log(chalk3.yellow(`Already pinned as: ${existing.id}`));
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1375
|
+
let spaceIds = [];
|
|
1376
|
+
if (opts.to) {
|
|
1377
|
+
const space = resolveCatalogRef(opts.to);
|
|
1378
|
+
spaceIds = [space.id];
|
|
1379
|
+
resolvedCatalog = { id: space.id, name: space.name };
|
|
1380
|
+
}
|
|
1381
|
+
const bookmark = {
|
|
1382
|
+
id: generateBookmarkId(listBookmarks()),
|
|
1383
|
+
provider: meta?.provider ?? "unknown",
|
|
1384
|
+
session_id: resolvedSessionId,
|
|
1385
|
+
title: opts.title ?? meta?.first_prompt?.slice(0, 60) ?? resolvedSessionId.slice(0, 16),
|
|
1386
|
+
category: "",
|
|
1387
|
+
tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : [],
|
|
1388
|
+
project_path: meta?.project_path ?? "",
|
|
1389
|
+
first_prompt: meta?.first_prompt ?? "",
|
|
1390
|
+
notes: [],
|
|
1391
|
+
space_ids: spaceIds,
|
|
1392
|
+
created_at: now,
|
|
1393
|
+
updated_at: now
|
|
1394
|
+
};
|
|
1395
|
+
addBookmark(bookmark);
|
|
1396
|
+
console.log(chalk3.green(`Pinned: ${bookmark.id}`));
|
|
1397
|
+
console.log(` Title: ${bookmark.title}`);
|
|
1398
|
+
console.log(` Tags: ${bookmark.tags.join(", ") || "(none)"}`);
|
|
1399
|
+
if (spaceIds.length > 0) {
|
|
1400
|
+
console.log(
|
|
1401
|
+
` Catalog: ${resolvedCatalog?.name ?? "Unknown"} (${resolvedCatalog?.id ?? opts.to})`
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
program2.addCommand(pin);
|
|
1406
|
+
}
|
|
1407
|
+
function resolveCatalogRef(ref) {
|
|
1408
|
+
const resolution = resolveCatalogReference(ref);
|
|
1409
|
+
if (resolution.kind === "found") return resolution.space;
|
|
1410
|
+
if (resolution.kind === "not_found") {
|
|
1411
|
+
console.error(chalk3.red(`Catalog not found: ${ref}`));
|
|
1412
|
+
process.exit(1);
|
|
1413
|
+
}
|
|
1414
|
+
console.error(chalk3.red(`Ambiguous catalog reference: ${ref}`));
|
|
1415
|
+
console.error(chalk3.red("Use a catalog path like parent/child or the catalog id."));
|
|
1416
|
+
for (const match of resolution.matches) {
|
|
1417
|
+
console.error(chalk3.gray(` ${catalogPath(match, listSpaces())} (${match.id})`));
|
|
1418
|
+
}
|
|
1419
|
+
process.exit(1);
|
|
1420
|
+
}
|
|
1421
|
+
async function resolveSessionOrSelect(input) {
|
|
1422
|
+
const candidates = await findSessionCandidates(input);
|
|
1423
|
+
if (candidates.length === 0) {
|
|
1424
|
+
console.error(chalk3.red(`No session matches: ${input}`));
|
|
1425
|
+
process.exit(1);
|
|
1426
|
+
}
|
|
1427
|
+
if (candidates.length === 1) {
|
|
1428
|
+
return { sessionId: candidates[0].session_id, meta: candidates[0] };
|
|
1429
|
+
}
|
|
1430
|
+
if (!stdin.isTTY) {
|
|
1431
|
+
console.error(chalk3.red(`Ambiguous session id: ${input}`));
|
|
1432
|
+
console.error(chalk3.red("Please rerun with full session id."));
|
|
1433
|
+
process.exit(1);
|
|
1434
|
+
}
|
|
1435
|
+
console.log(chalk3.yellow(`
|
|
1436
|
+
Found ${candidates.length} sessions for "${input}":`));
|
|
1437
|
+
candidates.forEach((candidate, index) => {
|
|
1438
|
+
const shortId = shortSessionId(candidate.session_id);
|
|
1439
|
+
const date = candidate.modified_at.slice(0, 16).replace("T", " ");
|
|
1440
|
+
const project = candidate.project_path ? candidate.project_path.length > 35 ? "\u2026" + candidate.project_path.slice(-34) : candidate.project_path : "-";
|
|
1441
|
+
const model = candidate.model || "-";
|
|
1442
|
+
const provider = candidate.provider === "codex" ? "codex" : "claude";
|
|
1443
|
+
console.log(
|
|
1444
|
+
` ${index + 1}. ${chalk3.cyan(shortId.padEnd(15))} ${chalk3.gray(provider.padEnd(7))} ${model.padEnd(18)} ${chalk3.gray(project.padEnd(38))} ${chalk3.gray(date)}`
|
|
1445
|
+
);
|
|
1446
|
+
});
|
|
1447
|
+
const rl = createInterface2({ input: stdin, output: stdout });
|
|
1448
|
+
const answer = await rl.question("Select one by number: ");
|
|
1449
|
+
rl.close();
|
|
1450
|
+
const choice = Number(answer.trim());
|
|
1451
|
+
if (!Number.isInteger(choice) || choice < 1 || choice > candidates.length) {
|
|
1452
|
+
console.error(chalk3.red(`Invalid selection: ${answer.trim() || "(empty)"}`));
|
|
1453
|
+
process.exit(1);
|
|
1454
|
+
}
|
|
1455
|
+
return { sessionId: candidates[choice - 1].session_id, meta: candidates[choice - 1] };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// src/commands/space.ts
|
|
1459
|
+
import { Command as Command3 } from "commander";
|
|
1460
|
+
import chalk4 from "chalk";
|
|
1461
|
+
import Table2 from "cli-table3";
|
|
1462
|
+
function registerSpaceCommand(program2) {
|
|
1463
|
+
const space = new Command3("catalog").alias("cat").description("Organize sessions into catalogs with hierarchical nesting");
|
|
1464
|
+
space.command("create <name>").description("Create a new catalog").option("-d, --description <desc>", "catalog description").option("--tags <tags>", "comma-separated tags").option("-p, --parent <parent>", "parent catalog name, path, or id").action((name, opts) => {
|
|
1465
|
+
let parentId = null;
|
|
1466
|
+
if (opts.parent) {
|
|
1467
|
+
const parent = resolveCatalogRef2(opts.parent);
|
|
1468
|
+
parentId = parent.id;
|
|
1469
|
+
}
|
|
1470
|
+
const isPathCreate = name.split("/").map((part) => part.trim()).filter(Boolean).length > 1;
|
|
1471
|
+
const created = createCatalogPath(name, parentId, {
|
|
1472
|
+
description: opts.description,
|
|
1473
|
+
tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
|
1474
|
+
allowExistingLeaf: isPathCreate
|
|
1475
|
+
});
|
|
1476
|
+
console.log(chalk4.green(`Created catalog: ${created.id} "${catalogPath(created)}"`));
|
|
1477
|
+
console.log(chalk4.gray(` Parent: ${created.parent_id ?? "-"}`));
|
|
1478
|
+
});
|
|
1479
|
+
space.command("list").alias("ls").description("List all catalogs (flat)").option("--pins", "show pins in each catalog").option("--json", "output as JSON").action((opts) => {
|
|
1480
|
+
const spaces = listSpaces();
|
|
1481
|
+
if (spaces.length === 0) {
|
|
1482
|
+
console.log(chalk4.yellow("No catalogs created yet."));
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const allBookmarks = listBookmarks();
|
|
1486
|
+
const rows = spaces.map((s) => {
|
|
1487
|
+
const pins = allBookmarks.filter((b) => b.space_ids.includes(s.id));
|
|
1488
|
+
const sessionCount = new Set(pins.map((b) => b.session_id)).size;
|
|
1489
|
+
const parentCatalog = s.parent_id ? spaces.find((candidate) => candidate.id === s.parent_id) : void 0;
|
|
1490
|
+
const parent = parentCatalog ? parentCatalog.name : s.parent_id ?? "-";
|
|
1491
|
+
return {
|
|
1492
|
+
space: s,
|
|
1493
|
+
id: s.id,
|
|
1494
|
+
name: s.name,
|
|
1495
|
+
sessions: sessionCount,
|
|
1496
|
+
pins: pins.length,
|
|
1497
|
+
parent,
|
|
1498
|
+
description: s.description || "-"
|
|
1499
|
+
};
|
|
1500
|
+
});
|
|
1501
|
+
if (opts.json) {
|
|
1502
|
+
const output = rows.map((row) => {
|
|
1503
|
+
if (opts.pins) {
|
|
1504
|
+
const pins = allBookmarks.filter((b) => b.space_ids.includes(row.id));
|
|
1505
|
+
return { ...row.space, session_count: row.sessions, pin_count: row.pins, pins };
|
|
1506
|
+
}
|
|
1507
|
+
return { ...row.space, session_count: row.sessions, pin_count: row.pins };
|
|
1508
|
+
});
|
|
1509
|
+
console.log(JSON.stringify(output, null, 2));
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
const table = new Table2({
|
|
1513
|
+
head: [
|
|
1514
|
+
chalk4.green("Catalog ID"),
|
|
1515
|
+
chalk4.green("Name"),
|
|
1516
|
+
chalk4.green("Sessions"),
|
|
1517
|
+
chalk4.green("Pins"),
|
|
1518
|
+
chalk4.green("Parent"),
|
|
1519
|
+
chalk4.green("Description")
|
|
1520
|
+
],
|
|
1521
|
+
colWidths: [12, 20, 10, 10, 20, 34],
|
|
1522
|
+
style: { head: [] }
|
|
1523
|
+
});
|
|
1524
|
+
const truncate2 = (value, max) => value.length > max ? value.slice(0, max - 1) + "\u2026" : value;
|
|
1525
|
+
for (const row of rows) {
|
|
1526
|
+
table.push([
|
|
1527
|
+
row.id,
|
|
1528
|
+
chalk4.bold(row.name),
|
|
1529
|
+
String(row.sessions),
|
|
1530
|
+
String(row.pins),
|
|
1531
|
+
row.parent,
|
|
1532
|
+
truncate2(row.description, 34)
|
|
1533
|
+
]);
|
|
1534
|
+
}
|
|
1535
|
+
console.log(table.toString());
|
|
1536
|
+
if (opts.pins) {
|
|
1537
|
+
for (const row of rows) {
|
|
1538
|
+
const pins = allBookmarks.filter((b) => b.space_ids.includes(row.id));
|
|
1539
|
+
if (pins.length === 0) continue;
|
|
1540
|
+
console.log(`
|
|
1541
|
+
${chalk4.yellow(`Pins in ${row.name} (${row.id})`)}`);
|
|
1542
|
+
for (const p of pins) {
|
|
1543
|
+
const shortId = p.session_id.length > 13 ? shortSessionId(p.session_id) + "\u2026" : p.session_id;
|
|
1544
|
+
console.log(` ${chalk4.cyan(p.id)} ${p.title} ${chalk4.gray(shortId)} ${chalk4.gray(p.provider)}`);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
space.command("tree").description("Display catalogs as a hierarchical tree").action(() => {
|
|
1550
|
+
const spaces = listSpaces();
|
|
1551
|
+
const bookmarks = listBookmarks();
|
|
1552
|
+
console.log(formatSpaceTree(spaces, bookmarks));
|
|
1553
|
+
});
|
|
1554
|
+
space.command("add <catalog> <session-id>").description("Add a session to a catalog").option("-t, --title <title>", "pin title when creating a new pin").option("--tags <tags>", "comma-separated tags when creating a new pin").action(async (catalog, sessionId, opts) => {
|
|
1555
|
+
const s = resolveCatalogRef2(catalog);
|
|
1556
|
+
const existing = findBookmarkBySessionRef(sessionId);
|
|
1557
|
+
if (existing) {
|
|
1558
|
+
if (existing.space_ids.includes(s.id)) {
|
|
1559
|
+
console.log(chalk4.yellow(`Already in catalog "${s.name}".`));
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
updateBookmark(existing.id, { space_ids: [...existing.space_ids, s.id] });
|
|
1563
|
+
console.log(chalk4.green(`Added ${existing.id} to catalog "${s.name}" (${s.id})`));
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
const meta = await resolveSessionMeta2(sessionId);
|
|
1567
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1568
|
+
const bookmark = {
|
|
1569
|
+
id: generateBookmarkId(listBookmarks()),
|
|
1570
|
+
provider: meta.provider || "unknown",
|
|
1571
|
+
session_id: meta.session_id,
|
|
1572
|
+
title: opts.title ?? meta.first_prompt?.slice(0, 60) ?? meta.session_id.slice(0, 16),
|
|
1573
|
+
category: "",
|
|
1574
|
+
tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
|
1575
|
+
project_path: meta.project_path ?? "",
|
|
1576
|
+
first_prompt: meta.first_prompt ?? "",
|
|
1577
|
+
notes: [],
|
|
1578
|
+
space_ids: [s.id],
|
|
1579
|
+
created_at: now,
|
|
1580
|
+
updated_at: now
|
|
1581
|
+
};
|
|
1582
|
+
addBookmark(bookmark);
|
|
1583
|
+
console.log(chalk4.green(`Added ${bookmark.id} to catalog "${s.name}" (${s.id})`));
|
|
1584
|
+
});
|
|
1585
|
+
space.command("show <name>").description("Show catalog details and contents").action((name) => {
|
|
1586
|
+
const s = resolveCatalogRef2(name);
|
|
1587
|
+
const pins = listBookmarks().filter((b) => b.space_ids.includes(s.id));
|
|
1588
|
+
const sessions = new Set(pins.map((b) => b.session_id)).size;
|
|
1589
|
+
const updated = s.updated_at.slice(0, 10);
|
|
1590
|
+
console.log(chalk4.bold(`Catalog: ${s.name}`));
|
|
1591
|
+
console.log(`Description: ${s.description || "(none)"}`);
|
|
1592
|
+
console.log(`Pins: ${pins.length}`);
|
|
1593
|
+
console.log(`Sessions: ${sessions}`);
|
|
1594
|
+
console.log(`Tags: ${s.tags.join(", ") || "(none)"}`);
|
|
1595
|
+
console.log(`Updated: ${updated}`);
|
|
1596
|
+
if (pins.length > 0) {
|
|
1597
|
+
console.log("");
|
|
1598
|
+
for (const p of pins) {
|
|
1599
|
+
const shortId = p.session_id.length > 36 ? shortSessionId(p.session_id) + "\u2026" : p.session_id;
|
|
1600
|
+
console.log(` ${chalk4.cyan(p.id)} ${p.title} ${chalk4.gray(shortId)} ${chalk4.gray(p.provider)}`);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1604
|
+
space.command("detach <catalog> <session-id>").description("Detach a session from a catalog").action((catalog, sessionId) => {
|
|
1605
|
+
const s = resolveCatalogRef2(catalog);
|
|
1606
|
+
const bookmark = findBookmarkBySessionRef(sessionId);
|
|
1607
|
+
if (!bookmark) {
|
|
1608
|
+
console.error(chalk4.red(`Session pin not found: ${sessionId}`));
|
|
1609
|
+
process.exit(1);
|
|
1610
|
+
}
|
|
1611
|
+
if (!bookmark.space_ids.includes(s.id)) {
|
|
1612
|
+
console.log(chalk4.yellow(`Session is not in catalog "${s.name}".`));
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const spaceIds = bookmark.space_ids.filter((sid) => sid !== s.id);
|
|
1616
|
+
updateBookmark(bookmark.id, { space_ids: spaceIds });
|
|
1617
|
+
console.log(chalk4.green(`Removed "${bookmark.title}" from catalog "${s.name}"`));
|
|
1618
|
+
});
|
|
1619
|
+
space.command("clear <catalog>").description("Remove all sessions from a catalog").action((catalog) => {
|
|
1620
|
+
const s = resolveCatalogRef2(catalog);
|
|
1621
|
+
for (const bookmark of listBookmarks()) {
|
|
1622
|
+
if (!bookmark.space_ids.includes(s.id)) continue;
|
|
1623
|
+
updateBookmark(bookmark.id, {
|
|
1624
|
+
space_ids: bookmark.space_ids.filter((sid) => sid !== s.id)
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
console.log(chalk4.green(`Cleared catalog: "${s.name}" (${s.id})`));
|
|
1628
|
+
});
|
|
1629
|
+
space.command("delete <catalog>").alias("del").description("Remove a catalog").action((catalog) => {
|
|
1630
|
+
const s = resolveCatalogRef2(catalog);
|
|
1631
|
+
removeSpace(s.id);
|
|
1632
|
+
console.log(chalk4.green(`Removed catalog: "${s.name}" (${s.id})`));
|
|
1633
|
+
});
|
|
1634
|
+
space.command("tag <name> <tags...>").description("Add tags to a catalog").action((name, newTags) => {
|
|
1635
|
+
const s = resolveCatalogRef2(name);
|
|
1636
|
+
const merged = [.../* @__PURE__ */ new Set([...s.tags, ...newTags])];
|
|
1637
|
+
updateSpace(s.id, { tags: merged });
|
|
1638
|
+
console.log(chalk4.green(`Tagged "${s.name}": ${merged.join(", ")}`));
|
|
1639
|
+
});
|
|
1640
|
+
space.command("edit <name>").description("Edit catalog metadata").option("-d, --description <desc>", "new description").option("--rename <new-name>", "rename the catalog").option("--parent <parent>", "set parent catalog").action((name, opts) => {
|
|
1641
|
+
const s = resolveCatalogRef2(name);
|
|
1642
|
+
const patch = {};
|
|
1643
|
+
if (opts.description) patch.description = opts.description;
|
|
1644
|
+
if (opts.rename) patch.name = opts.rename;
|
|
1645
|
+
if (opts.parent) {
|
|
1646
|
+
const parent = resolveCatalogRef2(opts.parent);
|
|
1647
|
+
if (parent.id === s.id) {
|
|
1648
|
+
console.error(chalk4.red("A catalog cannot be its own parent."));
|
|
1649
|
+
process.exit(1);
|
|
1650
|
+
}
|
|
1651
|
+
if (isDescendantCatalog(parent, s, listSpaces())) {
|
|
1652
|
+
console.error(chalk4.red("A catalog cannot use its descendant as parent."));
|
|
1653
|
+
process.exit(1);
|
|
1654
|
+
}
|
|
1655
|
+
patch.parent_id = parent.id;
|
|
1656
|
+
}
|
|
1657
|
+
const nextName = patch.name ?? s.name;
|
|
1658
|
+
const nextParentId = patch.parent_id ?? s.parent_id;
|
|
1659
|
+
if (hasSiblingSpaceName(nextName, nextParentId, s.id)) {
|
|
1660
|
+
console.error(chalk4.red(`Catalog already exists under this parent: ${nextName}`));
|
|
1661
|
+
process.exit(1);
|
|
1662
|
+
}
|
|
1663
|
+
const updated = updateSpace(s.id, patch);
|
|
1664
|
+
if (updated) {
|
|
1665
|
+
console.log(chalk4.green(`Updated catalog: "${updated.name}" (${updated.id})`));
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
program2.addCommand(space);
|
|
1669
|
+
}
|
|
1670
|
+
function isDescendantCatalog(candidate, root, spaces) {
|
|
1671
|
+
let current = candidate;
|
|
1672
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1673
|
+
while (current?.parent_id) {
|
|
1674
|
+
if (current.parent_id === root.id) return true;
|
|
1675
|
+
if (seen.has(current.parent_id)) return false;
|
|
1676
|
+
seen.add(current.parent_id);
|
|
1677
|
+
current = spaces.find((space) => space.id === current?.parent_id);
|
|
1678
|
+
}
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
function createCatalogPath(pathRef, parentId, opts) {
|
|
1682
|
+
const parts = pathRef.split("/").map((part) => part.trim()).filter(Boolean);
|
|
1683
|
+
if (parts.length === 0) {
|
|
1684
|
+
console.error(chalk4.red("Catalog name cannot be empty."));
|
|
1685
|
+
process.exit(1);
|
|
1686
|
+
}
|
|
1687
|
+
let currentParentId = parentId;
|
|
1688
|
+
let currentSpace;
|
|
1689
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
1690
|
+
const part = parts[index];
|
|
1691
|
+
const existing = findSiblingSpace(part, currentParentId);
|
|
1692
|
+
const isLeaf = index === parts.length - 1;
|
|
1693
|
+
if (existing) {
|
|
1694
|
+
if (isLeaf && !opts.allowExistingLeaf) {
|
|
1695
|
+
console.error(chalk4.red(`Catalog already exists under this parent: ${part}`));
|
|
1696
|
+
process.exit(1);
|
|
1697
|
+
}
|
|
1698
|
+
currentSpace = existing;
|
|
1699
|
+
currentParentId = existing.id;
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1703
|
+
currentSpace = {
|
|
1704
|
+
id: generateSpaceId(listSpaces()),
|
|
1705
|
+
name: part,
|
|
1706
|
+
description: isLeaf ? opts.description ?? "" : "",
|
|
1707
|
+
tags: isLeaf ? opts.tags ?? [] : [],
|
|
1708
|
+
parent_id: currentParentId,
|
|
1709
|
+
created_at: now,
|
|
1710
|
+
updated_at: now
|
|
1711
|
+
};
|
|
1712
|
+
addSpace(currentSpace);
|
|
1713
|
+
currentParentId = currentSpace.id;
|
|
1714
|
+
}
|
|
1715
|
+
return currentSpace;
|
|
1716
|
+
}
|
|
1717
|
+
function findSiblingSpace(name, parentId) {
|
|
1718
|
+
return listSpaces().find((space) => space.name === name && space.parent_id === parentId);
|
|
1719
|
+
}
|
|
1720
|
+
function resolveCatalogRef2(ref) {
|
|
1721
|
+
const resolution = resolveCatalogReference(ref);
|
|
1722
|
+
if (resolution.kind === "found") {
|
|
1723
|
+
return resolution.space;
|
|
1724
|
+
}
|
|
1725
|
+
if (resolution.kind === "not_found") {
|
|
1726
|
+
console.error(chalk4.red(`Catalog not found: ${ref}`));
|
|
1727
|
+
process.exit(1);
|
|
1728
|
+
}
|
|
1729
|
+
console.error(chalk4.red(`Ambiguous catalog reference: ${ref}`));
|
|
1730
|
+
console.error(chalk4.red("Use a catalog path like parent/child or the catalog id."));
|
|
1731
|
+
for (const match of resolution.matches) {
|
|
1732
|
+
console.error(chalk4.gray(` ${catalogPath(match, listSpaces())} (${match.id})`));
|
|
1733
|
+
}
|
|
1734
|
+
process.exit(1);
|
|
1735
|
+
}
|
|
1736
|
+
function findBookmarkBySessionRef(ref) {
|
|
1737
|
+
return listBookmarks().find((bookmark) => bookmark.id === ref || bookmark.session_id === ref);
|
|
1738
|
+
}
|
|
1739
|
+
async function resolveSessionMeta2(input) {
|
|
1740
|
+
const candidates = await findSessionCandidates(input);
|
|
1741
|
+
if (candidates.length === 0) {
|
|
1742
|
+
console.error(chalk4.red(`No session matches: ${input}`));
|
|
1743
|
+
process.exit(1);
|
|
1744
|
+
}
|
|
1745
|
+
if (candidates.length > 1) {
|
|
1746
|
+
const exact = candidates.find((candidate) => candidate.session_id === input);
|
|
1747
|
+
if (exact) return exact;
|
|
1748
|
+
console.error(chalk4.red(`Ambiguous session id: ${input}`));
|
|
1749
|
+
console.error(chalk4.red("Please rerun with full session id."));
|
|
1750
|
+
process.exit(1);
|
|
1751
|
+
}
|
|
1752
|
+
return candidates[0];
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// src/commands/project.ts
|
|
1756
|
+
import { Command as Command4 } from "commander";
|
|
1757
|
+
import chalk5 from "chalk";
|
|
1758
|
+
import Table3 from "cli-table3";
|
|
1759
|
+
async function aggregateByProject(providerFilter, limit, useIndex = true, refreshIndex = false) {
|
|
1760
|
+
if (useIndex) {
|
|
1761
|
+
const index = refreshIndex ? await rebuildSessionIndex(providerFilter) : await loadSessionIndexWithNewFiles(providerFilter);
|
|
1762
|
+
if (index) {
|
|
1763
|
+
return aggregateProjectsFromSessions(index.sessions, providerFilter);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
const map = /* @__PURE__ */ new Map();
|
|
1767
|
+
let count = 0;
|
|
1768
|
+
for await (const meta of streamSessions(providerFilter)) {
|
|
1769
|
+
if (limit && ++count > limit) break;
|
|
1770
|
+
const key = meta.project_path || "(unknown)";
|
|
1771
|
+
let stats = map.get(key);
|
|
1772
|
+
if (!stats) {
|
|
1773
|
+
stats = {
|
|
1774
|
+
project_path: key,
|
|
1775
|
+
session_count: 0,
|
|
1776
|
+
agents: {},
|
|
1777
|
+
models: {},
|
|
1778
|
+
first_active: meta.modified_at,
|
|
1779
|
+
last_active: meta.modified_at,
|
|
1780
|
+
sessions: []
|
|
1781
|
+
};
|
|
1782
|
+
map.set(key, stats);
|
|
1783
|
+
}
|
|
1784
|
+
stats.session_count++;
|
|
1785
|
+
stats.agents[meta.provider] = (stats.agents[meta.provider] || 0) + 1;
|
|
1786
|
+
const model = meta.model || "-";
|
|
1787
|
+
stats.models[model] = (stats.models[model] || 0) + 1;
|
|
1788
|
+
if (meta.modified_at < stats.first_active) stats.first_active = meta.modified_at;
|
|
1789
|
+
if (meta.modified_at > stats.last_active) stats.last_active = meta.modified_at;
|
|
1790
|
+
stats.sessions.push(meta);
|
|
1791
|
+
}
|
|
1792
|
+
const projects = [...map.values()];
|
|
1793
|
+
projects.sort((a, b) => b.last_active.localeCompare(a.last_active));
|
|
1794
|
+
return projects;
|
|
1795
|
+
}
|
|
1796
|
+
function shortPath(p, maxLen) {
|
|
1797
|
+
if (p.length <= maxLen) return p;
|
|
1798
|
+
return "\u2026" + p.slice(-(maxLen - 1));
|
|
1799
|
+
}
|
|
1800
|
+
function formatAgentModelSummary(counts) {
|
|
1801
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}(${v})`).join(", ");
|
|
1802
|
+
}
|
|
1803
|
+
function topModel(models) {
|
|
1804
|
+
const entries = Object.entries(models).sort((a, b) => b[1] - a[1]);
|
|
1805
|
+
return entries[0]?.[0] || "-";
|
|
1806
|
+
}
|
|
1807
|
+
function parseLimit(value) {
|
|
1808
|
+
return parseInt(value || "100", 10) || 100;
|
|
1809
|
+
}
|
|
1810
|
+
function registerProjectCommand(program2) {
|
|
1811
|
+
const project = new Command4("project").alias("prj").description("Manage projects \u2014 aggregate sessions by project directory");
|
|
1812
|
+
project.command("list").alias("ls").description("List all projects with session statistics").option("-a, --agent <agent>", "filter by agent: claude | codex").option("-n, --limit <number>", "max projects to show", "100").option("--all", "show all projects").option("--refresh-index", "rebuild ~/.starling/session-index.json before listing").option("--no-index", "scan session files instead of using ~/.starling/session-index.json").option("--json", "output as JSON").action(
|
|
1813
|
+
async (opts) => {
|
|
1814
|
+
const provider = opts.agent;
|
|
1815
|
+
const projectLimit = opts.all ? void 0 : parseLimit(opts.limit);
|
|
1816
|
+
const scanLimit = opts.index === false ? projectLimit : void 0;
|
|
1817
|
+
const allProjects = await aggregateByProject(provider, scanLimit, opts.index !== false, Boolean(opts.refreshIndex));
|
|
1818
|
+
const projects = projectLimit ? allProjects.slice(0, projectLimit) : allProjects;
|
|
1819
|
+
if (projects.length === 0) {
|
|
1820
|
+
if (opts.json) {
|
|
1821
|
+
console.log("[]");
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
console.log(chalk5.yellow("No projects found."));
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
if (opts.json) {
|
|
1828
|
+
console.log(
|
|
1829
|
+
JSON.stringify(
|
|
1830
|
+
projects.map(({ sessions, ...rest }) => rest),
|
|
1831
|
+
null,
|
|
1832
|
+
2
|
|
1833
|
+
)
|
|
1834
|
+
);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const table = new Table3({
|
|
1838
|
+
head: [
|
|
1839
|
+
chalk5.gray("PROJECT"),
|
|
1840
|
+
chalk5.gray("SESSIONS"),
|
|
1841
|
+
chalk5.gray("AGENTS"),
|
|
1842
|
+
chalk5.gray("TOP MODEL"),
|
|
1843
|
+
chalk5.gray("LAST ACTIVE")
|
|
1844
|
+
],
|
|
1845
|
+
colWidths: [42, 10, 18, 22, 20],
|
|
1846
|
+
style: { head: [], border: ["gray"] },
|
|
1847
|
+
chars: {
|
|
1848
|
+
mid: "",
|
|
1849
|
+
"left-mid": "",
|
|
1850
|
+
"mid-mid": "",
|
|
1851
|
+
"right-mid": ""
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1854
|
+
for (const p of projects) {
|
|
1855
|
+
table.push([
|
|
1856
|
+
shortPath(p.project_path, 40),
|
|
1857
|
+
String(p.session_count),
|
|
1858
|
+
formatAgentModelSummary(p.agents),
|
|
1859
|
+
topModel(p.models),
|
|
1860
|
+
p.last_active.slice(0, 16).replace("T", " ")
|
|
1861
|
+
]);
|
|
1862
|
+
}
|
|
1863
|
+
console.log(table.toString());
|
|
1864
|
+
}
|
|
1865
|
+
);
|
|
1866
|
+
project.command("show <path>").description("Show project details and session list").option("-a, --agent <agent>", "filter by agent: claude | codex").option("--refresh-index", "rebuild ~/.starling/session-index.json before showing").option("--no-index", "scan session files instead of using ~/.starling/session-index.json").option("--json", "output as JSON").action(
|
|
1867
|
+
async (path, opts) => {
|
|
1868
|
+
const provider = opts.agent;
|
|
1869
|
+
const projects = await aggregateByProject(provider, void 0, opts.index !== false, Boolean(opts.refreshIndex));
|
|
1870
|
+
const p = projects.find(
|
|
1871
|
+
(pr) => pr.project_path === path || pr.project_path.endsWith(path) || pr.project_path.endsWith("/" + path)
|
|
1872
|
+
);
|
|
1873
|
+
if (!p) {
|
|
1874
|
+
console.error(chalk5.red(`Project not found: ${path}`));
|
|
1875
|
+
process.exit(1);
|
|
1876
|
+
}
|
|
1877
|
+
if (opts.json) {
|
|
1878
|
+
console.log(JSON.stringify(p, null, 2));
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
console.log(chalk5.bold(`Project: ${p.project_path}`));
|
|
1882
|
+
console.log(` Sessions: ${p.session_count}`);
|
|
1883
|
+
console.log(` Agents: ${formatAgentModelSummary(p.agents)}`);
|
|
1884
|
+
console.log(` Models: ${formatAgentModelSummary(p.models)}`);
|
|
1885
|
+
console.log(
|
|
1886
|
+
` First session: ${p.first_active.slice(0, 10)}`
|
|
1887
|
+
);
|
|
1888
|
+
console.log(
|
|
1889
|
+
` Last active: ${p.last_active.slice(0, 16).replace("T", " ")}`
|
|
1890
|
+
);
|
|
1891
|
+
console.log("");
|
|
1892
|
+
console.log(chalk5.bold("Recent sessions:"));
|
|
1893
|
+
const sorted = [...p.sessions].sort(
|
|
1894
|
+
(a, b) => b.modified_at.localeCompare(a.modified_at)
|
|
1895
|
+
);
|
|
1896
|
+
for (const s of sorted.slice(0, 20)) {
|
|
1897
|
+
const short = shortSessionId(s.session_id);
|
|
1898
|
+
const agent = s.provider === "codex" ? "codex" : "claude";
|
|
1899
|
+
const date = s.modified_at.slice(0, 16).replace("T", " ");
|
|
1900
|
+
const prompt = s.first_prompt ? s.first_prompt.length > 40 ? s.first_prompt.slice(0, 37) + "\u2026" : s.first_prompt : "";
|
|
1901
|
+
console.log(
|
|
1902
|
+
` ${chalk5.cyan(short)} ${chalk5.gray(agent.padEnd(7))} ${(s.model || "-").padEnd(22)} ${chalk5.gray(date)} ${chalk5.gray(prompt)}`
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
);
|
|
1907
|
+
program2.addCommand(project);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// src/commands/run.ts
|
|
1911
|
+
import { Command as Command5 } from "commander";
|
|
1912
|
+
import chalk6 from "chalk";
|
|
1913
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1914
|
+
import { chmodSync as chmodSync4, existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync4, statSync as statSync4, unlinkSync as unlinkSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
1915
|
+
import { createInterface as createInterface3 } from "readline/promises";
|
|
1916
|
+
import { spawn as spawn2 } from "child_process";
|
|
1917
|
+
import { basename as basename2, extname as extname2, isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "path";
|
|
1918
|
+
|
|
1919
|
+
// src/lib/codexProvider.ts
|
|
1920
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync as readdirSync3, writeFileSync as writeFileSync2, chmodSync as chmodSync2, unlinkSync as unlinkSync4, renameSync as renameSync2 } from "fs";
|
|
1921
|
+
import { basename, extname, isAbsolute, join as join5, resolve } from "path";
|
|
1922
|
+
var CODEX_PROVIDER_HISTORY_PATH = join5(DEFAULT_STARLING_HOME, "codex-provider.json");
|
|
1923
|
+
var CODEX_PROVIDER_EXTENSIONS = [".toml", ".json", ".jsonc"];
|
|
1924
|
+
function resolveCodexConfigPath(nameOrPath) {
|
|
1925
|
+
if (!nameOrPath) return null;
|
|
1926
|
+
if (isAbsolute(nameOrPath) || existsSync4(nameOrPath)) {
|
|
1927
|
+
if (!existsSync4(nameOrPath)) {
|
|
1928
|
+
return null;
|
|
1929
|
+
}
|
|
1930
|
+
return resolve(nameOrPath);
|
|
1931
|
+
}
|
|
1932
|
+
const base = join5(DEFAULT_CODEX_SETTINGS_DIR, basename(nameOrPath));
|
|
1933
|
+
const extension = extname(base);
|
|
1934
|
+
if (extension && existsSync4(base)) return base;
|
|
1935
|
+
if (extension) return null;
|
|
1936
|
+
for (const ext of CODEX_PROVIDER_EXTENSIONS) {
|
|
1937
|
+
const candidate = `${base}${ext}`;
|
|
1938
|
+
if (existsSync4(candidate)) return candidate;
|
|
1939
|
+
}
|
|
1940
|
+
return null;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// src/lib/codexChatProxy.ts
|
|
1944
|
+
import { createServer } from "http";
|
|
1945
|
+
import { randomUUID } from "crypto";
|
|
1946
|
+
var JSON_HEADERS = { "content-type": "application/json" };
|
|
1947
|
+
var SSE_HEADERS = {
|
|
1948
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1949
|
+
"cache-control": "no-cache",
|
|
1950
|
+
connection: "keep-alive"
|
|
1951
|
+
};
|
|
1952
|
+
async function startCodexChatProxy(options) {
|
|
1953
|
+
const history = /* @__PURE__ */ new Map();
|
|
1954
|
+
const upstreamBaseUrl = normalizeUpstreamBaseUrl(options.upstreamBaseUrl);
|
|
1955
|
+
const server = createServer(async (req, res) => {
|
|
1956
|
+
try {
|
|
1957
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
1958
|
+
if (req.method === "GET" && isModelsPath(url.pathname)) {
|
|
1959
|
+
await handleModels(req, res, upstreamBaseUrl, options.apiKey, url.search);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
if (req.method === "POST" && isResponsesPath(url.pathname)) {
|
|
1963
|
+
const body = await readJsonBody(req);
|
|
1964
|
+
await handleResponses(res, body, {
|
|
1965
|
+
upstreamBaseUrl,
|
|
1966
|
+
apiKey: options.apiKey,
|
|
1967
|
+
defaultModel: options.model,
|
|
1968
|
+
history
|
|
1969
|
+
});
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
writeJson(res, 404, { error: { message: `Unsupported Codex proxy path: ${url.pathname}` } });
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
writeJson(res, 500, {
|
|
1975
|
+
error: {
|
|
1976
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1977
|
+
type: "starling_codex_proxy_error"
|
|
1978
|
+
}
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
await new Promise((resolve3, reject) => {
|
|
1983
|
+
server.once("error", reject);
|
|
1984
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1985
|
+
server.off("error", reject);
|
|
1986
|
+
resolve3();
|
|
1987
|
+
});
|
|
1988
|
+
});
|
|
1989
|
+
const address = server.address();
|
|
1990
|
+
if (!address || typeof address === "string") {
|
|
1991
|
+
throw new Error("Could not resolve Starling Codex proxy listen address.");
|
|
1992
|
+
}
|
|
1993
|
+
return {
|
|
1994
|
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
|
1995
|
+
close: () => closeServer(server)
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
function closeServer(server) {
|
|
1999
|
+
return new Promise((resolve3) => {
|
|
2000
|
+
server.close(() => resolve3());
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
async function handleModels(req, res, upstreamBaseUrl, apiKey, search) {
|
|
2004
|
+
const upstream = await fetch(`${upstreamBaseUrl}/models${search}`, {
|
|
2005
|
+
method: "GET",
|
|
2006
|
+
headers: forwardHeaders(req, apiKey)
|
|
2007
|
+
});
|
|
2008
|
+
const body = await upstream.json().catch(() => null);
|
|
2009
|
+
if (!upstream.ok) {
|
|
2010
|
+
writeJson(res, upstream.status, body ?? { models: [] });
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
writeJson(res, 200, normalizeModelsResponse(body));
|
|
2014
|
+
}
|
|
2015
|
+
async function handleResponses(res, body, context) {
|
|
2016
|
+
if (!isRecord3(body)) {
|
|
2017
|
+
writeJson(res, 400, { error: { message: "Responses request body must be a JSON object." } });
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
const chatRequest = responsesToChatRequest(body, context.defaultModel, context.history);
|
|
2021
|
+
const upstream = await fetch(`${context.upstreamBaseUrl}/chat/completions`, {
|
|
2022
|
+
method: "POST",
|
|
2023
|
+
headers: {
|
|
2024
|
+
"content-type": "application/json",
|
|
2025
|
+
authorization: `Bearer ${context.apiKey}`
|
|
2026
|
+
},
|
|
2027
|
+
body: JSON.stringify(chatRequest)
|
|
2028
|
+
});
|
|
2029
|
+
const contentType = upstream.headers.get("content-type") || "";
|
|
2030
|
+
if (!upstream.ok) {
|
|
2031
|
+
const errorText = await upstream.text();
|
|
2032
|
+
writeJson(res, upstream.status, chatErrorToResponsesError(errorText, upstream.status));
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (chatRequest.stream || contentType.includes("text/event-stream")) {
|
|
2036
|
+
await streamChatToResponses(upstream, res, chatRequest.model, context.history);
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
const chatResponse = await upstream.json();
|
|
2040
|
+
const { response, storedMessages } = chatCompletionToResponse(chatResponse, chatRequest.model);
|
|
2041
|
+
if (typeof response.id === "string") {
|
|
2042
|
+
context.history.set(response.id, { messages: [...chatRequest.messages, ...storedMessages] });
|
|
2043
|
+
}
|
|
2044
|
+
writeJson(res, 200, response);
|
|
2045
|
+
}
|
|
2046
|
+
function responsesToChatRequest(body, defaultModel, history) {
|
|
2047
|
+
const model = stringValue(body.model) || defaultModel || "deepseek-v4-pro";
|
|
2048
|
+
const messages = [];
|
|
2049
|
+
const previousResponseId = stringValue(body.previous_response_id);
|
|
2050
|
+
if (previousResponseId) {
|
|
2051
|
+
messages.push(...history.get(previousResponseId)?.messages ?? []);
|
|
2052
|
+
}
|
|
2053
|
+
const instructions = stringValue(body.instructions);
|
|
2054
|
+
if (instructions) messages.push({ role: "system", content: instructions });
|
|
2055
|
+
messages.push(...responsesInputToChatMessages(body.input));
|
|
2056
|
+
const result = {
|
|
2057
|
+
model,
|
|
2058
|
+
messages,
|
|
2059
|
+
stream: body.stream !== false
|
|
2060
|
+
};
|
|
2061
|
+
const tools = responsesToolsToChatTools(body.tools);
|
|
2062
|
+
if (tools.length > 0) result.tools = tools;
|
|
2063
|
+
copyIfPresent(body, result, "temperature");
|
|
2064
|
+
copyIfPresent(body, result, "top_p");
|
|
2065
|
+
copyIfPresent(body, result, "parallel_tool_calls");
|
|
2066
|
+
copyIfPresent(body, result, "tool_choice");
|
|
2067
|
+
copyIfPresent(body, result, "stop");
|
|
2068
|
+
copyIfPresent(body, result, "frequency_penalty");
|
|
2069
|
+
copyIfPresent(body, result, "presence_penalty");
|
|
2070
|
+
copyIfPresent(body, result, "seed");
|
|
2071
|
+
copyIfPresent(body, result, "stream_options");
|
|
2072
|
+
copyIfPresent(body, result, "n");
|
|
2073
|
+
if (typeof body.max_output_tokens === "number") result.max_tokens = body.max_output_tokens;
|
|
2074
|
+
if (typeof body.max_completion_tokens === "number") result.max_tokens = body.max_completion_tokens;
|
|
2075
|
+
const effort = readReasoningEffort(body);
|
|
2076
|
+
if (effort) result.reasoning_effort = effort;
|
|
2077
|
+
const reasoningObject = body.reasoning;
|
|
2078
|
+
if (typeof reasoningObject === "string" && reasoningObject.trim()) {
|
|
2079
|
+
copyIfPresent(body, result, "reasoning");
|
|
2080
|
+
} else if (isRecord3(reasoningObject) && typeof reasoningObject.effort === "string") {
|
|
2081
|
+
result.reasoning_effort = reasoningObject.effort;
|
|
2082
|
+
copyIfPresent(body, result, "reasoning");
|
|
2083
|
+
}
|
|
2084
|
+
return result;
|
|
2085
|
+
}
|
|
2086
|
+
function responsesInputToChatMessages(input) {
|
|
2087
|
+
if (typeof input === "string") return [{ role: "user", content: input }];
|
|
2088
|
+
if (!Array.isArray(input)) return [];
|
|
2089
|
+
const messages = [];
|
|
2090
|
+
let pendingToolCalls = [];
|
|
2091
|
+
const pendingReasoning = [];
|
|
2092
|
+
const flushPendingReasoning = () => {
|
|
2093
|
+
if (pendingReasoning.length === 0) return;
|
|
2094
|
+
messages.push({
|
|
2095
|
+
role: "system",
|
|
2096
|
+
content: `Reasoning: ${pendingReasoning.join("\n")}`
|
|
2097
|
+
});
|
|
2098
|
+
pendingReasoning.length = 0;
|
|
2099
|
+
};
|
|
2100
|
+
const flushPendingToolCalls = () => {
|
|
2101
|
+
if (pendingToolCalls.length === 0) return;
|
|
2102
|
+
flushPendingReasoning();
|
|
2103
|
+
messages.push({ role: "assistant", content: null, tool_calls: pendingToolCalls });
|
|
2104
|
+
pendingToolCalls = [];
|
|
2105
|
+
};
|
|
2106
|
+
for (const item of input) {
|
|
2107
|
+
if (!isRecord3(item)) continue;
|
|
2108
|
+
const type = stringValue(item.type);
|
|
2109
|
+
if (type === "function_call" || type === "custom_tool_call" || type === "tool_search_call") {
|
|
2110
|
+
const callId = stringValue(item.call_id) || stringValue(item.id) || `call_${randomUUID().replace(/-/g, "")}`;
|
|
2111
|
+
const namespace = stringValue(item.namespace);
|
|
2112
|
+
const callName = stringValue(item.name) || stringValue(item.tool_name) || "tool_call";
|
|
2113
|
+
const name = safeChatToolName(namespace ? `${namespace}_${callName}` : callName);
|
|
2114
|
+
const args = stringValue(item.arguments) || stringifyContent(item.input) || "{}";
|
|
2115
|
+
pendingToolCalls.push({
|
|
2116
|
+
id: callId,
|
|
2117
|
+
type: "function",
|
|
2118
|
+
function: {
|
|
2119
|
+
name,
|
|
2120
|
+
arguments: args
|
|
2121
|
+
}
|
|
2122
|
+
});
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
if (type === "function_call_output" || type === "custom_tool_call_output" || type === "tool_search_output") {
|
|
2126
|
+
flushPendingToolCalls();
|
|
2127
|
+
const callId = stringValue(item.call_id) || stringValue(item.id) || "";
|
|
2128
|
+
messages.push({
|
|
2129
|
+
role: "tool",
|
|
2130
|
+
tool_call_id: callId || void 0,
|
|
2131
|
+
content: stringifyContent(item.output ?? item)
|
|
2132
|
+
});
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
if (type === "reasoning") {
|
|
2136
|
+
const text = extractReasoningFromInputItem(item);
|
|
2137
|
+
if (text) pendingReasoning.push(text);
|
|
2138
|
+
continue;
|
|
2139
|
+
}
|
|
2140
|
+
if (type === "message" || item.role) {
|
|
2141
|
+
flushPendingToolCalls();
|
|
2142
|
+
flushPendingReasoning();
|
|
2143
|
+
const role = normalizeChatRole(stringValue(item.role));
|
|
2144
|
+
if (!role || role === "tool") continue;
|
|
2145
|
+
const content = responsesContentToText(item.content);
|
|
2146
|
+
const message = { role, content };
|
|
2147
|
+
if (item.reasoning) {
|
|
2148
|
+
const attached = extractReasoningFromInputItem(item);
|
|
2149
|
+
if (attached) {
|
|
2150
|
+
const existing = message.content || "";
|
|
2151
|
+
message.content = existing ? `${existing}
|
|
2152
|
+
|
|
2153
|
+
Reasoning: ${attached}` : `Reasoning: ${attached}`;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
messages.push(message);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
flushPendingToolCalls();
|
|
2160
|
+
flushPendingReasoning();
|
|
2161
|
+
return messages;
|
|
2162
|
+
}
|
|
2163
|
+
function responsesContentToText(content) {
|
|
2164
|
+
if (typeof content === "string") return content;
|
|
2165
|
+
if (!Array.isArray(content)) return stringifyContent(content);
|
|
2166
|
+
const parts = [];
|
|
2167
|
+
for (const part of content) {
|
|
2168
|
+
if (typeof part === "string") {
|
|
2169
|
+
parts.push(part);
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
2172
|
+
if (!isRecord3(part)) continue;
|
|
2173
|
+
const text = stringValue(part.text) || stringValue(part.input_text) || stringValue(part.output_text);
|
|
2174
|
+
if (text) parts.push(text);
|
|
2175
|
+
}
|
|
2176
|
+
return parts.join("\n");
|
|
2177
|
+
}
|
|
2178
|
+
function responsesToolsToChatTools(tools) {
|
|
2179
|
+
if (!Array.isArray(tools)) return [];
|
|
2180
|
+
const result = [];
|
|
2181
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2182
|
+
const addFunctionTool = (name, tool, namespace) => {
|
|
2183
|
+
const displayName = safeChatToolName(namespace ? `${namespace}_${name}` : name);
|
|
2184
|
+
if (seen.has(displayName)) return;
|
|
2185
|
+
seen.add(displayName);
|
|
2186
|
+
result.push({
|
|
2187
|
+
type: "function",
|
|
2188
|
+
function: {
|
|
2189
|
+
name: displayName,
|
|
2190
|
+
description: stringValue(tool.description) || "",
|
|
2191
|
+
parameters: isRecord3(tool.parameters) ? tool.parameters : { type: "object", properties: {} }
|
|
2192
|
+
}
|
|
2193
|
+
});
|
|
2194
|
+
};
|
|
2195
|
+
const visitTool = (tool, namespace = null) => {
|
|
2196
|
+
if (typeof tool === "string") {
|
|
2197
|
+
const name2 = tool.trim();
|
|
2198
|
+
if (!name2) return;
|
|
2199
|
+
addFunctionTool(
|
|
2200
|
+
name2,
|
|
2201
|
+
{
|
|
2202
|
+
description: "",
|
|
2203
|
+
parameters: { type: "object", properties: {} }
|
|
2204
|
+
},
|
|
2205
|
+
namespace
|
|
2206
|
+
);
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
if (!isRecord3(tool)) return;
|
|
2210
|
+
const toolType = stringValue(tool.type);
|
|
2211
|
+
if (toolType === "namespace") {
|
|
2212
|
+
const ns = stringValue(tool.name);
|
|
2213
|
+
if (!ns) return;
|
|
2214
|
+
const children = Array.isArray(tool.tools) ? tool.tools : isRecord3(tool.tools) ? [] : Array.isArray(tool.children) ? tool.children : [];
|
|
2215
|
+
for (const child of children) {
|
|
2216
|
+
visitTool(child, ns);
|
|
2217
|
+
}
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (toolType === "tool_search") {
|
|
2221
|
+
const displayName = "tool_search";
|
|
2222
|
+
if (seen.has(displayName)) return;
|
|
2223
|
+
seen.add(displayName);
|
|
2224
|
+
result.push({
|
|
2225
|
+
type: "function",
|
|
2226
|
+
function: {
|
|
2227
|
+
name: displayName,
|
|
2228
|
+
description: "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.",
|
|
2229
|
+
parameters: { type: "object", properties: { query: { type: "string" }, limit: { type: "integer" } }, required: ["query"] }
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
const name = stringValue(tool.name);
|
|
2235
|
+
if (!name) return;
|
|
2236
|
+
if (toolType === "custom") {
|
|
2237
|
+
const displayName = safeChatToolName(namespace ? `${namespace}_${name}` : name);
|
|
2238
|
+
if (seen.has(displayName)) return;
|
|
2239
|
+
seen.add(displayName);
|
|
2240
|
+
result.push({
|
|
2241
|
+
type: "function",
|
|
2242
|
+
function: {
|
|
2243
|
+
name: displayName,
|
|
2244
|
+
description: stringValue(tool.description) || "",
|
|
2245
|
+
parameters: {
|
|
2246
|
+
type: "object",
|
|
2247
|
+
properties: {
|
|
2248
|
+
input: {
|
|
2249
|
+
type: "string",
|
|
2250
|
+
description: "Tool input"
|
|
2251
|
+
}
|
|
2252
|
+
},
|
|
2253
|
+
required: ["input"]
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
if (toolType === "function" || !toolType) {
|
|
2260
|
+
addFunctionTool(name, tool, namespace);
|
|
2261
|
+
}
|
|
2262
|
+
};
|
|
2263
|
+
for (const tool of tools) {
|
|
2264
|
+
visitTool(tool);
|
|
2265
|
+
}
|
|
2266
|
+
return result;
|
|
2267
|
+
}
|
|
2268
|
+
function safeChatToolName(value) {
|
|
2269
|
+
const normalized = value.trim().replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
|
|
2270
|
+
return normalized || "tool_call";
|
|
2271
|
+
}
|
|
2272
|
+
async function streamChatToResponses(upstream, res, model, history) {
|
|
2273
|
+
res.writeHead(200, SSE_HEADERS);
|
|
2274
|
+
const responseId = `resp_starling_${randomUUID().replace(/-/g, "")}`;
|
|
2275
|
+
const createdAt = Math.floor(Date.now() / 1e3);
|
|
2276
|
+
const state = createResponseState(responseId, model, createdAt);
|
|
2277
|
+
const assistantMessage = { role: "assistant", content: "" };
|
|
2278
|
+
writeSse(res, "response.created", {
|
|
2279
|
+
type: "response.created",
|
|
2280
|
+
response: responseEnvelope(state, "in_progress", [])
|
|
2281
|
+
});
|
|
2282
|
+
const reader = upstream.body?.getReader();
|
|
2283
|
+
if (!reader) throw new Error("Upstream response did not provide a readable stream.");
|
|
2284
|
+
let buffer = "";
|
|
2285
|
+
while (true) {
|
|
2286
|
+
const { done, value } = await reader.read();
|
|
2287
|
+
if (done) break;
|
|
2288
|
+
buffer += new TextDecoder().decode(value, { stream: true });
|
|
2289
|
+
const blocks = splitSseBlocks(buffer);
|
|
2290
|
+
buffer = blocks.remainder;
|
|
2291
|
+
for (const block of blocks.complete) {
|
|
2292
|
+
const data = parseSseData(block);
|
|
2293
|
+
if (!data || data === "[DONE]") continue;
|
|
2294
|
+
const chunk = JSON.parse(data);
|
|
2295
|
+
for (const event of chatChunkToResponseEvents(chunk, state)) {
|
|
2296
|
+
writeSse(res, event.event, event.data);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
const completedOutput = finalizeResponseState(state);
|
|
2301
|
+
for (const event of completedOutput.events) {
|
|
2302
|
+
writeSse(res, event.event, event.data);
|
|
2303
|
+
}
|
|
2304
|
+
const response = responseEnvelope(state, "completed", completedOutput.items);
|
|
2305
|
+
writeSse(res, "response.completed", { type: "response.completed", response });
|
|
2306
|
+
res.end();
|
|
2307
|
+
assistantMessage.content = state.text;
|
|
2308
|
+
const toolCalls = [...state.toolItems.values()].filter((tool) => tool.started).map((tool) => ({
|
|
2309
|
+
id: tool.callId,
|
|
2310
|
+
type: "function",
|
|
2311
|
+
function: {
|
|
2312
|
+
name: tool.name,
|
|
2313
|
+
arguments: tool.arguments
|
|
2314
|
+
}
|
|
2315
|
+
}));
|
|
2316
|
+
if (toolCalls.length > 0) assistantMessage.tool_calls = toolCalls;
|
|
2317
|
+
history.set(responseId, { messages: [assistantMessage] });
|
|
2318
|
+
}
|
|
2319
|
+
function chatChunkToResponseEvents(chunk, state) {
|
|
2320
|
+
if (!isRecord3(chunk)) return [];
|
|
2321
|
+
const model = stringValue(chunk.model);
|
|
2322
|
+
if (model) state.model = model;
|
|
2323
|
+
const choice = Array.isArray(chunk.choices) && isRecord3(chunk.choices[0]) ? chunk.choices[0] : null;
|
|
2324
|
+
const delta = isRecord3(choice?.delta) ? choice.delta : null;
|
|
2325
|
+
const events = [];
|
|
2326
|
+
const content = stringValue(delta?.content);
|
|
2327
|
+
if (content) events.push(...pushTextDelta(state, content));
|
|
2328
|
+
const reasoning = stringValue(delta?.reasoning);
|
|
2329
|
+
if (reasoning) events.push(...pushReasoningDelta(state, reasoning));
|
|
2330
|
+
if (Array.isArray(delta?.tool_calls)) {
|
|
2331
|
+
for (const callDelta of delta.tool_calls) {
|
|
2332
|
+
if (!isRecord3(callDelta)) continue;
|
|
2333
|
+
const index = typeof callDelta.index === "number" ? callDelta.index : 0;
|
|
2334
|
+
const current = state.toolItems.get(index) ?? {
|
|
2335
|
+
itemId: `fc_${randomUUID().replace(/-/g, "")}`,
|
|
2336
|
+
callId: stringValue(callDelta.id) || `call_${randomUUID().replace(/-/g, "")}`,
|
|
2337
|
+
name: "",
|
|
2338
|
+
arguments: "",
|
|
2339
|
+
started: false,
|
|
2340
|
+
done: false,
|
|
2341
|
+
outputIndex: -1
|
|
2342
|
+
};
|
|
2343
|
+
if (stringValue(callDelta.id)) {
|
|
2344
|
+
current.callId = stringValue(callDelta.id) || current.callId;
|
|
2345
|
+
}
|
|
2346
|
+
const fn = isRecord3(callDelta.function) ? callDelta.function : {};
|
|
2347
|
+
const name = stringValue(fn.name);
|
|
2348
|
+
const args = stringValue(fn.arguments);
|
|
2349
|
+
if (name) current.name = name;
|
|
2350
|
+
if (args) current.arguments += args;
|
|
2351
|
+
state.toolItems.set(index, current);
|
|
2352
|
+
events.push(...pushToolDelta(state, current, args || ""));
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
return events;
|
|
2356
|
+
}
|
|
2357
|
+
function createResponseState(responseId, model, createdAt) {
|
|
2358
|
+
return {
|
|
2359
|
+
responseId,
|
|
2360
|
+
model,
|
|
2361
|
+
createdAt,
|
|
2362
|
+
text: "",
|
|
2363
|
+
textStarted: false,
|
|
2364
|
+
textOutputIndex: 0,
|
|
2365
|
+
nextOutputIndex: 0,
|
|
2366
|
+
reasoning: {
|
|
2367
|
+
text: "",
|
|
2368
|
+
started: false,
|
|
2369
|
+
done: false,
|
|
2370
|
+
outputIndex: -1,
|
|
2371
|
+
itemId: `${responseId}_reason`
|
|
2372
|
+
},
|
|
2373
|
+
outputItems: /* @__PURE__ */ new Map(),
|
|
2374
|
+
toolItems: /* @__PURE__ */ new Map()
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
function pushTextDelta(state, delta) {
|
|
2378
|
+
const itemId = `${state.responseId}_msg`;
|
|
2379
|
+
const events = [];
|
|
2380
|
+
if (!state.textStarted) {
|
|
2381
|
+
state.textStarted = true;
|
|
2382
|
+
state.textOutputIndex = state.nextOutputIndex++;
|
|
2383
|
+
const item = { id: itemId, type: "message", status: "in_progress", role: "assistant", content: [] };
|
|
2384
|
+
events.push({
|
|
2385
|
+
event: "response.output_item.added",
|
|
2386
|
+
data: { type: "response.output_item.added", output_index: state.textOutputIndex, item }
|
|
2387
|
+
});
|
|
2388
|
+
events.push({
|
|
2389
|
+
event: "response.content_part.added",
|
|
2390
|
+
data: {
|
|
2391
|
+
type: "response.content_part.added",
|
|
2392
|
+
item_id: itemId,
|
|
2393
|
+
output_index: state.textOutputIndex,
|
|
2394
|
+
content_index: 0,
|
|
2395
|
+
part: { type: "output_text", text: "", annotations: [] }
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
state.text += delta;
|
|
2400
|
+
events.push({
|
|
2401
|
+
event: "response.output_text.delta",
|
|
2402
|
+
data: {
|
|
2403
|
+
type: "response.output_text.delta",
|
|
2404
|
+
item_id: itemId,
|
|
2405
|
+
output_index: state.textOutputIndex,
|
|
2406
|
+
content_index: 0,
|
|
2407
|
+
delta
|
|
2408
|
+
}
|
|
2409
|
+
});
|
|
2410
|
+
return events;
|
|
2411
|
+
}
|
|
2412
|
+
function pushToolDelta(state, current, delta) {
|
|
2413
|
+
const outputIndex = current.outputIndex < 0 ? state.nextOutputIndex++ : current.outputIndex;
|
|
2414
|
+
current.outputIndex = outputIndex;
|
|
2415
|
+
const events = [];
|
|
2416
|
+
if (!current.started) {
|
|
2417
|
+
current.started = true;
|
|
2418
|
+
events.push({
|
|
2419
|
+
event: "response.output_item.added",
|
|
2420
|
+
data: {
|
|
2421
|
+
type: "response.output_item.added",
|
|
2422
|
+
output_index: outputIndex,
|
|
2423
|
+
item: {
|
|
2424
|
+
id: current.itemId,
|
|
2425
|
+
type: "function_call",
|
|
2426
|
+
status: "in_progress",
|
|
2427
|
+
call_id: current.callId,
|
|
2428
|
+
name: current.name,
|
|
2429
|
+
arguments: ""
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
if (delta) {
|
|
2435
|
+
events.push({
|
|
2436
|
+
event: "response.function_call_arguments.delta",
|
|
2437
|
+
data: {
|
|
2438
|
+
type: "response.function_call_arguments.delta",
|
|
2439
|
+
item_id: current.itemId,
|
|
2440
|
+
output_index: outputIndex,
|
|
2441
|
+
delta
|
|
2442
|
+
}
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
return events;
|
|
2446
|
+
}
|
|
2447
|
+
function finalizeResponseState(state) {
|
|
2448
|
+
const events = [];
|
|
2449
|
+
const items = [];
|
|
2450
|
+
if (state.reasoning.started && !state.reasoning.done) {
|
|
2451
|
+
const summary = state.reasoning.text.trim();
|
|
2452
|
+
const outputIndex = state.reasoning.outputIndex < 0 ? state.nextOutputIndex++ : state.reasoning.outputIndex;
|
|
2453
|
+
state.reasoning.outputIndex = outputIndex;
|
|
2454
|
+
state.reasoning.done = true;
|
|
2455
|
+
state.outputItems.set(outputIndex, {
|
|
2456
|
+
id: state.reasoning.itemId,
|
|
2457
|
+
type: "reasoning",
|
|
2458
|
+
status: "completed",
|
|
2459
|
+
summary: [{ type: "summary_text", text: summary }]
|
|
2460
|
+
});
|
|
2461
|
+
events.push({
|
|
2462
|
+
event: "response.reasoning_summary_text.done",
|
|
2463
|
+
data: {
|
|
2464
|
+
type: "response.reasoning_summary_text.done",
|
|
2465
|
+
item_id: state.reasoning.itemId,
|
|
2466
|
+
output_index: outputIndex,
|
|
2467
|
+
summary_index: 0,
|
|
2468
|
+
text: summary
|
|
2469
|
+
}
|
|
2470
|
+
});
|
|
2471
|
+
events.push({
|
|
2472
|
+
event: "response.output_item.done",
|
|
2473
|
+
data: {
|
|
2474
|
+
type: "response.output_item.done",
|
|
2475
|
+
output_index: outputIndex,
|
|
2476
|
+
item: {
|
|
2477
|
+
id: state.reasoning.itemId,
|
|
2478
|
+
type: "reasoning",
|
|
2479
|
+
status: "completed",
|
|
2480
|
+
summary: [{ type: "summary_text", text: summary }]
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
if (state.textStarted) {
|
|
2486
|
+
const itemId = `${state.responseId}_msg`;
|
|
2487
|
+
const outputIndex = state.textOutputIndex;
|
|
2488
|
+
const item = {
|
|
2489
|
+
id: itemId,
|
|
2490
|
+
type: "message",
|
|
2491
|
+
status: "completed",
|
|
2492
|
+
role: "assistant",
|
|
2493
|
+
content: [{ type: "output_text", text: state.text, annotations: [] }]
|
|
2494
|
+
};
|
|
2495
|
+
events.push({
|
|
2496
|
+
event: "response.output_text.done",
|
|
2497
|
+
data: {
|
|
2498
|
+
type: "response.output_text.done",
|
|
2499
|
+
item_id: itemId,
|
|
2500
|
+
output_index: outputIndex,
|
|
2501
|
+
content_index: 0,
|
|
2502
|
+
text: state.text
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
2505
|
+
events.push({
|
|
2506
|
+
event: "response.content_part.done",
|
|
2507
|
+
data: {
|
|
2508
|
+
type: "response.content_part.done",
|
|
2509
|
+
item_id: itemId,
|
|
2510
|
+
output_index: outputIndex,
|
|
2511
|
+
content_index: 0,
|
|
2512
|
+
part: { type: "output_text", text: state.text, annotations: [] }
|
|
2513
|
+
}
|
|
2514
|
+
});
|
|
2515
|
+
events.push({
|
|
2516
|
+
event: "response.output_item.done",
|
|
2517
|
+
data: { type: "response.output_item.done", output_index: outputIndex, item }
|
|
2518
|
+
});
|
|
2519
|
+
items.push(item);
|
|
2520
|
+
state.outputItems.set(outputIndex, item);
|
|
2521
|
+
}
|
|
2522
|
+
for (const tool of state.toolItems.values()) {
|
|
2523
|
+
const outputIndex = tool.outputIndex < 0 ? state.nextOutputIndex++ : tool.outputIndex;
|
|
2524
|
+
tool.outputIndex = outputIndex;
|
|
2525
|
+
if (!tool.started) {
|
|
2526
|
+
tool.started = true;
|
|
2527
|
+
events.push({
|
|
2528
|
+
event: "response.output_item.added",
|
|
2529
|
+
data: {
|
|
2530
|
+
type: "response.output_item.added",
|
|
2531
|
+
output_index: outputIndex,
|
|
2532
|
+
item: {
|
|
2533
|
+
id: tool.itemId,
|
|
2534
|
+
type: "function_call",
|
|
2535
|
+
status: "in_progress",
|
|
2536
|
+
call_id: tool.callId,
|
|
2537
|
+
name: tool.name,
|
|
2538
|
+
arguments: ""
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
const item = {
|
|
2544
|
+
id: tool.itemId,
|
|
2545
|
+
type: "function_call",
|
|
2546
|
+
status: "completed",
|
|
2547
|
+
call_id: tool.callId,
|
|
2548
|
+
name: tool.name,
|
|
2549
|
+
arguments: tool.arguments
|
|
2550
|
+
};
|
|
2551
|
+
events.push({
|
|
2552
|
+
event: "response.function_call_arguments.done",
|
|
2553
|
+
data: {
|
|
2554
|
+
type: "response.function_call_arguments.done",
|
|
2555
|
+
item_id: tool.itemId,
|
|
2556
|
+
output_index: outputIndex,
|
|
2557
|
+
arguments: tool.arguments
|
|
2558
|
+
}
|
|
2559
|
+
});
|
|
2560
|
+
events.push({
|
|
2561
|
+
event: "response.output_item.done",
|
|
2562
|
+
data: { type: "response.output_item.done", output_index: outputIndex, item }
|
|
2563
|
+
});
|
|
2564
|
+
items.push(item);
|
|
2565
|
+
state.outputItems.set(outputIndex, item);
|
|
2566
|
+
}
|
|
2567
|
+
const orderedItems = [...state.outputItems.entries()].sort((a, b) => a[0] - b[0]).map(([, item]) => item);
|
|
2568
|
+
return { events, items: orderedItems };
|
|
2569
|
+
}
|
|
2570
|
+
function pushReasoningDelta(state, delta) {
|
|
2571
|
+
const events = [];
|
|
2572
|
+
if (!state.reasoning.started) {
|
|
2573
|
+
state.reasoning.started = true;
|
|
2574
|
+
state.reasoning.outputIndex = state.nextOutputIndex++;
|
|
2575
|
+
state.outputItems.set(state.reasoning.outputIndex, {
|
|
2576
|
+
id: state.reasoning.itemId,
|
|
2577
|
+
type: "reasoning",
|
|
2578
|
+
status: "in_progress",
|
|
2579
|
+
summary: [{ type: "summary_text", text: "" }]
|
|
2580
|
+
});
|
|
2581
|
+
events.push({
|
|
2582
|
+
event: "response.output_item.added",
|
|
2583
|
+
data: {
|
|
2584
|
+
type: "response.output_item.added",
|
|
2585
|
+
output_index: state.reasoning.outputIndex,
|
|
2586
|
+
item: {
|
|
2587
|
+
id: state.reasoning.itemId,
|
|
2588
|
+
type: "reasoning",
|
|
2589
|
+
status: "in_progress",
|
|
2590
|
+
summary: [{ type: "summary_text", text: "" }]
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
state.reasoning.text += delta;
|
|
2596
|
+
events.push({
|
|
2597
|
+
event: "response.reasoning_summary_text.delta",
|
|
2598
|
+
data: {
|
|
2599
|
+
type: "response.reasoning_summary_text.delta",
|
|
2600
|
+
item_id: state.reasoning.itemId,
|
|
2601
|
+
output_index: state.reasoning.outputIndex,
|
|
2602
|
+
summary_index: 0,
|
|
2603
|
+
delta
|
|
2604
|
+
}
|
|
2605
|
+
});
|
|
2606
|
+
return events;
|
|
2607
|
+
}
|
|
2608
|
+
function chatCompletionToResponse(chatResponse, defaultModel) {
|
|
2609
|
+
const responseId = isRecord3(chatResponse) && stringValue(chatResponse.id) ? `resp_${stringValue(chatResponse.id)}` : `resp_starling_${randomUUID().replace(/-/g, "")}`;
|
|
2610
|
+
const choice = isRecord3(chatResponse) && Array.isArray(chatResponse.choices) && isRecord3(chatResponse.choices[0]) ? chatResponse.choices[0] : {};
|
|
2611
|
+
const message = isRecord3(choice.message) ? choice.message : {};
|
|
2612
|
+
const [text, inlineReasoning] = splitReasoningFromContent(stringValue(message.content) || "");
|
|
2613
|
+
const reasoningText = [stringifyContent(message.reasoning), inlineReasoning].map((value) => value.trim()).filter(Boolean).join("\n");
|
|
2614
|
+
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
2615
|
+
const output = [];
|
|
2616
|
+
if (reasoningText) {
|
|
2617
|
+
output.push({
|
|
2618
|
+
id: `${responseId}_reason`,
|
|
2619
|
+
type: "reasoning",
|
|
2620
|
+
status: "completed",
|
|
2621
|
+
summary: [{ type: "summary_text", text: reasoningText }]
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
if (text) {
|
|
2625
|
+
output.push({
|
|
2626
|
+
id: `${responseId}_msg`,
|
|
2627
|
+
type: "message",
|
|
2628
|
+
status: "completed",
|
|
2629
|
+
role: "assistant",
|
|
2630
|
+
content: [{ type: "output_text", text, annotations: [] }]
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
for (const tool of toolCalls) {
|
|
2634
|
+
if (!isRecord3(tool)) continue;
|
|
2635
|
+
const fn = isRecord3(tool.function) ? tool.function : {};
|
|
2636
|
+
output.push({
|
|
2637
|
+
id: `fc_${randomUUID().replace(/-/g, "")}`,
|
|
2638
|
+
type: "function_call",
|
|
2639
|
+
status: "completed",
|
|
2640
|
+
call_id: stringValue(tool.id) || `call_${randomUUID().replace(/-/g, "")}`,
|
|
2641
|
+
name: stringValue(fn.name) || "",
|
|
2642
|
+
arguments: stringValue(fn.arguments) || ""
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
const response = responseEnvelope(
|
|
2646
|
+
{
|
|
2647
|
+
responseId,
|
|
2648
|
+
model: isRecord3(chatResponse) && stringValue(chatResponse.model) || defaultModel,
|
|
2649
|
+
createdAt: isRecord3(chatResponse) && typeof chatResponse.created === "number" ? chatResponse.created : Math.floor(Date.now() / 1e3)
|
|
2650
|
+
},
|
|
2651
|
+
"completed",
|
|
2652
|
+
output
|
|
2653
|
+
);
|
|
2654
|
+
return {
|
|
2655
|
+
response,
|
|
2656
|
+
storedMessages: [{ role: "assistant", content: text, ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {} }]
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
function extractReasoningFromInputItem(item) {
|
|
2660
|
+
const reasoning = item.reasoning;
|
|
2661
|
+
if (typeof reasoning === "string" && reasoning.trim()) return reasoning.trim();
|
|
2662
|
+
if (isRecord3(reasoning)) {
|
|
2663
|
+
const summary = reasonSummaryTextFromContainer(reasoning);
|
|
2664
|
+
if (summary) return summary.trim();
|
|
2665
|
+
}
|
|
2666
|
+
if (typeof item.summary === "string" && item.summary.trim()) return item.summary.trim();
|
|
2667
|
+
if (Array.isArray(item.summary)) {
|
|
2668
|
+
const summary = reasonSummaryTextFromItems(item.summary);
|
|
2669
|
+
if (summary) return summary.trim();
|
|
2670
|
+
}
|
|
2671
|
+
if (isRecord3(item.summary)) {
|
|
2672
|
+
const summary = reasonSummaryTextFromContainer(item.summary);
|
|
2673
|
+
if (summary) return summary.trim();
|
|
2674
|
+
}
|
|
2675
|
+
const text = stringifyContent(item.text);
|
|
2676
|
+
return text ? text.trim() : null;
|
|
2677
|
+
}
|
|
2678
|
+
function reasonSummaryTextFromItems(value) {
|
|
2679
|
+
const chunks = [];
|
|
2680
|
+
for (const entry of value) {
|
|
2681
|
+
if (!isRecord3(entry)) continue;
|
|
2682
|
+
const text = typeof entry.text === "string" ? entry.text : reasonSummaryTextFromContainer(entry) || "";
|
|
2683
|
+
if (text) chunks.push(text);
|
|
2684
|
+
}
|
|
2685
|
+
return chunks.length > 0 ? chunks.join("\n") : null;
|
|
2686
|
+
}
|
|
2687
|
+
function reasonSummaryTextFromContainer(container) {
|
|
2688
|
+
const summary = container.summary;
|
|
2689
|
+
if (typeof summary === "string") return summary;
|
|
2690
|
+
if (Array.isArray(summary)) {
|
|
2691
|
+
const chunks = [];
|
|
2692
|
+
for (const entry of summary) {
|
|
2693
|
+
if (!isRecord3(entry)) continue;
|
|
2694
|
+
const text = typeof entry.text === "string" ? entry.text : "";
|
|
2695
|
+
if (text) chunks.push(text);
|
|
2696
|
+
}
|
|
2697
|
+
return chunks.length > 0 ? chunks.join("\n") : null;
|
|
2698
|
+
}
|
|
2699
|
+
if (isRecord3(summary) && typeof summary.text === "string") return summary.text;
|
|
2700
|
+
return null;
|
|
2701
|
+
}
|
|
2702
|
+
function splitReasoningFromContent(content) {
|
|
2703
|
+
if (!content) return ["", ""];
|
|
2704
|
+
const normalized = content.trim();
|
|
2705
|
+
if (!normalized) return ["", ""];
|
|
2706
|
+
const reasonParts = [];
|
|
2707
|
+
let remaining = normalized;
|
|
2708
|
+
const reasonRegex = /<reasoning>([\s\S]*?)<\/reasoning>/gi;
|
|
2709
|
+
remaining = remaining.replace(reasonRegex, (_, captured) => {
|
|
2710
|
+
const text = typeof captured === "string" ? captured.trim() : "";
|
|
2711
|
+
if (text) reasonParts.push(text);
|
|
2712
|
+
return "";
|
|
2713
|
+
});
|
|
2714
|
+
const thinkRegex = /<think>([\s\S]*?)<\/think>/gi;
|
|
2715
|
+
remaining = remaining.replace(thinkRegex, (_, captured) => {
|
|
2716
|
+
const text = typeof captured === "string" ? captured.trim() : "";
|
|
2717
|
+
if (text) reasonParts.push(text);
|
|
2718
|
+
return "";
|
|
2719
|
+
});
|
|
2720
|
+
const finalRemaining = remaining.replace(/^\s*\n+|\s+$/g, "").replace(/\n{3,}/g, "\n\n");
|
|
2721
|
+
const reasoningText = reasonParts.join("\n").trim();
|
|
2722
|
+
return [finalRemaining, reasoningText];
|
|
2723
|
+
}
|
|
2724
|
+
function responseEnvelope(state, status, output) {
|
|
2725
|
+
return {
|
|
2726
|
+
id: state.responseId,
|
|
2727
|
+
object: "response",
|
|
2728
|
+
created_at: state.createdAt,
|
|
2729
|
+
status,
|
|
2730
|
+
model: state.model,
|
|
2731
|
+
output,
|
|
2732
|
+
parallel_tool_calls: true,
|
|
2733
|
+
usage: null
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
function chatErrorToResponsesError(errorText, status) {
|
|
2737
|
+
let parsed;
|
|
2738
|
+
try {
|
|
2739
|
+
parsed = JSON.parse(errorText);
|
|
2740
|
+
} catch {
|
|
2741
|
+
parsed = null;
|
|
2742
|
+
}
|
|
2743
|
+
const message = isRecord3(parsed) && isRecord3(parsed.error) && stringValue(parsed.error.message) || isRecord3(parsed) && stringValue(parsed.message) || errorText || `Upstream error ${status}`;
|
|
2744
|
+
return { error: { message, type: "upstream_error", code: status } };
|
|
2745
|
+
}
|
|
2746
|
+
function normalizeModelsResponse(body) {
|
|
2747
|
+
if (isRecord3(body) && Array.isArray(body.models)) return body;
|
|
2748
|
+
const source = isRecord3(body) && Array.isArray(body.data) ? body.data : [];
|
|
2749
|
+
const models = source.filter(isRecord3).map((model, index) => {
|
|
2750
|
+
const id = stringValue(model.id) || stringValue(model.name);
|
|
2751
|
+
const name = stringValue(model.name) || id;
|
|
2752
|
+
return {
|
|
2753
|
+
id,
|
|
2754
|
+
slug: id,
|
|
2755
|
+
name,
|
|
2756
|
+
display_name: name,
|
|
2757
|
+
description: name,
|
|
2758
|
+
default_reasoning_level: "high",
|
|
2759
|
+
supported_reasoning_levels: [
|
|
2760
|
+
{ effort: "low", description: "Fast responses with lighter reasoning" },
|
|
2761
|
+
{ effort: "medium", description: "Balances speed and reasoning depth" },
|
|
2762
|
+
{ effort: "high", description: "Greater reasoning depth for complex tasks" }
|
|
2763
|
+
],
|
|
2764
|
+
shell_type: "shell_command",
|
|
2765
|
+
visibility: "list",
|
|
2766
|
+
supported_in_api: true,
|
|
2767
|
+
object: stringValue(model.object) || "model",
|
|
2768
|
+
owned_by: stringValue(model.owned_by) || "deepseek",
|
|
2769
|
+
context_window: 1e6,
|
|
2770
|
+
max_context_window: 1e6,
|
|
2771
|
+
priority: 1e3 + index,
|
|
2772
|
+
additional_speed_tiers: [],
|
|
2773
|
+
service_tiers: [],
|
|
2774
|
+
availability_nux: null,
|
|
2775
|
+
upgrade: null,
|
|
2776
|
+
base_instructions: "You are Codex, a coding agent. Help the user with software engineering tasks in the current workspace.",
|
|
2777
|
+
model_messages: {
|
|
2778
|
+
instructions_template: "You are Codex, a coding agent. Help the user with software engineering tasks in the current workspace."
|
|
2779
|
+
},
|
|
2780
|
+
supports_reasoning_summaries: false,
|
|
2781
|
+
default_reasoning_summary: "none",
|
|
2782
|
+
support_verbosity: true,
|
|
2783
|
+
default_verbosity: "low",
|
|
2784
|
+
apply_patch_tool_type: "freeform",
|
|
2785
|
+
web_search_tool_type: "text_and_image",
|
|
2786
|
+
truncation_policy: { mode: "tokens", limit: 1e4 },
|
|
2787
|
+
supports_parallel_tool_calls: true,
|
|
2788
|
+
supports_image_detail_original: true,
|
|
2789
|
+
effective_context_window_percent: 95,
|
|
2790
|
+
experimental_supported_tools: [],
|
|
2791
|
+
input_modalities: ["text"],
|
|
2792
|
+
supports_search_tool: true,
|
|
2793
|
+
use_responses_lite: false
|
|
2794
|
+
};
|
|
2795
|
+
}).filter((model) => model.id && model.slug);
|
|
2796
|
+
return { models };
|
|
2797
|
+
}
|
|
2798
|
+
function normalizeUpstreamBaseUrl(value) {
|
|
2799
|
+
return value.replace(/\/+$/, "");
|
|
2800
|
+
}
|
|
2801
|
+
function isModelsPath(pathname) {
|
|
2802
|
+
return pathname === "/models" || pathname === "/v1/models";
|
|
2803
|
+
}
|
|
2804
|
+
function isResponsesPath(pathname) {
|
|
2805
|
+
return pathname === "/responses" || pathname === "/v1/responses" || pathname === "/v1/responses/compact";
|
|
2806
|
+
}
|
|
2807
|
+
function forwardHeaders(req, apiKey) {
|
|
2808
|
+
const headers = { authorization: `Bearer ${apiKey}` };
|
|
2809
|
+
const accept = req.headers.accept;
|
|
2810
|
+
if (typeof accept === "string") headers.accept = accept;
|
|
2811
|
+
return headers;
|
|
2812
|
+
}
|
|
2813
|
+
async function readJsonBody(req) {
|
|
2814
|
+
const chunks = [];
|
|
2815
|
+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2816
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
2817
|
+
return text ? JSON.parse(text) : {};
|
|
2818
|
+
}
|
|
2819
|
+
function writeJson(res, status, body) {
|
|
2820
|
+
res.writeHead(status, JSON_HEADERS);
|
|
2821
|
+
res.end(JSON.stringify(body));
|
|
2822
|
+
}
|
|
2823
|
+
function writeSse(res, event, data) {
|
|
2824
|
+
res.write(`event: ${event}
|
|
2825
|
+
`);
|
|
2826
|
+
res.write(`data: ${JSON.stringify(data)}
|
|
2827
|
+
|
|
2828
|
+
`);
|
|
2829
|
+
}
|
|
2830
|
+
function splitSseBlocks(buffer) {
|
|
2831
|
+
const normalized = buffer.replace(/\r\n/g, "\n");
|
|
2832
|
+
const parts = normalized.split("\n\n");
|
|
2833
|
+
return { complete: parts.slice(0, -1), remainder: parts.at(-1) ?? "" };
|
|
2834
|
+
}
|
|
2835
|
+
function parseSseData(block) {
|
|
2836
|
+
return block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
2837
|
+
}
|
|
2838
|
+
function readReasoningEffort(body) {
|
|
2839
|
+
if (isRecord3(body.reasoning)) {
|
|
2840
|
+
const effort = stringValue(body.reasoning.effort);
|
|
2841
|
+
if (effort) return effort;
|
|
2842
|
+
}
|
|
2843
|
+
return stringValue(body.model_reasoning_effort);
|
|
2844
|
+
}
|
|
2845
|
+
function copyIfPresent(source, target, key) {
|
|
2846
|
+
if (typeof source[key] !== "undefined") target[key] = source[key];
|
|
2847
|
+
}
|
|
2848
|
+
function normalizeChatRole(value) {
|
|
2849
|
+
if (value === "system" || value === "user" || value === "assistant" || value === "tool") return value;
|
|
2850
|
+
return value ? "user" : null;
|
|
2851
|
+
}
|
|
2852
|
+
function stringifyContent(value) {
|
|
2853
|
+
if (typeof value === "string") return value;
|
|
2854
|
+
if (value == null) return "";
|
|
2855
|
+
return JSON.stringify(value);
|
|
2856
|
+
}
|
|
2857
|
+
function stringValue(value) {
|
|
2858
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
2859
|
+
}
|
|
2860
|
+
function isRecord3(value) {
|
|
2861
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// src/lib/codexDefaultGuard.ts
|
|
2865
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, statSync as statSync3, unlinkSync as unlinkSync5, writeFileSync as writeFileSync3, chmodSync as chmodSync3 } from "fs";
|
|
2866
|
+
import { join as join6 } from "path";
|
|
2867
|
+
function snapshotCodexDefaultConfig() {
|
|
2868
|
+
return {
|
|
2869
|
+
files: [
|
|
2870
|
+
snapshotFile(join6(DEFAULT_CODEX_HOME, "config.toml")),
|
|
2871
|
+
snapshotFile(join6(DEFAULT_CODEX_HOME, "auth.json"))
|
|
2872
|
+
]
|
|
2873
|
+
};
|
|
2874
|
+
}
|
|
2875
|
+
function restoreCodexDefaultConfig(snapshot) {
|
|
2876
|
+
if (!snapshot) return;
|
|
2877
|
+
for (const file of snapshot.files) {
|
|
2878
|
+
restoreFile(file);
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
function snapshotFile(path) {
|
|
2882
|
+
if (!existsSync5(path)) {
|
|
2883
|
+
return { path, existed: false };
|
|
2884
|
+
}
|
|
2885
|
+
const st = statSync3(path);
|
|
2886
|
+
return {
|
|
2887
|
+
path,
|
|
2888
|
+
existed: true,
|
|
2889
|
+
content: readFileSync4(path, "utf-8"),
|
|
2890
|
+
mode: st.mode & 511
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
function restoreFile(snapshot) {
|
|
2894
|
+
if (!snapshot.existed) {
|
|
2895
|
+
if (existsSync5(snapshot.path)) {
|
|
2896
|
+
unlinkSync5(snapshot.path);
|
|
2897
|
+
}
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
ensureDir(snapshot.path);
|
|
2901
|
+
writeFileSync3(snapshot.path, snapshot.content ?? "", "utf-8");
|
|
2902
|
+
if (snapshot.mode !== void 0) {
|
|
2903
|
+
chmodSync3(snapshot.path, snapshot.mode);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// src/commands/run.ts
|
|
2908
|
+
var RUN_SESSION_SCAN_LIMIT = 500;
|
|
2909
|
+
var RUN_SESSION_CATALOG_SCAN_LIMIT = 2e3;
|
|
2910
|
+
var RUN_SESSION_DETECT_ATTEMPTS = 8;
|
|
2911
|
+
var RUN_SESSION_DETECT_INTERVAL_MS = 300;
|
|
2912
|
+
var RUN_SESSION_DETECT_FULL_SCAN_THRESHOLD_MS = 200;
|
|
2913
|
+
var RUN_SESSION_EXIT_SETTLE_MS = 200;
|
|
2914
|
+
var RUN_FAST_FAILURE_SKIP_SCAN_MS = 2e3;
|
|
2915
|
+
function registerRunCommand(program2) {
|
|
2916
|
+
const run = new Command5("run").description("Launch claude/codex with auto catalog assignment for the created session").argument("<agent>", "agent binary: claude | codex | agent").argument("[agent-args...]", "arguments passed verbatim to the agent CLI").option("-c, --catalog <catalog>", "add created session to catalog").option("--config <config>", "Starling settings profile under ~/.starling/settings/{claude|codex}").option("--title <title>", "pin title for created session").option("--tags <tags>", "pin tags for created session, comma-separated").option("--cwd <path>", "working directory for agent launch").allowUnknownOption().passThroughOptions().addHelpText(
|
|
2917
|
+
"after",
|
|
2918
|
+
"\nStarling options must be placed before <agent>. Everything after <agent> is passed to claude/codex."
|
|
2919
|
+
).action(async (agentRaw, agentArgs, opts, command) => {
|
|
2920
|
+
const provider = normalizeAgent(agentRaw);
|
|
2921
|
+
if (!provider) {
|
|
2922
|
+
console.error(chalk6.red(`Unknown agent: ${agentRaw}`));
|
|
2923
|
+
console.error(chalk6.gray("Allowed values: claude, codex, agent"));
|
|
2924
|
+
process.exit(1);
|
|
2925
|
+
}
|
|
2926
|
+
const rawArgs = command.rawArgs;
|
|
2927
|
+
const requestedConfig = opts.config;
|
|
2928
|
+
const resolvedConfig = provider === "codex" ? resolveCodexConfigPath(requestedConfig) : resolveConfigFilePath(provider, opts.config);
|
|
2929
|
+
if (provider === "codex" && requestedConfig && !resolvedConfig) {
|
|
2930
|
+
const expectedPath = join7(DEFAULT_CODEX_SETTINGS_DIR, requestedConfig);
|
|
2931
|
+
console.error(chalk6.red(`Config file not found: ${requestedConfig}`));
|
|
2932
|
+
console.error(chalk6.gray(`Expected path: ${expectedPath}`));
|
|
2933
|
+
process.exit(1);
|
|
2934
|
+
}
|
|
2935
|
+
const normalizedCwd = opts.cwd ? resolve2(opts.cwd) : process.cwd();
|
|
2936
|
+
const catalog = await resolveCatalog2(opts.catalog);
|
|
2937
|
+
const codexDefaultSnapshot = provider === "codex" ? snapshotCodexDefaultConfig() : null;
|
|
2938
|
+
let codexConfig = provider === "codex" ? await createCodexRunConfig(resolvedConfig) : null;
|
|
2939
|
+
if (provider === "codex" && catalog) {
|
|
2940
|
+
codexConfig = ensureCodexRunHookConfig(codexConfig);
|
|
2941
|
+
}
|
|
2942
|
+
const hookRun = provider === "claude" && catalog ? createClaudeRunHookSettings(resolvedConfig) : null;
|
|
2943
|
+
const effectiveConfig = hookRun?.settingsPath ?? resolvedConfig;
|
|
2944
|
+
const args = resolveAgentArgs(provider, rawArgs, agentArgs, effectiveConfig, codexConfig);
|
|
2945
|
+
const cwd = opts.cwd;
|
|
2946
|
+
const binary = provider === "claude" ? "claude" : "codex";
|
|
2947
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2948
|
+
const runStartedAtMs = Date.now();
|
|
2949
|
+
const beforeRun = hookRun ? /* @__PURE__ */ new Map() : await snapshotSessions(provider);
|
|
2950
|
+
const beforeRunProjectFiles = provider === "claude" && !hookRun ? snapshotProjectSessions(normalizedCwd) : /* @__PURE__ */ new Map();
|
|
2951
|
+
const cleanupRunState = async () => {
|
|
2952
|
+
cleanupClaudeRunHookSettings(hookRun);
|
|
2953
|
+
await cleanupCodexRunConfig(codexConfig);
|
|
2954
|
+
restoreCodexDefaultConfig(codexDefaultSnapshot);
|
|
2955
|
+
};
|
|
2956
|
+
let catalogPinned = false;
|
|
2957
|
+
let agentClosed = false;
|
|
2958
|
+
let hintedSessionId;
|
|
2959
|
+
let pinAttempt = null;
|
|
2960
|
+
const startAutoPinWatcher = async () => {
|
|
2961
|
+
if (!catalog || catalogPinned) return;
|
|
2962
|
+
if (pinAttempt) return;
|
|
2963
|
+
pinAttempt = (async () => {
|
|
2964
|
+
const startedTime = Date.parse(startedAt);
|
|
2965
|
+
let attemptsAfterClose = 0;
|
|
2966
|
+
for (let i = 0; ; i++) {
|
|
2967
|
+
const sessionId = hintedSessionId ?? readRunHookSessionId(hookRun?.eventsPath ?? codexConfig?.eventsPath);
|
|
2968
|
+
if (!sessionId) {
|
|
2969
|
+
if (provider === "codex") {
|
|
2970
|
+
const candidate2 = await findSingleCodexSessionForRunningAgent(startedTime, beforeRun, normalizedCwd);
|
|
2971
|
+
if (candidate2) {
|
|
2972
|
+
hintedSessionId = candidate2.session_id;
|
|
2973
|
+
await pinSessionToCatalog(candidate2, opts, catalog);
|
|
2974
|
+
catalogPinned = true;
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
if (agentClosed) return;
|
|
2979
|
+
await sleep(250);
|
|
2980
|
+
continue;
|
|
2981
|
+
}
|
|
2982
|
+
hintedSessionId = sessionId;
|
|
2983
|
+
const candidate = hookRun && provider === "claude" ? await findClaudeSessionInProjectById(sessionId, normalizedCwd) : await findKnownSessionForRun(sessionId, provider, normalizedCwd, i);
|
|
2984
|
+
if (candidate && candidate.provider === provider && wasSessionTouchedAfterRun(candidate, startedTime, beforeRun)) {
|
|
2985
|
+
await pinSessionToCatalog(candidate, opts, catalog);
|
|
2986
|
+
catalogPinned = true;
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
if (agentClosed) {
|
|
2990
|
+
attemptsAfterClose++;
|
|
2991
|
+
if (attemptsAfterClose >= 20) break;
|
|
2992
|
+
}
|
|
2993
|
+
await sleep(250);
|
|
2994
|
+
}
|
|
2995
|
+
const fallback = provider === "claude" ? await detectSessionInCurrentClaudeProject(
|
|
2996
|
+
Date.parse(startedAt),
|
|
2997
|
+
beforeRun,
|
|
2998
|
+
normalizedCwd,
|
|
2999
|
+
beforeRunProjectFiles
|
|
3000
|
+
) : await findSingleCodexSessionForRunningAgent(
|
|
3001
|
+
Date.parse(startedAt),
|
|
3002
|
+
beforeRun,
|
|
3003
|
+
normalizedCwd
|
|
3004
|
+
);
|
|
3005
|
+
if (fallback && fallback.provider === provider && (!hintedSessionId || fallback.session_id === hintedSessionId)) {
|
|
3006
|
+
await pinSessionToCatalog(fallback, opts, catalog);
|
|
3007
|
+
catalogPinned = true;
|
|
3008
|
+
}
|
|
3009
|
+
})().finally(() => {
|
|
3010
|
+
pinAttempt = null;
|
|
3011
|
+
});
|
|
3012
|
+
pinAttempt.catch((error) => {
|
|
3013
|
+
if (process.env.NODE_ENV !== "test") {
|
|
3014
|
+
const sessionLabel = hintedSessionId ? ` ${hintedSessionId}` : "";
|
|
3015
|
+
console.error(chalk6.yellow(`Failed to auto-pin session${sessionLabel} to catalog ${catalog?.name}: ${String(error)}`));
|
|
3016
|
+
}
|
|
3017
|
+
});
|
|
3018
|
+
};
|
|
3019
|
+
if (hookRun || provider === "codex" && catalog) {
|
|
3020
|
+
void startAutoPinWatcher();
|
|
3021
|
+
}
|
|
3022
|
+
let runResult;
|
|
3023
|
+
try {
|
|
3024
|
+
runResult = await runAgent(binary, args, cwd, {
|
|
3025
|
+
preserveSignals: true,
|
|
3026
|
+
env: buildAgentEnv(provider, codexConfig?.env)
|
|
3027
|
+
});
|
|
3028
|
+
} catch (error) {
|
|
3029
|
+
await cleanupRunState();
|
|
3030
|
+
throw error;
|
|
3031
|
+
}
|
|
3032
|
+
agentClosed = true;
|
|
3033
|
+
syncCodexProfileProjectTrustFromRunConfig(resolvedConfig, codexConfig);
|
|
3034
|
+
const exitCode = runResult.exitCode;
|
|
3035
|
+
if (exitCode !== 0) {
|
|
3036
|
+
await sleep(RUN_SESSION_EXIT_SETTLE_MS);
|
|
3037
|
+
}
|
|
3038
|
+
const knownSessionId = hintedSessionId ?? readRunHookSessionId(hookRun?.eventsPath ?? codexConfig?.eventsPath) ?? void 0;
|
|
3039
|
+
if (exitCode !== 0 && Date.now() - runStartedAtMs < RUN_FAST_FAILURE_SKIP_SCAN_MS && !knownSessionId) {
|
|
3040
|
+
await cleanupRunState();
|
|
3041
|
+
process.exit(exitCode);
|
|
3042
|
+
}
|
|
3043
|
+
if (hookRun && !knownSessionId) {
|
|
3044
|
+
await cleanupRunState();
|
|
3045
|
+
if (exitCode !== 0) {
|
|
3046
|
+
process.exit(exitCode);
|
|
3047
|
+
}
|
|
3048
|
+
console.log(chalk6.yellow("No Claude session id was reported by SessionStart hook."));
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
const newSessionMeta = hookRun && knownSessionId ? await resolveHookReportedClaudeSession(knownSessionId, normalizedCwd) : await detectSessionStartedAfterRun(
|
|
3052
|
+
provider,
|
|
3053
|
+
startedAt,
|
|
3054
|
+
beforeRun,
|
|
3055
|
+
normalizedCwd,
|
|
3056
|
+
beforeRunProjectFiles,
|
|
3057
|
+
knownSessionId
|
|
3058
|
+
);
|
|
3059
|
+
if (!newSessionMeta) {
|
|
3060
|
+
if (exitCode !== 0) {
|
|
3061
|
+
await cleanupRunState();
|
|
3062
|
+
process.exit(exitCode);
|
|
3063
|
+
}
|
|
3064
|
+
console.log(chalk6.yellow("No new session found, or session metadata is not ready yet."));
|
|
3065
|
+
await cleanupRunState();
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
if (catalog && !catalogPinned) {
|
|
3069
|
+
if (knownSessionId && newSessionMeta.session_id === knownSessionId) {
|
|
3070
|
+
await pinSessionToCatalog(newSessionMeta, opts, catalog);
|
|
3071
|
+
catalogPinned = true;
|
|
3072
|
+
} else {
|
|
3073
|
+
const candidates = await collectRunSessionCandidates(
|
|
3074
|
+
provider,
|
|
3075
|
+
Date.parse(startedAt),
|
|
3076
|
+
beforeRun,
|
|
3077
|
+
normalizedCwd,
|
|
3078
|
+
beforeRunProjectFiles
|
|
3079
|
+
);
|
|
3080
|
+
const sameProjectCandidates = candidates.filter(
|
|
3081
|
+
(session) => normalizeProjectPath(session.project_path) === normalizedCwd
|
|
3082
|
+
);
|
|
3083
|
+
const targetCandidates = sameProjectCandidates.length > 0 ? sameProjectCandidates : candidates;
|
|
3084
|
+
if (targetCandidates.length === 0) {
|
|
3085
|
+
console.log(chalk6.yellow("Could not find a stable candidate session for catalog assignment."));
|
|
3086
|
+
} else if (targetCandidates.length === 1) {
|
|
3087
|
+
await pinSessionToCatalog(targetCandidates[0], opts, catalog);
|
|
3088
|
+
catalogPinned = true;
|
|
3089
|
+
} else {
|
|
3090
|
+
const header = `Found ${targetCandidates.length} possible sessions created after run, can't choose automatically.`;
|
|
3091
|
+
console.log(chalk6.yellow(header));
|
|
3092
|
+
targetCandidates.slice(0, 5).forEach((session, index) => {
|
|
3093
|
+
const shortId = shortSessionId(session.session_id);
|
|
3094
|
+
const date = session.modified_at.slice(0, 16).replace("T", " ");
|
|
3095
|
+
const project = session.project_path ? session.project_path.length > 36 ? `\u2026${session.project_path.slice(-35)}` : session.project_path : "-";
|
|
3096
|
+
console.log(` ${index + 1}. ${chalk6.cyan(shortId)} ${date} ${project}`);
|
|
3097
|
+
});
|
|
3098
|
+
console.log(chalk6.gray(`Use: starling pin <session_id> --to ${catalog.id} to assign manually.`));
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
console.log(chalk6.green(`Session started: ${newSessionMeta.session_id}`));
|
|
3103
|
+
updateSessionIndexInBackground(newSessionMeta);
|
|
3104
|
+
if (pinAttempt) {
|
|
3105
|
+
await pinAttempt;
|
|
3106
|
+
}
|
|
3107
|
+
if (exitCode !== 0) {
|
|
3108
|
+
await cleanupRunState();
|
|
3109
|
+
process.exit(exitCode);
|
|
3110
|
+
}
|
|
3111
|
+
await cleanupRunState();
|
|
3112
|
+
});
|
|
3113
|
+
program2.addCommand(run);
|
|
3114
|
+
}
|
|
3115
|
+
function updateSessionIndexInBackground(session) {
|
|
3116
|
+
setImmediate(() => {
|
|
3117
|
+
try {
|
|
3118
|
+
upsertSessionInIndex(session);
|
|
3119
|
+
} catch {
|
|
3120
|
+
}
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
var CONFIG_FILE_EXTENSIONS = [".json", ".jsonc", ".toml", ".yaml", ".yml", ".js", ".ts"];
|
|
3124
|
+
var SESSION_ID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
3125
|
+
function buildAgentEnv(provider, overrides) {
|
|
3126
|
+
if (provider !== "codex" && !overrides) return void 0;
|
|
3127
|
+
const env = { ...process.env, ...overrides ?? {} };
|
|
3128
|
+
if (provider === "codex") {
|
|
3129
|
+
for (const key of Object.keys(env)) {
|
|
3130
|
+
if (key.startsWith("CODEX_") && key !== "CODEX_HOME") {
|
|
3131
|
+
delete env[key];
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
return env;
|
|
3136
|
+
}
|
|
3137
|
+
function parseSessionIdFromText(text) {
|
|
3138
|
+
const resumeMatch = text.match(new RegExp(`--resume\\s+(${SESSION_ID_PATTERN.source})`, "i"));
|
|
3139
|
+
if (resumeMatch?.[1]) return resumeMatch[1];
|
|
3140
|
+
const sessionMatch = text.match(new RegExp(`session\\s+id\\s*[:=]\\s*(${SESSION_ID_PATTERN.source})`, "i"));
|
|
3141
|
+
if (sessionMatch?.[1]) return sessionMatch[1];
|
|
3142
|
+
const genericMatch = SESSION_ID_PATTERN.exec(text)?.[0];
|
|
3143
|
+
if (genericMatch) return genericMatch;
|
|
3144
|
+
return null;
|
|
3145
|
+
}
|
|
3146
|
+
function createClaudeRunHookSettings(configPath) {
|
|
3147
|
+
const runId = randomUUID2();
|
|
3148
|
+
const baseDir = join7(DEFAULT_STARLING_HOME, "run-hooks");
|
|
3149
|
+
const eventsPath = join7(baseDir, `${runId}.jsonl`);
|
|
3150
|
+
const settingsPath = join7(baseDir, `${runId}.settings.json`);
|
|
3151
|
+
ensureDir(eventsPath);
|
|
3152
|
+
const settings = readClaudeSettingsObject(configPath);
|
|
3153
|
+
if (!settings) return null;
|
|
3154
|
+
const hooks = isRecord4(settings.hooks) ? { ...settings.hooks } : {};
|
|
3155
|
+
const sessionStart = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
|
|
3156
|
+
sessionStart.push({
|
|
3157
|
+
hooks: [
|
|
3158
|
+
{
|
|
3159
|
+
type: "command",
|
|
3160
|
+
command: `bash -c 'cat >> "$1"; printf "\\n" >> "$1"' _ ${shellQuote(eventsPath)}`
|
|
3161
|
+
}
|
|
3162
|
+
]
|
|
3163
|
+
});
|
|
3164
|
+
hooks.SessionStart = sessionStart;
|
|
3165
|
+
atomicWriteJSON(settingsPath, { ...settings, hooks });
|
|
3166
|
+
return { settingsPath, eventsPath };
|
|
3167
|
+
}
|
|
3168
|
+
function cleanupClaudeRunHookSettings(hookRun) {
|
|
3169
|
+
if (!hookRun) return;
|
|
3170
|
+
for (const path of [hookRun.settingsPath, hookRun.eventsPath]) {
|
|
3171
|
+
try {
|
|
3172
|
+
unlinkSync6(path);
|
|
3173
|
+
} catch {
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
async function createCodexRunConfig(configPath) {
|
|
3178
|
+
if (!configPath) {
|
|
3179
|
+
return null;
|
|
3180
|
+
}
|
|
3181
|
+
const ext = extname2(configPath).toLowerCase();
|
|
3182
|
+
if (ext === ".toml") {
|
|
3183
|
+
const profileName = `starling-run-${randomUUID2()}`;
|
|
3184
|
+
const profilePath = join7(DEFAULT_CODEX_HOME, `${profileName}.config.toml`);
|
|
3185
|
+
ensureDir(profilePath);
|
|
3186
|
+
writeFileSync4(profilePath, readFileSync5(configPath, "utf-8"), "utf-8");
|
|
3187
|
+
chmodSync4(profilePath, 384);
|
|
3188
|
+
return { args: ["--profile", profileName], cleanupPaths: [profilePath] };
|
|
3189
|
+
}
|
|
3190
|
+
if (ext === ".json" || ext === ".jsonc") {
|
|
3191
|
+
const profile = readCodexJsonProfileForRun(configPath, ext === ".jsonc");
|
|
3192
|
+
return createCodexRunConfigFromProfile(profile);
|
|
3193
|
+
}
|
|
3194
|
+
console.error(chalk6.red(`Unsupported Codex config file type: ${configPath}`));
|
|
3195
|
+
console.error(chalk6.gray("Use .json, .jsonc, or .toml under ~/.starling/settings/codex."));
|
|
3196
|
+
process.exit(1);
|
|
3197
|
+
}
|
|
3198
|
+
function ensureCodexRunHookConfig(config) {
|
|
3199
|
+
const runId = randomUUID2();
|
|
3200
|
+
const baseDir = join7(DEFAULT_STARLING_HOME, "run-hooks");
|
|
3201
|
+
const eventsPath = join7(baseDir, `${runId}.codex.jsonl`);
|
|
3202
|
+
ensureDir(eventsPath);
|
|
3203
|
+
const hookText = codexSessionStartHookToml(eventsPath);
|
|
3204
|
+
if (config?.cleanupPaths[0] && config.args.includes("--profile")) {
|
|
3205
|
+
const profilePath2 = config.cleanupPaths[0];
|
|
3206
|
+
const existing = readFileSync5(profilePath2, "utf-8");
|
|
3207
|
+
writeFileSync4(profilePath2, `${existing.trimEnd()}
|
|
3208
|
+
|
|
3209
|
+
${hookText}`, "utf-8");
|
|
3210
|
+
return {
|
|
3211
|
+
...config,
|
|
3212
|
+
args: addCodexHookTrustBypassArg(config.args),
|
|
3213
|
+
cleanupPaths: [...config.cleanupPaths, eventsPath],
|
|
3214
|
+
eventsPath
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
const profileName = `starling-run-${randomUUID2()}`;
|
|
3218
|
+
const profilePath = join7(DEFAULT_CODEX_HOME, `${profileName}.config.toml`);
|
|
3219
|
+
ensureDir(profilePath);
|
|
3220
|
+
writeFileSync4(profilePath, hookText, "utf-8");
|
|
3221
|
+
chmodSync4(profilePath, 384);
|
|
3222
|
+
return {
|
|
3223
|
+
args: ["--profile", profileName, ...addCodexHookTrustBypassArg(config?.args ?? [])],
|
|
3224
|
+
cleanupPaths: [profilePath, eventsPath, ...config?.cleanupPaths ?? []],
|
|
3225
|
+
cleanupTasks: config?.cleanupTasks,
|
|
3226
|
+
env: config?.env,
|
|
3227
|
+
eventsPath
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
function codexSessionStartHookToml(eventsPath) {
|
|
3231
|
+
return [
|
|
3232
|
+
"[features]",
|
|
3233
|
+
"hooks = true",
|
|
3234
|
+
"",
|
|
3235
|
+
"[[hooks.SessionStart]]",
|
|
3236
|
+
'matcher = "startup"',
|
|
3237
|
+
"",
|
|
3238
|
+
"[[hooks.SessionStart.hooks]]",
|
|
3239
|
+
'type = "command"',
|
|
3240
|
+
`command = ${JSON.stringify(`bash -c 'cat >> "$1"; printf "\\n" >> "$1"' _ ${shellQuote(eventsPath)}`)}`,
|
|
3241
|
+
"timeout = 5"
|
|
3242
|
+
].join("\n") + "\n";
|
|
3243
|
+
}
|
|
3244
|
+
function addCodexHookTrustBypassArg(args) {
|
|
3245
|
+
return args.includes("--dangerously-bypass-hook-trust") ? args : ["--dangerously-bypass-hook-trust", ...args];
|
|
3246
|
+
}
|
|
3247
|
+
async function createCodexRunConfigFromProfile(profile) {
|
|
3248
|
+
const args = [];
|
|
3249
|
+
const cleanupPaths = [];
|
|
3250
|
+
const cleanupTasks = [];
|
|
3251
|
+
let configText = profile.configText;
|
|
3252
|
+
if (profile.chatProxy) {
|
|
3253
|
+
const proxy = await startCodexChatProxy({
|
|
3254
|
+
upstreamBaseUrl: profile.chatProxy.upstreamBaseUrl,
|
|
3255
|
+
apiKey: profile.chatProxy.apiKey,
|
|
3256
|
+
model: profile.chatProxy.model
|
|
3257
|
+
});
|
|
3258
|
+
cleanupTasks.push(proxy.close);
|
|
3259
|
+
configText = codexProxyConfigText(profile.chatProxy.config, proxy.baseUrl);
|
|
3260
|
+
console.error(chalk6.gray(`Starling Codex adapter: routing ${profile.chatProxy.providerName} via ${proxy.baseUrl}`));
|
|
3261
|
+
}
|
|
3262
|
+
if (configText) {
|
|
3263
|
+
const profileName = `starling-run-${randomUUID2()}`;
|
|
3264
|
+
const profilePath = join7(DEFAULT_CODEX_HOME, `${profileName}.config.toml`);
|
|
3265
|
+
ensureDir(profilePath);
|
|
3266
|
+
writeFileSync4(profilePath, configText, "utf-8");
|
|
3267
|
+
chmodSync4(profilePath, 384);
|
|
3268
|
+
args.push("--profile", profileName);
|
|
3269
|
+
cleanupPaths.push(profilePath);
|
|
3270
|
+
}
|
|
3271
|
+
if (profile.inlineConfig) {
|
|
3272
|
+
for (const [key, value] of flattenCodexConfig(profile.inlineConfig)) {
|
|
3273
|
+
args.push("--config", `${key}=${toCodexConfigValue(value)}`);
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
return { args, cleanupPaths, cleanupTasks, env: profile.env };
|
|
3277
|
+
}
|
|
3278
|
+
async function cleanupCodexRunConfig(config) {
|
|
3279
|
+
if (!config) return;
|
|
3280
|
+
for (const path of config.cleanupPaths) {
|
|
3281
|
+
try {
|
|
3282
|
+
unlinkSync6(path);
|
|
3283
|
+
} catch {
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
for (const cleanup of config.cleanupTasks ?? []) {
|
|
3287
|
+
try {
|
|
3288
|
+
await cleanup();
|
|
3289
|
+
} catch {
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
function syncCodexProfileProjectTrustFromRunConfig(sourceConfigPath, runConfig) {
|
|
3294
|
+
if (!sourceConfigPath || !runConfig) return;
|
|
3295
|
+
const sourceExt = extname2(sourceConfigPath).toLowerCase();
|
|
3296
|
+
if (sourceExt !== ".json" && sourceExt !== ".jsonc") return;
|
|
3297
|
+
const trustedProjects = /* @__PURE__ */ new Set();
|
|
3298
|
+
for (const path of runConfig.cleanupPaths) {
|
|
3299
|
+
if (!path.endsWith(".config.toml") || !existsSync6(path)) continue;
|
|
3300
|
+
for (const projectPath of readTrustedProjectsFromCodexToml(path)) {
|
|
3301
|
+
trustedProjects.add(projectPath);
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
if (trustedProjects.size === 0) return;
|
|
3305
|
+
try {
|
|
3306
|
+
const raw = readFileSync5(sourceConfigPath, "utf-8");
|
|
3307
|
+
const parsed = JSON.parse(sourceExt === ".jsonc" ? stripJsonComments(raw) : raw);
|
|
3308
|
+
if (!isRecord4(parsed)) return;
|
|
3309
|
+
const config = isRecord4(parsed.config) ? parsed.config : {};
|
|
3310
|
+
const projects = isRecord4(config.projects) ? config.projects : {};
|
|
3311
|
+
let changed = false;
|
|
3312
|
+
for (const projectPath of trustedProjects) {
|
|
3313
|
+
const project = isRecord4(projects[projectPath]) ? projects[projectPath] : {};
|
|
3314
|
+
if (project.trust_level === "trusted") continue;
|
|
3315
|
+
project.trust_level = "trusted";
|
|
3316
|
+
projects[projectPath] = project;
|
|
3317
|
+
changed = true;
|
|
3318
|
+
}
|
|
3319
|
+
if (!changed) return;
|
|
3320
|
+
config.projects = projects;
|
|
3321
|
+
parsed.config = config;
|
|
3322
|
+
atomicWriteJSON(sourceConfigPath, parsed);
|
|
3323
|
+
} catch (error) {
|
|
3324
|
+
console.error(chalk6.yellow(`Could not sync Codex project trust to ${sourceConfigPath}: ${String(error)}`));
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
function readTrustedProjectsFromCodexToml(filePath) {
|
|
3328
|
+
const raw = readFileSync5(filePath, "utf-8");
|
|
3329
|
+
const trusted = [];
|
|
3330
|
+
let currentProject = null;
|
|
3331
|
+
let currentTrusted = false;
|
|
3332
|
+
const flush = () => {
|
|
3333
|
+
if (currentProject && currentTrusted) trusted.push(currentProject);
|
|
3334
|
+
};
|
|
3335
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
3336
|
+
const section = line.match(/^\s*\[projects\.(?:"([^"]+)"|'([^']+)'|([^\]]+))\]\s*$/);
|
|
3337
|
+
if (section) {
|
|
3338
|
+
flush();
|
|
3339
|
+
currentProject = section[1] ?? section[2] ?? section[3] ?? null;
|
|
3340
|
+
currentTrusted = false;
|
|
3341
|
+
continue;
|
|
3342
|
+
}
|
|
3343
|
+
if (!currentProject) continue;
|
|
3344
|
+
const trust = line.match(/^\s*trust_level\s*=\s*(?:"trusted"|'trusted')\s*(?:#.*)?$/);
|
|
3345
|
+
if (trust) currentTrusted = true;
|
|
3346
|
+
}
|
|
3347
|
+
flush();
|
|
3348
|
+
return trusted;
|
|
3349
|
+
}
|
|
3350
|
+
function readCodexJsonProfileForRun(configPath, allowComments) {
|
|
3351
|
+
try {
|
|
3352
|
+
const raw = readFileSync5(configPath, "utf-8");
|
|
3353
|
+
const parsed = JSON.parse(allowComments ? stripJsonComments(raw) : raw);
|
|
3354
|
+
if (!isRecord4(parsed)) {
|
|
3355
|
+
console.error(chalk6.red(`Codex config must be a JSON object: ${configPath}`));
|
|
3356
|
+
process.exit(1);
|
|
3357
|
+
}
|
|
3358
|
+
const auth = resolveCodexProfileAuth(parsed);
|
|
3359
|
+
const chatProxy = resolveCodexChatProxySpec(parsed, auth);
|
|
3360
|
+
const configText = chatProxy ? convertCodexJsonToToml(chatProxy.config) : resolveCodexProfileConfigText(parsed);
|
|
3361
|
+
const env = chatProxy ? resolveStringEnv(parsed.env) : resolveCodexProfileEnv(parsed, auth, configText);
|
|
3362
|
+
const inlineConfig = resolveCodexInlineConfig(parsed);
|
|
3363
|
+
return { inlineConfig, configText, env, chatProxy };
|
|
3364
|
+
} catch (error) {
|
|
3365
|
+
console.error(chalk6.red(`Could not parse Codex config JSON: ${configPath}`));
|
|
3366
|
+
console.error(chalk6.gray(String(error)));
|
|
3367
|
+
process.exit(1);
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
function resolveCodexProfileConfigText(profile) {
|
|
3371
|
+
const value = profile.config;
|
|
3372
|
+
if (typeof value === "string" && value.trim()) {
|
|
3373
|
+
return value;
|
|
3374
|
+
}
|
|
3375
|
+
if (isRecord4(value)) {
|
|
3376
|
+
const toml = convertCodexJsonToToml(value);
|
|
3377
|
+
return toml.trim() ? toml : null;
|
|
3378
|
+
}
|
|
3379
|
+
return null;
|
|
3380
|
+
}
|
|
3381
|
+
function resolveCodexProfileAuth(profile) {
|
|
3382
|
+
if (isRecord4(profile.auth)) {
|
|
3383
|
+
return profile.auth;
|
|
3384
|
+
}
|
|
3385
|
+
const candidateKeys = ["OPENAI_API_KEY", "openai_api_key", "apiKey", "api_key"];
|
|
3386
|
+
for (const key of candidateKeys) {
|
|
3387
|
+
const value = profile[key];
|
|
3388
|
+
if (typeof value === "string" && value.trim()) {
|
|
3389
|
+
return { OPENAI_API_KEY: value };
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
if (typeof profile.token === "string" && profile.token.trim()) {
|
|
3393
|
+
return { OPENAI_API_KEY: profile.token };
|
|
3394
|
+
}
|
|
3395
|
+
return null;
|
|
3396
|
+
}
|
|
3397
|
+
function resolveCodexProfileEnv(profile, auth, configText) {
|
|
3398
|
+
const env = {};
|
|
3399
|
+
const candidateKeys = ["OPENAI_API_KEY", "openai_api_key", "apiKey", "api_key", "token"];
|
|
3400
|
+
for (const key of candidateKeys) {
|
|
3401
|
+
const value = auth?.[key] ?? (key !== "token" && isRecord4(profile.env) ? profile.env[key] : void 0);
|
|
3402
|
+
if (typeof value === "string" && value.trim()) {
|
|
3403
|
+
env.OPENAI_API_KEY = value;
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
if (isRecord4(profile.env)) {
|
|
3407
|
+
for (const [key, value] of Object.entries(profile.env)) {
|
|
3408
|
+
if (typeof value === "string" && value.trim()) {
|
|
3409
|
+
env[key] = value;
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
if (configText && isRecord4(profile.config) && typeof profile.config === "object" && profile.config !== null) {
|
|
3414
|
+
const providerName = resolveCodexModelProviderName(profile.config);
|
|
3415
|
+
const baseUrl = resolveCodexCustomProviderBaseUrl(profile.config, providerName);
|
|
3416
|
+
if (typeof baseUrl === "string" && baseUrl.trim()) {
|
|
3417
|
+
env.OPENAI_BASE_URL = env.OPENAI_BASE_URL || baseUrl;
|
|
3418
|
+
env.OPENAI_API_BASE_URL = env.OPENAI_API_BASE_URL || baseUrl;
|
|
3419
|
+
env.BASE_URL = env.BASE_URL || baseUrl;
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
return env;
|
|
3423
|
+
}
|
|
3424
|
+
function resolveStringEnv(value) {
|
|
3425
|
+
const env = {};
|
|
3426
|
+
if (!isRecord4(value)) return env;
|
|
3427
|
+
for (const [key, child] of Object.entries(value)) {
|
|
3428
|
+
if (typeof child === "string" && child.trim()) {
|
|
3429
|
+
env[key] = child;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
return env;
|
|
3433
|
+
}
|
|
3434
|
+
function resolveCodexChatProxySpec(profile, auth) {
|
|
3435
|
+
if (!isRecord4(profile.config)) return null;
|
|
3436
|
+
const providerName = resolveCodexModelProviderName(profile.config);
|
|
3437
|
+
if (!providerName) return null;
|
|
3438
|
+
const providers = profile.config.model_providers;
|
|
3439
|
+
if (!isRecord4(providers)) return null;
|
|
3440
|
+
const providerConfig = providers[providerName];
|
|
3441
|
+
if (!isRecord4(providerConfig)) return null;
|
|
3442
|
+
const upstreamBaseUrl = typeof providerConfig.base_url === "string" ? providerConfig.base_url.trim() : "";
|
|
3443
|
+
if (!upstreamBaseUrl) return null;
|
|
3444
|
+
const apiFormat = resolveCodexApiFormat(profile, profile.config, providerConfig);
|
|
3445
|
+
const providerLabel = `${providerName} ${stringValue2(providerConfig.name)} ${stringValue2(profile.config.model)} ${upstreamBaseUrl}`.toLowerCase();
|
|
3446
|
+
const shouldProxy = apiFormat === "openai_chat" || providerLabel.includes("deepseek");
|
|
3447
|
+
if (!shouldProxy) return null;
|
|
3448
|
+
const apiKey = resolveCodexApiKey(auth, profile);
|
|
3449
|
+
if (!apiKey) {
|
|
3450
|
+
console.error(chalk6.red("Codex chat adapter requires an API key in auth.OPENAI_API_KEY or OPENAI_API_KEY."));
|
|
3451
|
+
process.exit(1);
|
|
3452
|
+
}
|
|
3453
|
+
return {
|
|
3454
|
+
providerName,
|
|
3455
|
+
upstreamBaseUrl,
|
|
3456
|
+
apiKey,
|
|
3457
|
+
model: typeof profile.config.model === "string" ? profile.config.model : void 0,
|
|
3458
|
+
config: cloneRecord(profile.config)
|
|
3459
|
+
};
|
|
3460
|
+
}
|
|
3461
|
+
function codexProxyConfigText(config, proxyBaseUrl) {
|
|
3462
|
+
const cloned = cloneRecord(config);
|
|
3463
|
+
const providerName = resolveCodexModelProviderName(cloned);
|
|
3464
|
+
if (!providerName || !isRecord4(cloned.model_providers)) {
|
|
3465
|
+
return convertCodexJsonToToml(cloned);
|
|
3466
|
+
}
|
|
3467
|
+
const providerConfig = cloned.model_providers[providerName];
|
|
3468
|
+
if (isRecord4(providerConfig)) {
|
|
3469
|
+
providerConfig.base_url = proxyBaseUrl;
|
|
3470
|
+
providerConfig.wire_api = "responses";
|
|
3471
|
+
providerConfig.requires_openai_auth = false;
|
|
3472
|
+
delete providerConfig.env_key;
|
|
3473
|
+
delete providerConfig.experimental_bearer_token;
|
|
3474
|
+
delete providerConfig.auth;
|
|
3475
|
+
}
|
|
3476
|
+
return convertCodexJsonToToml(cloned);
|
|
3477
|
+
}
|
|
3478
|
+
function resolveCodexApiFormat(...values) {
|
|
3479
|
+
for (const value of values) {
|
|
3480
|
+
const apiFormat = stringValue2(value.api_format) || stringValue2(value.apiFormat);
|
|
3481
|
+
if (apiFormat) return apiFormat;
|
|
3482
|
+
}
|
|
3483
|
+
return null;
|
|
3484
|
+
}
|
|
3485
|
+
function resolveCodexApiKey(auth, profile) {
|
|
3486
|
+
const candidateKeys = ["OPENAI_API_KEY", "openai_api_key", "apiKey", "api_key", "token"];
|
|
3487
|
+
for (const key of candidateKeys) {
|
|
3488
|
+
const value = auth?.[key] ?? profile[key] ?? (isRecord4(profile.env) ? profile.env[key] : void 0);
|
|
3489
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
3490
|
+
}
|
|
3491
|
+
return null;
|
|
3492
|
+
}
|
|
3493
|
+
function cloneRecord(value) {
|
|
3494
|
+
return JSON.parse(JSON.stringify(value));
|
|
3495
|
+
}
|
|
3496
|
+
function stringValue2(value) {
|
|
3497
|
+
return typeof value === "string" ? value : "";
|
|
3498
|
+
}
|
|
3499
|
+
function resolveCodexModelProviderName(configValue) {
|
|
3500
|
+
const provider = configValue.model_provider;
|
|
3501
|
+
if (typeof provider === "string" && provider.trim()) return provider.trim();
|
|
3502
|
+
return null;
|
|
3503
|
+
}
|
|
3504
|
+
function resolveCodexCustomProviderBaseUrl(configValue, providerName) {
|
|
3505
|
+
if (!providerName) return null;
|
|
3506
|
+
const providers = configValue.model_providers;
|
|
3507
|
+
if (!isRecord4(providers)) return null;
|
|
3508
|
+
const providerConfig = providers[providerName];
|
|
3509
|
+
if (!isRecord4(providerConfig)) return null;
|
|
3510
|
+
const baseUrl = providerConfig.base_url;
|
|
3511
|
+
if (typeof baseUrl === "string" && baseUrl.trim()) return baseUrl.trim();
|
|
3512
|
+
return null;
|
|
3513
|
+
}
|
|
3514
|
+
function resolveCodexInlineConfig(profile) {
|
|
3515
|
+
if (typeof profile.config !== "undefined" && typeof profile.config !== "string") {
|
|
3516
|
+
return null;
|
|
3517
|
+
}
|
|
3518
|
+
const config = { ...profile };
|
|
3519
|
+
delete config.auth;
|
|
3520
|
+
delete config.config;
|
|
3521
|
+
return Object.keys(config).length > 0 ? config : null;
|
|
3522
|
+
}
|
|
3523
|
+
function stripJsonComments(value) {
|
|
3524
|
+
return value.replace(/^\s*\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
3525
|
+
}
|
|
3526
|
+
function flattenCodexConfig(value, prefix = "") {
|
|
3527
|
+
const entries = [];
|
|
3528
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
3529
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
3530
|
+
if (isRecord4(nestedValue)) {
|
|
3531
|
+
entries.push(...flattenCodexConfig(nestedValue, path));
|
|
3532
|
+
continue;
|
|
3533
|
+
}
|
|
3534
|
+
entries.push([path, nestedValue]);
|
|
3535
|
+
}
|
|
3536
|
+
return entries;
|
|
3537
|
+
}
|
|
3538
|
+
function toCodexConfigValue(value) {
|
|
3539
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
3540
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
3541
|
+
if (value === null) {
|
|
3542
|
+
console.error(chalk6.red("Codex config values cannot be null."));
|
|
3543
|
+
process.exit(1);
|
|
3544
|
+
}
|
|
3545
|
+
return JSON.stringify(value);
|
|
3546
|
+
}
|
|
3547
|
+
function toTomlValue(value) {
|
|
3548
|
+
if (isRecord4(value)) {
|
|
3549
|
+
const segments = [];
|
|
3550
|
+
for (const [k, v] of Object.entries(value)) {
|
|
3551
|
+
if (typeof v === "undefined") continue;
|
|
3552
|
+
segments.push(`${toTomlKey(k)} = ${toTomlValue(v)}`);
|
|
3553
|
+
}
|
|
3554
|
+
return `{ ${segments.join(", ")} }`;
|
|
3555
|
+
}
|
|
3556
|
+
if (Array.isArray(value)) {
|
|
3557
|
+
const entries = value.filter((item) => typeof item !== "undefined").map((item) => toTomlValue(item));
|
|
3558
|
+
return `[${entries.join(", ")}]`;
|
|
3559
|
+
}
|
|
3560
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
3561
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
3562
|
+
if (value === null) {
|
|
3563
|
+
console.error(chalk6.red("Codex config values cannot be null."));
|
|
3564
|
+
process.exit(1);
|
|
3565
|
+
}
|
|
3566
|
+
return JSON.stringify(String(value));
|
|
3567
|
+
}
|
|
3568
|
+
function toTomlKey(key) {
|
|
3569
|
+
return /^\w+$/.test(key) ? key : JSON.stringify(key);
|
|
3570
|
+
}
|
|
3571
|
+
function serializeTomlObject(value, prefix, lines) {
|
|
3572
|
+
for (const [key, child] of Object.entries(value)) {
|
|
3573
|
+
if (typeof child === "undefined") continue;
|
|
3574
|
+
if (isRecord4(child)) {
|
|
3575
|
+
const nextPath = [...prefix, key];
|
|
3576
|
+
lines.push("");
|
|
3577
|
+
lines.push(`[${[...nextPath].map(toTomlKey).join(".")}]`);
|
|
3578
|
+
serializeTomlObject(child, nextPath, lines);
|
|
3579
|
+
continue;
|
|
3580
|
+
}
|
|
3581
|
+
if (Array.isArray(child)) {
|
|
3582
|
+
lines.push(`${toTomlKey(key)} = ${toTomlValue(child)}`);
|
|
3583
|
+
continue;
|
|
3584
|
+
}
|
|
3585
|
+
lines.push(`${toTomlKey(key)} = ${toTomlValue(child)}`);
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
function convertCodexJsonToToml(value) {
|
|
3589
|
+
const lines = [];
|
|
3590
|
+
serializeTomlObject(value, [], lines);
|
|
3591
|
+
return lines.length > 0 ? `${lines.join("\n")}
|
|
3592
|
+
` : "";
|
|
3593
|
+
}
|
|
3594
|
+
function readRunHookSessionId(eventsPath) {
|
|
3595
|
+
if (!eventsPath || !existsSync6(eventsPath)) return null;
|
|
3596
|
+
let raw = "";
|
|
3597
|
+
try {
|
|
3598
|
+
raw = readFileSync5(eventsPath, "utf-8");
|
|
3599
|
+
} catch {
|
|
3600
|
+
return null;
|
|
3601
|
+
}
|
|
3602
|
+
const lines = raw.trim().split(/\r?\n/).filter(Boolean).reverse();
|
|
3603
|
+
for (const line of lines) {
|
|
3604
|
+
try {
|
|
3605
|
+
const entry = JSON.parse(line);
|
|
3606
|
+
const sessionId = readSessionIdFromHookEntry(entry);
|
|
3607
|
+
if (sessionId) return sessionId;
|
|
3608
|
+
} catch {
|
|
3609
|
+
const sessionId = parseSessionIdFromText(line);
|
|
3610
|
+
if (sessionId) return sessionId;
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
return null;
|
|
3614
|
+
}
|
|
3615
|
+
function readSessionIdFromHookEntry(value) {
|
|
3616
|
+
if (!isRecord4(value)) return null;
|
|
3617
|
+
const direct = value.session_id ?? value.sessionId;
|
|
3618
|
+
if (typeof direct === "string" && SESSION_ID_PATTERN.test(direct)) return direct;
|
|
3619
|
+
for (const nested of Object.values(value)) {
|
|
3620
|
+
const found = readSessionIdFromHookEntry(nested);
|
|
3621
|
+
if (found) return found;
|
|
3622
|
+
}
|
|
3623
|
+
return null;
|
|
3624
|
+
}
|
|
3625
|
+
function readClaudeSettingsObject(configPath) {
|
|
3626
|
+
if (!configPath) return {};
|
|
3627
|
+
try {
|
|
3628
|
+
const raw = readFileSync5(configPath, "utf-8");
|
|
3629
|
+
const parsed = JSON.parse(raw);
|
|
3630
|
+
if (isRecord4(parsed)) return parsed;
|
|
3631
|
+
} catch {
|
|
3632
|
+
console.log(chalk6.yellow("Could not add Claude SessionStart hook because settings is not parseable JSON."));
|
|
3633
|
+
}
|
|
3634
|
+
return null;
|
|
3635
|
+
}
|
|
3636
|
+
function isRecord4(value) {
|
|
3637
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3638
|
+
}
|
|
3639
|
+
function shellQuote(value) {
|
|
3640
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
3641
|
+
}
|
|
3642
|
+
function resolveConfigFilePath(provider, configFile) {
|
|
3643
|
+
if (!configFile) return null;
|
|
3644
|
+
if (isAbsolute2(configFile) || existsSync6(configFile)) {
|
|
3645
|
+
if (!existsSync6(configFile)) {
|
|
3646
|
+
console.error(chalk6.red(`Config file not found: ${configFile}`));
|
|
3647
|
+
process.exit(1);
|
|
3648
|
+
}
|
|
3649
|
+
return configFile;
|
|
3650
|
+
}
|
|
3651
|
+
const baseDir = provider === "claude" ? DEFAULT_CLAUDE_SETTINGS_DIR : DEFAULT_CODEX_SETTINGS_DIR;
|
|
3652
|
+
const fileName = basename2(configFile);
|
|
3653
|
+
const candidate = join7(baseDir, fileName);
|
|
3654
|
+
if (existsSync6(candidate)) return candidate;
|
|
3655
|
+
const candidatesTried = [candidate];
|
|
3656
|
+
if (!extname2(fileName)) {
|
|
3657
|
+
for (const ext of CONFIG_FILE_EXTENSIONS) {
|
|
3658
|
+
const candidateWithExtension = `${candidate}${ext}`;
|
|
3659
|
+
candidatesTried.push(candidateWithExtension);
|
|
3660
|
+
if (existsSync6(candidateWithExtension)) return candidateWithExtension;
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
console.error(chalk6.red(`Config file not found: ${configFile}`));
|
|
3664
|
+
console.error(chalk6.gray(`Expected path: ${candidate}`));
|
|
3665
|
+
console.error(
|
|
3666
|
+
chalk6.gray(`Tried: ${candidatesTried.map((path) => path.replace(`${DEFAULT_CLAUDE_SETTINGS_DIR}/`, "").replace(`${DEFAULT_CODEX_SETTINGS_DIR}/`, "")).join(", ")}`)
|
|
3667
|
+
);
|
|
3668
|
+
process.exit(1);
|
|
3669
|
+
}
|
|
3670
|
+
async function resolveCatalog2(catalog) {
|
|
3671
|
+
if (!catalog) return null;
|
|
3672
|
+
const existing = resolveCatalogReference(catalog);
|
|
3673
|
+
if (existing.kind === "found") return existing.space;
|
|
3674
|
+
if (existing.kind === "ambiguous") {
|
|
3675
|
+
console.error(chalk6.red(`Ambiguous catalog reference: ${catalog}`));
|
|
3676
|
+
console.error(chalk6.red("Use a catalog path like parent/child or the catalog id."));
|
|
3677
|
+
for (const match of existing.matches) {
|
|
3678
|
+
console.error(chalk6.gray(` ${catalogPath(match, listSpaces())} (${match.id})`));
|
|
3679
|
+
}
|
|
3680
|
+
process.exit(1);
|
|
3681
|
+
}
|
|
3682
|
+
if (!process.stdin.isTTY) {
|
|
3683
|
+
console.error(chalk6.red(`Catalog not found: ${catalog}`));
|
|
3684
|
+
console.error(chalk6.yellow(`Create it first: starling catalog create ${catalog}`));
|
|
3685
|
+
process.exit(1);
|
|
3686
|
+
}
|
|
3687
|
+
const input = await askCreateCatalog(catalog);
|
|
3688
|
+
if (!input) {
|
|
3689
|
+
console.error(chalk6.yellow(`Catalog not found: ${catalog}`));
|
|
3690
|
+
process.exit(1);
|
|
3691
|
+
}
|
|
3692
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3693
|
+
const created = createCatalogPath2(catalog, now);
|
|
3694
|
+
console.log(chalk6.green(`Created catalog: ${created.id} "${catalogPath(created)}"`));
|
|
3695
|
+
return created;
|
|
3696
|
+
}
|
|
3697
|
+
async function askCreateCatalog(catalog) {
|
|
3698
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
3699
|
+
try {
|
|
3700
|
+
const answer = await rl.question(`Catalog not found: ${chalk6.yellow(catalog)}. Create it now? (y/N) `);
|
|
3701
|
+
const normalized = answer.trim().toLowerCase();
|
|
3702
|
+
return normalized === "y" || normalized === "yes";
|
|
3703
|
+
} catch (error) {
|
|
3704
|
+
if (isReadlineAbort(error)) {
|
|
3705
|
+
return false;
|
|
3706
|
+
}
|
|
3707
|
+
throw error;
|
|
3708
|
+
} finally {
|
|
3709
|
+
rl.close();
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
function isReadlineAbort(error) {
|
|
3713
|
+
return Boolean(
|
|
3714
|
+
error && typeof error === "object" && "code" in error && error.code === "ABORT_ERR"
|
|
3715
|
+
);
|
|
3716
|
+
}
|
|
3717
|
+
function createCatalogPath2(pathRef, now) {
|
|
3718
|
+
const parts = pathRef.split("/").map((part) => part.trim()).filter(Boolean);
|
|
3719
|
+
if (parts.length === 0) {
|
|
3720
|
+
console.error(chalk6.red("Catalog name cannot be empty."));
|
|
3721
|
+
process.exit(1);
|
|
3722
|
+
}
|
|
3723
|
+
let parentId = null;
|
|
3724
|
+
let currentSpace;
|
|
3725
|
+
for (const part of parts) {
|
|
3726
|
+
const existing = findSiblingCatalog(part, parentId);
|
|
3727
|
+
if (existing) {
|
|
3728
|
+
currentSpace = existing;
|
|
3729
|
+
parentId = existing.id;
|
|
3730
|
+
continue;
|
|
3731
|
+
}
|
|
3732
|
+
currentSpace = {
|
|
3733
|
+
id: generateSpaceId(listSpaces()),
|
|
3734
|
+
name: part,
|
|
3735
|
+
description: "",
|
|
3736
|
+
tags: [],
|
|
3737
|
+
parent_id: parentId,
|
|
3738
|
+
created_at: now,
|
|
3739
|
+
updated_at: now
|
|
3740
|
+
};
|
|
3741
|
+
addSpace(currentSpace);
|
|
3742
|
+
parentId = currentSpace.id;
|
|
3743
|
+
}
|
|
3744
|
+
return currentSpace;
|
|
3745
|
+
}
|
|
3746
|
+
function findSiblingCatalog(name, parentId) {
|
|
3747
|
+
return listSpaces().find((space) => space.name === name && space.parent_id === parentId);
|
|
3748
|
+
}
|
|
3749
|
+
function resolveAgentArgs(provider, rawArgs, parsedArgs, configPath, codexConfig) {
|
|
3750
|
+
const args = rawArgs ? parsePassthroughArgs(rawArgs, parsedArgs) : parsedArgs;
|
|
3751
|
+
if (provider === "codex") {
|
|
3752
|
+
return [...codexConfig?.args ?? [], ...args];
|
|
3753
|
+
}
|
|
3754
|
+
if (!configPath) return args;
|
|
3755
|
+
return ["--settings", configPath, ...args];
|
|
3756
|
+
}
|
|
3757
|
+
function parsePassthroughArgs(rawArgs, parsedArgs) {
|
|
3758
|
+
if (!rawArgs) return parsedArgs;
|
|
3759
|
+
const separatorIndex = rawArgs.lastIndexOf("--");
|
|
3760
|
+
if (separatorIndex === -1) return parsedArgs;
|
|
3761
|
+
return rawArgs.slice(separatorIndex + 1);
|
|
3762
|
+
}
|
|
3763
|
+
async function runAgent(binary, args, cwd, options) {
|
|
3764
|
+
return new Promise((resolvePromise, reject) => {
|
|
3765
|
+
const childEnv = options?.env;
|
|
3766
|
+
const child = spawn2(binary, args, {
|
|
3767
|
+
stdio: "inherit",
|
|
3768
|
+
cwd,
|
|
3769
|
+
env: childEnv
|
|
3770
|
+
});
|
|
3771
|
+
let terminalInterrupted = false;
|
|
3772
|
+
const onSigInt = () => {
|
|
3773
|
+
terminalInterrupted = true;
|
|
3774
|
+
child.kill("SIGINT");
|
|
3775
|
+
};
|
|
3776
|
+
if (options?.preserveSignals) {
|
|
3777
|
+
process.on("SIGINT", onSigInt);
|
|
3778
|
+
}
|
|
3779
|
+
child.on("error", (err) => {
|
|
3780
|
+
if (options?.preserveSignals) {
|
|
3781
|
+
process.off("SIGINT", onSigInt);
|
|
3782
|
+
}
|
|
3783
|
+
reject(err);
|
|
3784
|
+
});
|
|
3785
|
+
child.on("close", (code) => {
|
|
3786
|
+
if (options?.preserveSignals) {
|
|
3787
|
+
process.off("SIGINT", onSigInt);
|
|
3788
|
+
}
|
|
3789
|
+
if (terminalInterrupted) {
|
|
3790
|
+
return resolvePromise({ exitCode: 130 });
|
|
3791
|
+
}
|
|
3792
|
+
resolvePromise({ exitCode: code ?? 0 });
|
|
3793
|
+
});
|
|
3794
|
+
});
|
|
3795
|
+
}
|
|
3796
|
+
async function snapshotSessions(provider) {
|
|
3797
|
+
const sessions = await findSessions(RUN_SESSION_SCAN_LIMIT, provider);
|
|
3798
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
3799
|
+
for (const session of sessions) {
|
|
3800
|
+
const modifiedAt = Date.parse(session.modified_at);
|
|
3801
|
+
snapshot.set(session.session_id, Number.isFinite(modifiedAt) ? modifiedAt : 0);
|
|
3802
|
+
}
|
|
3803
|
+
return snapshot;
|
|
3804
|
+
}
|
|
3805
|
+
function wasSessionTouchedAfterRun(session, startedAt, beforeRun) {
|
|
3806
|
+
const modifiedAt = Date.parse(session.modified_at);
|
|
3807
|
+
if (!Number.isFinite(modifiedAt) || modifiedAt < startedAt) return false;
|
|
3808
|
+
const previousModifiedAt = beforeRun.get(session.session_id);
|
|
3809
|
+
if (previousModifiedAt === void 0) return true;
|
|
3810
|
+
return modifiedAt > previousModifiedAt;
|
|
3811
|
+
}
|
|
3812
|
+
async function detectSessionStartedAfterRun(provider, startedAt, beforeRun, cwd, beforeRunProjectFiles = /* @__PURE__ */ new Map(), knownSessionId) {
|
|
3813
|
+
const startedTime = Date.parse(startedAt);
|
|
3814
|
+
const graceUntil = Date.now() + RUN_SESSION_DETECT_FULL_SCAN_THRESHOLD_MS;
|
|
3815
|
+
const normalizedCwd = cwd ? normalizeProjectPath(cwd) : "";
|
|
3816
|
+
if (knownSessionId) {
|
|
3817
|
+
const hintedSession = await tryResolveKnownSession(
|
|
3818
|
+
knownSessionId,
|
|
3819
|
+
provider,
|
|
3820
|
+
startedTime,
|
|
3821
|
+
beforeRun,
|
|
3822
|
+
normalizedCwd
|
|
3823
|
+
);
|
|
3824
|
+
if (hintedSession) {
|
|
3825
|
+
return hintedSession;
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
for (let attempt = 0; attempt < RUN_SESSION_DETECT_ATTEMPTS; attempt++) {
|
|
3829
|
+
if (provider === "codex") {
|
|
3830
|
+
const codexMatches = await collectSessionCandidatesByModifiedTime(
|
|
3831
|
+
CODEX_SESSIONS_DIR,
|
|
3832
|
+
startedTime,
|
|
3833
|
+
beforeRun,
|
|
3834
|
+
"codex"
|
|
3835
|
+
);
|
|
3836
|
+
if (codexMatches.length > 0) {
|
|
3837
|
+
return pickBestMatch(codexMatches, startedTime, beforeRun, cwd);
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
const recentSessions = await findSessions(RUN_SESSION_SCAN_LIMIT, provider);
|
|
3841
|
+
const recentMatches = recentSessions.filter(
|
|
3842
|
+
(session) => wasSessionTouchedAfterRun(session, startedTime, beforeRun)
|
|
3843
|
+
);
|
|
3844
|
+
if (recentMatches.length > 0) {
|
|
3845
|
+
return pickBestMatch(recentMatches, startedTime, beforeRun, cwd);
|
|
3846
|
+
}
|
|
3847
|
+
const fallbackLimit = RUN_SESSION_SCAN_LIMIT * Math.max(1, Math.min(attempt + 1, 4));
|
|
3848
|
+
const allMatches = [];
|
|
3849
|
+
for await (const session of streamSessions(provider, fallbackLimit)) {
|
|
3850
|
+
if (!wasSessionTouchedAfterRun(session, startedTime, beforeRun)) {
|
|
3851
|
+
continue;
|
|
3852
|
+
}
|
|
3853
|
+
allMatches.push(session);
|
|
3854
|
+
}
|
|
3855
|
+
if (allMatches.length > 0) {
|
|
3856
|
+
return pickBestMatch(allMatches, startedTime, beforeRun, cwd);
|
|
3857
|
+
}
|
|
3858
|
+
if (provider === "claude" && normalizedCwd) {
|
|
3859
|
+
const projectMatch = await detectSessionInCurrentClaudeProject(
|
|
3860
|
+
startedTime,
|
|
3861
|
+
beforeRun,
|
|
3862
|
+
normalizedCwd,
|
|
3863
|
+
beforeRunProjectFiles
|
|
3864
|
+
);
|
|
3865
|
+
if (projectMatch) {
|
|
3866
|
+
return projectMatch;
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
if (attempt + 1 < RUN_SESSION_DETECT_ATTEMPTS) {
|
|
3870
|
+
await sleep(RUN_SESSION_DETECT_INTERVAL_MS);
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
const fullScanMatches = [];
|
|
3874
|
+
for await (const session of streamSessions(provider, Infinity)) {
|
|
3875
|
+
if (!wasSessionTouchedAfterRun(session, startedTime, beforeRun)) continue;
|
|
3876
|
+
fullScanMatches.push(session);
|
|
3877
|
+
}
|
|
3878
|
+
if (fullScanMatches.length > 0) {
|
|
3879
|
+
return pickBestMatch(fullScanMatches, startedTime, beforeRun, cwd);
|
|
3880
|
+
}
|
|
3881
|
+
if (provider === "claude" && normalizedCwd) {
|
|
3882
|
+
const projectMatch = await detectSessionInCurrentClaudeProject(
|
|
3883
|
+
startedTime,
|
|
3884
|
+
beforeRun,
|
|
3885
|
+
normalizedCwd,
|
|
3886
|
+
beforeRunProjectFiles
|
|
3887
|
+
);
|
|
3888
|
+
if (projectMatch) {
|
|
3889
|
+
return projectMatch;
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
if (Date.now() < graceUntil) {
|
|
3893
|
+
await sleep(RUN_SESSION_DETECT_INTERVAL_MS);
|
|
3894
|
+
return detectSessionStartedAfterRun(
|
|
3895
|
+
provider,
|
|
3896
|
+
startedAt,
|
|
3897
|
+
beforeRun,
|
|
3898
|
+
cwd,
|
|
3899
|
+
beforeRunProjectFiles,
|
|
3900
|
+
knownSessionId
|
|
3901
|
+
);
|
|
3902
|
+
}
|
|
3903
|
+
return null;
|
|
3904
|
+
}
|
|
3905
|
+
function collectSessionFilesByModifiedTime(dir, sinceMs, accumulator, limit = 3e3) {
|
|
3906
|
+
if (accumulator.length >= limit) return;
|
|
3907
|
+
let entries;
|
|
3908
|
+
try {
|
|
3909
|
+
entries = readdirSync4(dir);
|
|
3910
|
+
} catch {
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3913
|
+
for (const entry of entries) {
|
|
3914
|
+
if (entry === "subagents") continue;
|
|
3915
|
+
const full = join7(dir, entry);
|
|
3916
|
+
let st;
|
|
3917
|
+
try {
|
|
3918
|
+
st = statSync4(full);
|
|
3919
|
+
} catch {
|
|
3920
|
+
continue;
|
|
3921
|
+
}
|
|
3922
|
+
if (st.isDirectory()) {
|
|
3923
|
+
collectSessionFilesByModifiedTime(full, sinceMs, accumulator, limit);
|
|
3924
|
+
continue;
|
|
3925
|
+
}
|
|
3926
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
3927
|
+
if (st.mtimeMs < sinceMs) continue;
|
|
3928
|
+
accumulator.push(full);
|
|
3929
|
+
if (accumulator.length >= limit) return;
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
async function collectSessionCandidatesByModifiedTime(baseDir, startedTime, beforeRun, provider, limit = 500) {
|
|
3933
|
+
const filePaths = [];
|
|
3934
|
+
collectSessionFilesByModifiedTime(baseDir, startedTime, filePaths, limit * 4);
|
|
3935
|
+
const matches = [];
|
|
3936
|
+
for (const filePath of filePaths) {
|
|
3937
|
+
try {
|
|
3938
|
+
const st = statSync4(filePath);
|
|
3939
|
+
const modifiedAt = new Date(st.mtimeMs).toISOString();
|
|
3940
|
+
const entries = await parseJsonlHead(filePath);
|
|
3941
|
+
const extract = provider === "codex" ? extractCodexSessionMeta : extractClaudeSessionMeta;
|
|
3942
|
+
const meta = extract(entries, filePath, modifiedAt);
|
|
3943
|
+
if (!meta) continue;
|
|
3944
|
+
if (wasSessionTouchedAfterRun(meta, startedTime, beforeRun)) {
|
|
3945
|
+
matches.push(meta);
|
|
3946
|
+
}
|
|
3947
|
+
} catch {
|
|
3948
|
+
continue;
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
return dedupeById(matches).sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
3952
|
+
}
|
|
3953
|
+
async function findSingleCodexSessionForRunningAgent(startedTime, beforeRun, normalizedCwd) {
|
|
3954
|
+
const candidates = await collectSessionCandidatesByModifiedTime(
|
|
3955
|
+
CODEX_SESSIONS_DIR,
|
|
3956
|
+
startedTime,
|
|
3957
|
+
beforeRun,
|
|
3958
|
+
"codex"
|
|
3959
|
+
);
|
|
3960
|
+
const sameProjectCandidates = candidates.filter(
|
|
3961
|
+
(session) => normalizeProjectPath(session.project_path) === normalizedCwd
|
|
3962
|
+
);
|
|
3963
|
+
if (sameProjectCandidates.length !== 1) return null;
|
|
3964
|
+
return sameProjectCandidates[0];
|
|
3965
|
+
}
|
|
3966
|
+
async function collectRunSessionCandidates(provider, startedAtMs, beforeRun, cwd, beforeRunProjectFiles = /* @__PURE__ */ new Map()) {
|
|
3967
|
+
const normalizedCwd = cwd ? normalizeProjectPath(cwd) : "";
|
|
3968
|
+
const matches = [];
|
|
3969
|
+
for await (const session of streamSessions(provider, RUN_SESSION_CATALOG_SCAN_LIMIT)) {
|
|
3970
|
+
if (!wasSessionTouchedAfterRun(session, startedAtMs, beforeRun)) continue;
|
|
3971
|
+
if (normalizedCwd && normalizeProjectPath(session.project_path) !== normalizedCwd) continue;
|
|
3972
|
+
matches.push(session);
|
|
3973
|
+
}
|
|
3974
|
+
if (provider === "claude" && normalizedCwd) {
|
|
3975
|
+
const currentProjectFiles = snapshotProjectSessions(normalizedCwd);
|
|
3976
|
+
for (const [filePath, fileModifiedAt] of currentProjectFiles) {
|
|
3977
|
+
const beforeModifiedAt = beforeRunProjectFiles.get(filePath);
|
|
3978
|
+
if (beforeModifiedAt !== void 0 && fileModifiedAt <= beforeModifiedAt) continue;
|
|
3979
|
+
if (!Number.isFinite(fileModifiedAt) || fileModifiedAt < startedAtMs) continue;
|
|
3980
|
+
const modifiedAt = new Date(fileModifiedAt).toISOString();
|
|
3981
|
+
let parsed = null;
|
|
3982
|
+
try {
|
|
3983
|
+
const parsedEntries = await parseJsonlHead(filePath);
|
|
3984
|
+
const parsedMeta = extractClaudeSessionMeta(parsedEntries, filePath, modifiedAt);
|
|
3985
|
+
parsed = parsedMeta ?? null;
|
|
3986
|
+
} catch {
|
|
3987
|
+
parsed = null;
|
|
3988
|
+
}
|
|
3989
|
+
matches.push({
|
|
3990
|
+
session_id: parsed?.session_id || basename2(filePath, ".jsonl"),
|
|
3991
|
+
provider: "claude",
|
|
3992
|
+
model: parsed?.model || "",
|
|
3993
|
+
project_path: parsed?.project_path || normalizedCwd,
|
|
3994
|
+
first_prompt: parsed?.first_prompt || "",
|
|
3995
|
+
file_path: filePath,
|
|
3996
|
+
created_at: parsed?.created_at || modifiedAt,
|
|
3997
|
+
modified_at: modifiedAt,
|
|
3998
|
+
...parsed?.token_usage ? { token_usage: parsed.token_usage } : {}
|
|
3999
|
+
});
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
return dedupeById(matches).sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
4003
|
+
}
|
|
4004
|
+
async function resolveHookReportedClaudeSession(sessionId, normalizedCwd) {
|
|
4005
|
+
for (let i = 0; i < 20; i++) {
|
|
4006
|
+
const direct = await findClaudeSessionInProjectById(sessionId, normalizedCwd);
|
|
4007
|
+
if (direct) return direct;
|
|
4008
|
+
await sleep(250);
|
|
4009
|
+
}
|
|
4010
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4011
|
+
return {
|
|
4012
|
+
session_id: sessionId,
|
|
4013
|
+
provider: "claude",
|
|
4014
|
+
model: "",
|
|
4015
|
+
project_path: normalizedCwd,
|
|
4016
|
+
first_prompt: "",
|
|
4017
|
+
file_path: join7(encodeClaudeProjectDirectory(normalizedCwd), `${sessionId}.jsonl`),
|
|
4018
|
+
created_at: now,
|
|
4019
|
+
modified_at: now
|
|
4020
|
+
};
|
|
4021
|
+
}
|
|
4022
|
+
async function findKnownSessionForRun(sessionId, provider, normalizedCwd, attempt) {
|
|
4023
|
+
if (provider === "claude" && normalizedCwd) {
|
|
4024
|
+
const direct = await findClaudeSessionInProjectById(sessionId, normalizedCwd);
|
|
4025
|
+
if (direct) return direct;
|
|
4026
|
+
}
|
|
4027
|
+
if (attempt % 8 !== 0) return null;
|
|
4028
|
+
return findSessionById(sessionId);
|
|
4029
|
+
}
|
|
4030
|
+
async function findClaudeSessionInProjectById(sessionId, normalizedCwd) {
|
|
4031
|
+
const filePath = join7(encodeClaudeProjectDirectory(normalizedCwd), `${sessionId}.jsonl`);
|
|
4032
|
+
let fileModifiedAt;
|
|
4033
|
+
try {
|
|
4034
|
+
const st = statSync4(filePath);
|
|
4035
|
+
if (!st.isFile()) return null;
|
|
4036
|
+
fileModifiedAt = st.mtimeMs;
|
|
4037
|
+
} catch {
|
|
4038
|
+
return null;
|
|
4039
|
+
}
|
|
4040
|
+
const modifiedAt = new Date(fileModifiedAt).toISOString();
|
|
4041
|
+
try {
|
|
4042
|
+
const parsedEntries = await parseJsonlHead(filePath);
|
|
4043
|
+
const parsedMeta = extractClaudeSessionMeta(parsedEntries, filePath, modifiedAt);
|
|
4044
|
+
if (parsedMeta) {
|
|
4045
|
+
return parsedMeta;
|
|
4046
|
+
}
|
|
4047
|
+
} catch {
|
|
4048
|
+
}
|
|
4049
|
+
return {
|
|
4050
|
+
session_id: sessionId,
|
|
4051
|
+
provider: "claude",
|
|
4052
|
+
model: "",
|
|
4053
|
+
project_path: normalizedCwd,
|
|
4054
|
+
first_prompt: "",
|
|
4055
|
+
file_path: filePath,
|
|
4056
|
+
created_at: modifiedAt,
|
|
4057
|
+
modified_at: modifiedAt
|
|
4058
|
+
};
|
|
4059
|
+
}
|
|
4060
|
+
async function tryResolveKnownSession(sessionId, provider, startedTime, beforeRun, cwd) {
|
|
4061
|
+
if (provider === "claude" && cwd) {
|
|
4062
|
+
const direct = await findClaudeSessionInProjectById(sessionId, normalizeProjectPath(cwd));
|
|
4063
|
+
if (direct && wasSessionTouchedAfterRun(direct, startedTime, beforeRun)) {
|
|
4064
|
+
return direct;
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
const candidate = await findSessionById(sessionId);
|
|
4068
|
+
if (!candidate || candidate.provider !== provider) {
|
|
4069
|
+
return null;
|
|
4070
|
+
}
|
|
4071
|
+
if (wasSessionTouchedAfterRun(candidate, startedTime, beforeRun)) {
|
|
4072
|
+
return candidate;
|
|
4073
|
+
}
|
|
4074
|
+
if (!beforeRun.has(sessionId) && candidate.modified_at && Date.parse(candidate.modified_at) >= startedTime) {
|
|
4075
|
+
return candidate;
|
|
4076
|
+
}
|
|
4077
|
+
if (!cwd) return null;
|
|
4078
|
+
const normalizedCwd = normalizeProjectPath(cwd);
|
|
4079
|
+
if (!candidate.project_path) return null;
|
|
4080
|
+
return normalizeProjectPath(candidate.project_path) === normalizedCwd ? candidate : null;
|
|
4081
|
+
}
|
|
4082
|
+
function encodeClaudeProjectDirectory(cwd) {
|
|
4083
|
+
const normalized = resolve2(cwd);
|
|
4084
|
+
const parts = normalized.split(/[\\/]/).filter(Boolean);
|
|
4085
|
+
return join7(CLAUDE_SESSIONS_DIR, `-${parts.join("-")}`);
|
|
4086
|
+
}
|
|
4087
|
+
function snapshotProjectSessions(projectDir) {
|
|
4088
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
4089
|
+
const absoluteProjectDir = encodeClaudeProjectDirectory(projectDir);
|
|
4090
|
+
const stack = [absoluteProjectDir];
|
|
4091
|
+
while (stack.length > 0) {
|
|
4092
|
+
const current = stack.pop();
|
|
4093
|
+
if (!current) continue;
|
|
4094
|
+
let entries;
|
|
4095
|
+
try {
|
|
4096
|
+
entries = readdirSync4(current);
|
|
4097
|
+
} catch {
|
|
4098
|
+
continue;
|
|
4099
|
+
}
|
|
4100
|
+
for (const entry of entries) {
|
|
4101
|
+
if (entry === "subagents") {
|
|
4102
|
+
continue;
|
|
4103
|
+
}
|
|
4104
|
+
const fullPath = join7(current, entry);
|
|
4105
|
+
let stat;
|
|
4106
|
+
try {
|
|
4107
|
+
stat = statSync4(fullPath);
|
|
4108
|
+
} catch {
|
|
4109
|
+
continue;
|
|
4110
|
+
}
|
|
4111
|
+
if (stat.isDirectory()) {
|
|
4112
|
+
stack.push(fullPath);
|
|
4113
|
+
continue;
|
|
4114
|
+
}
|
|
4115
|
+
if (entry.endsWith(".jsonl")) {
|
|
4116
|
+
snapshot.set(fullPath, stat.mtimeMs);
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
return snapshot;
|
|
4121
|
+
}
|
|
4122
|
+
async function detectSessionInCurrentClaudeProject(startedTime, beforeRun, normalizedCwd, beforeRunProjectFiles = /* @__PURE__ */ new Map()) {
|
|
4123
|
+
const currentProjectFiles = snapshotProjectSessions(normalizedCwd);
|
|
4124
|
+
if (currentProjectFiles.size === 0) return null;
|
|
4125
|
+
const candidates = [];
|
|
4126
|
+
for (const [filePath, fileModifiedAt] of currentProjectFiles) {
|
|
4127
|
+
if (fileModifiedAt < startedTime) continue;
|
|
4128
|
+
const beforeProjectModifiedAt = beforeRunProjectFiles.get(filePath);
|
|
4129
|
+
if (beforeProjectModifiedAt !== void 0 && fileModifiedAt <= beforeProjectModifiedAt) continue;
|
|
4130
|
+
const modifiedAt = new Date(fileModifiedAt).toISOString();
|
|
4131
|
+
let parsed = null;
|
|
4132
|
+
try {
|
|
4133
|
+
const parsedEntries = await parseJsonlHead(filePath);
|
|
4134
|
+
const parsedMeta = extractClaudeSessionMeta(parsedEntries, filePath, modifiedAt);
|
|
4135
|
+
parsed = parsedMeta ?? null;
|
|
4136
|
+
} catch {
|
|
4137
|
+
parsed = null;
|
|
4138
|
+
}
|
|
4139
|
+
const sessionId = parsed?.session_id || basename2(filePath, ".jsonl");
|
|
4140
|
+
const candidate = {
|
|
4141
|
+
session_id: sessionId,
|
|
4142
|
+
provider: "claude",
|
|
4143
|
+
model: parsed?.model || "",
|
|
4144
|
+
project_path: parsed?.project_path || normalizedCwd,
|
|
4145
|
+
first_prompt: parsed?.first_prompt || "",
|
|
4146
|
+
file_path: filePath,
|
|
4147
|
+
created_at: parsed?.created_at || modifiedAt,
|
|
4148
|
+
modified_at: modifiedAt,
|
|
4149
|
+
...parsed?.token_usage ? { token_usage: parsed.token_usage } : {}
|
|
4150
|
+
};
|
|
4151
|
+
if (normalizeProjectPath(candidate.project_path) !== normalizedCwd) continue;
|
|
4152
|
+
if (!wasSessionTouchedAfterRun(candidate, startedTime, beforeRun)) continue;
|
|
4153
|
+
candidates.push(candidate);
|
|
4154
|
+
}
|
|
4155
|
+
if (candidates.length === 0) {
|
|
4156
|
+
const directCandidates = [];
|
|
4157
|
+
for (const [filePath, beforeModifiedAt] of beforeRunProjectFiles) {
|
|
4158
|
+
const after = currentProjectFiles.get(filePath);
|
|
4159
|
+
if (after === void 0) continue;
|
|
4160
|
+
if (!Number.isFinite(after) || after < startedTime || after <= beforeModifiedAt) continue;
|
|
4161
|
+
const sessionId = basename2(filePath, ".jsonl");
|
|
4162
|
+
directCandidates.push({
|
|
4163
|
+
session_id: sessionId,
|
|
4164
|
+
provider: "claude",
|
|
4165
|
+
model: "",
|
|
4166
|
+
project_path: normalizedCwd,
|
|
4167
|
+
first_prompt: "",
|
|
4168
|
+
file_path: filePath,
|
|
4169
|
+
created_at: new Date(after).toISOString(),
|
|
4170
|
+
modified_at: new Date(after).toISOString()
|
|
4171
|
+
});
|
|
4172
|
+
}
|
|
4173
|
+
if (directCandidates.length > 0) {
|
|
4174
|
+
directCandidates.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
4175
|
+
return directCandidates[0];
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
if (candidates.length === 0) return null;
|
|
4179
|
+
const deduped = dedupeById(candidates);
|
|
4180
|
+
if (deduped.length === 1) return deduped[0];
|
|
4181
|
+
deduped.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
4182
|
+
return deduped[0];
|
|
4183
|
+
}
|
|
4184
|
+
function pickBestMatch(sessions, startedTime, beforeRun, cwd) {
|
|
4185
|
+
const matches = sessions.filter(
|
|
4186
|
+
(session) => wasSessionTouchedAfterRun(session, startedTime, beforeRun)
|
|
4187
|
+
);
|
|
4188
|
+
if (matches.length === 0) return matches[0];
|
|
4189
|
+
const deduped = dedupeById(matches);
|
|
4190
|
+
if (!cwd) {
|
|
4191
|
+
deduped.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
4192
|
+
return deduped[0];
|
|
4193
|
+
}
|
|
4194
|
+
const cwdNormalized = resolve2(cwd);
|
|
4195
|
+
const exact = deduped.filter((session) => normalizeProjectPath(session.project_path) === cwdNormalized);
|
|
4196
|
+
if (exact.length > 0) {
|
|
4197
|
+
exact.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
4198
|
+
return exact[0];
|
|
4199
|
+
}
|
|
4200
|
+
deduped.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
|
|
4201
|
+
return deduped[0];
|
|
4202
|
+
}
|
|
4203
|
+
function normalizeProjectPath(value) {
|
|
4204
|
+
if (!value) return "";
|
|
4205
|
+
try {
|
|
4206
|
+
return resolve2(value);
|
|
4207
|
+
} catch {
|
|
4208
|
+
return value;
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
function dedupeById(sessions) {
|
|
4212
|
+
const latest = /* @__PURE__ */ new Map();
|
|
4213
|
+
for (const session of sessions) {
|
|
4214
|
+
const current = latest.get(session.session_id);
|
|
4215
|
+
if (!current || session.modified_at > current.modified_at) {
|
|
4216
|
+
latest.set(session.session_id, session);
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
return [...latest.values()];
|
|
4220
|
+
}
|
|
4221
|
+
function sleep(ms) {
|
|
4222
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
4223
|
+
}
|
|
4224
|
+
async function pinSessionToCatalog(session, opts, space) {
|
|
4225
|
+
const existing = findBookmark(session.session_id);
|
|
4226
|
+
if (existing) {
|
|
4227
|
+
if (!existing.space_ids.includes(space.id)) {
|
|
4228
|
+
existing.space_ids.push(space.id);
|
|
4229
|
+
updateBookmark(existing.id, { space_ids: existing.space_ids });
|
|
4230
|
+
console.log(chalk6.green(`Added ${existing.id} to catalog "${space.name}" (${space.id})`));
|
|
4231
|
+
} else {
|
|
4232
|
+
console.log(chalk6.yellow(`Session already in catalog "${space.name}".`));
|
|
4233
|
+
}
|
|
4234
|
+
return;
|
|
4235
|
+
}
|
|
4236
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4237
|
+
const bookmarkId = generateBookmarkId(listBookmarks());
|
|
4238
|
+
const title = opts.title || session.first_prompt.slice(0, 60) || session.session_id.slice(0, 16);
|
|
4239
|
+
const tagList = opts.tags ? opts.tags.split(",").map((tag) => tag.trim()).filter(Boolean) : [];
|
|
4240
|
+
addBookmark({
|
|
4241
|
+
id: bookmarkId,
|
|
4242
|
+
provider: session.provider,
|
|
4243
|
+
session_id: session.session_id,
|
|
4244
|
+
title,
|
|
4245
|
+
category: "",
|
|
4246
|
+
tags: tagList,
|
|
4247
|
+
project_path: session.project_path ?? "",
|
|
4248
|
+
first_prompt: session.first_prompt ?? "",
|
|
4249
|
+
notes: [],
|
|
4250
|
+
space_ids: [space.id],
|
|
4251
|
+
created_at: now,
|
|
4252
|
+
updated_at: now
|
|
4253
|
+
});
|
|
4254
|
+
console.log(chalk6.green(`Pinned: ${bookmarkId}`));
|
|
4255
|
+
console.log(` Title: ${title}`);
|
|
4256
|
+
console.log(` Catalog: ${space.name} (${space.id})`);
|
|
4257
|
+
}
|
|
4258
|
+
function normalizeAgent(input) {
|
|
4259
|
+
if (input === "claude") return "claude";
|
|
4260
|
+
if (input === "codex" || input === "agent") return "codex";
|
|
4261
|
+
return null;
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
// src/commands/model.ts
|
|
4265
|
+
import { Command as Command6 } from "commander";
|
|
4266
|
+
import chalk7 from "chalk";
|
|
4267
|
+
import Table4 from "cli-table3";
|
|
4268
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync5 } from "fs";
|
|
4269
|
+
import { basename as basename3, extname as extname3, join as join8 } from "path";
|
|
4270
|
+
import { homedir as homedir2 } from "os";
|
|
4271
|
+
var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([".json", ".jsonc", ".toml"]);
|
|
4272
|
+
function registerModelCommand(program2) {
|
|
4273
|
+
const model = new Command6("model").description("Inspect model configurations");
|
|
4274
|
+
model.command("list").alias("ls").description("List current and Starling-managed model configurations").option("-a, --agent <agent>", "filter by agent: claude | codex | all", "all").option("--json", "output JSON").action((opts) => {
|
|
4275
|
+
const agent = normalizeAgent2(opts.agent);
|
|
4276
|
+
if (!agent) {
|
|
4277
|
+
console.error(chalk7.red(`Unknown agent: ${opts.agent}`));
|
|
4278
|
+
process.exit(1);
|
|
4279
|
+
}
|
|
4280
|
+
const rows = collectModelConfigs(agent);
|
|
4281
|
+
if (opts.json) {
|
|
4282
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
4283
|
+
return;
|
|
4284
|
+
}
|
|
4285
|
+
printModelTable(rows);
|
|
4286
|
+
});
|
|
4287
|
+
model.command("add <name>").description("Add a Starling model profile").requiredOption("-a, --agent <agent>", "agent: claude | codex").requiredOption("--model <model>", "model name").option("--base-url <url>", "provider base URL").option("--api-key <key>", "API key/token").option("--provider <provider>", "provider name", "custom").option("--reasoning <effort>", "Codex reasoning effort").option("--wire-api <api>", "Codex wire_api: responses | chat", "responses").option("--force", "overwrite existing profile").option("--json", "output JSON").action((name, opts) => {
|
|
4288
|
+
const agent = normalizeAgent2(opts.agent);
|
|
4289
|
+
if (!agent || agent === "all") {
|
|
4290
|
+
console.error(chalk7.red(`Unknown agent: ${opts.agent}`));
|
|
4291
|
+
console.error(chalk7.gray("Allowed values: claude, codex"));
|
|
4292
|
+
process.exit(1);
|
|
4293
|
+
}
|
|
4294
|
+
const result = addModelProfile(name, agent, opts);
|
|
4295
|
+
if (opts.json) {
|
|
4296
|
+
console.log(JSON.stringify(result, null, 2));
|
|
4297
|
+
return;
|
|
4298
|
+
}
|
|
4299
|
+
console.log(chalk7.green(`Added ${agent} model profile: ${result.name}`));
|
|
4300
|
+
console.log(chalk7.gray(` Source: ${result.source}`));
|
|
4301
|
+
});
|
|
4302
|
+
program2.addCommand(model);
|
|
4303
|
+
}
|
|
4304
|
+
function addModelProfile(name, agent, opts) {
|
|
4305
|
+
const profileName = normalizeProfileName(name);
|
|
4306
|
+
const source = join8(agent === "claude" ? DEFAULT_CLAUDE_SETTINGS_DIR : DEFAULT_CODEX_SETTINGS_DIR, `${profileName}.json`);
|
|
4307
|
+
if (existsSync7(source) && !opts.force) {
|
|
4308
|
+
console.error(chalk7.red(`Model profile already exists: ${profileName}`));
|
|
4309
|
+
console.error(chalk7.gray(` Source: ${source}`));
|
|
4310
|
+
console.error(chalk7.gray("Use --force to overwrite it."));
|
|
4311
|
+
process.exit(1);
|
|
4312
|
+
}
|
|
4313
|
+
const model = opts.model.trim();
|
|
4314
|
+
if (!model) {
|
|
4315
|
+
console.error(chalk7.red("Model name cannot be empty."));
|
|
4316
|
+
process.exit(1);
|
|
4317
|
+
}
|
|
4318
|
+
const payload = agent === "claude" ? buildClaudeProfile(opts, model) : buildCodexProfile(opts, model);
|
|
4319
|
+
atomicWriteJSON(source, payload);
|
|
4320
|
+
return { agent, name: profileName, source, model };
|
|
4321
|
+
}
|
|
4322
|
+
function buildClaudeProfile(opts, model) {
|
|
4323
|
+
const env = {
|
|
4324
|
+
ANTHROPIC_AUTH_TOKEN: opts.apiKey?.trim() || "",
|
|
4325
|
+
ANTHROPIC_BASE_URL: opts.baseUrl?.trim() || "",
|
|
4326
|
+
API_TIMEOUT_MS: "3000000",
|
|
4327
|
+
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1",
|
|
4328
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
4329
|
+
ANTHROPIC_MODEL: model,
|
|
4330
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
|
|
4331
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
4332
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: model
|
|
4333
|
+
};
|
|
4334
|
+
return {
|
|
4335
|
+
env,
|
|
4336
|
+
enableAllProjectMcpServers: true,
|
|
4337
|
+
permissions: {
|
|
4338
|
+
allow: [
|
|
4339
|
+
"Edit:*",
|
|
4340
|
+
"Write:*",
|
|
4341
|
+
"MultiEdit:*",
|
|
4342
|
+
"NotebookEdit:*",
|
|
4343
|
+
"Bash:*"
|
|
4344
|
+
],
|
|
4345
|
+
defaultMode: "plan"
|
|
4346
|
+
}
|
|
4347
|
+
};
|
|
4348
|
+
}
|
|
4349
|
+
function buildCodexProfile(opts, model) {
|
|
4350
|
+
const provider = opts.provider?.trim() || "custom";
|
|
4351
|
+
const providerConfig = {
|
|
4352
|
+
name: provider,
|
|
4353
|
+
base_url: opts.baseUrl?.trim() || "",
|
|
4354
|
+
wire_api: opts.wireApi?.trim() || "responses",
|
|
4355
|
+
requires_openai_auth: true
|
|
4356
|
+
};
|
|
4357
|
+
const config = {
|
|
4358
|
+
model_provider: provider,
|
|
4359
|
+
model,
|
|
4360
|
+
model_reasoning_effort: opts.reasoning?.trim() || "",
|
|
4361
|
+
disable_response_storage: true,
|
|
4362
|
+
model_providers: {
|
|
4363
|
+
[provider]: providerConfig
|
|
4364
|
+
}
|
|
4365
|
+
};
|
|
4366
|
+
return {
|
|
4367
|
+
auth: {
|
|
4368
|
+
OPENAI_API_KEY: opts.apiKey?.trim() || ""
|
|
4369
|
+
},
|
|
4370
|
+
config
|
|
4371
|
+
};
|
|
4372
|
+
}
|
|
4373
|
+
function normalizeProfileName(name) {
|
|
4374
|
+
const normalized = basename3(name).replace(/\.(jsonc?|toml)$/i, "").trim();
|
|
4375
|
+
if (!normalized || normalized === "." || normalized === "..") {
|
|
4376
|
+
console.error(chalk7.red(`Invalid model profile name: ${name}`));
|
|
4377
|
+
process.exit(1);
|
|
4378
|
+
}
|
|
4379
|
+
if (!/^[A-Za-z0-9._-]+$/.test(normalized)) {
|
|
4380
|
+
console.error(chalk7.red("Model profile name may only contain letters, numbers, dot, dash, and underscore."));
|
|
4381
|
+
process.exit(1);
|
|
4382
|
+
}
|
|
4383
|
+
return normalized;
|
|
4384
|
+
}
|
|
4385
|
+
function normalizeAgent2(value) {
|
|
4386
|
+
const normalized = (value || "all").trim().toLowerCase();
|
|
4387
|
+
if (normalized === "all") return "all";
|
|
4388
|
+
if (normalized === "claude") return "claude";
|
|
4389
|
+
if (normalized === "codex" || normalized === "code") return "codex";
|
|
4390
|
+
return null;
|
|
4391
|
+
}
|
|
4392
|
+
function collectModelConfigs(agent) {
|
|
4393
|
+
const rows = [];
|
|
4394
|
+
if (agent === "all" || agent === "claude") {
|
|
4395
|
+
rows.push(...collectClaudeConfigs());
|
|
4396
|
+
}
|
|
4397
|
+
if (agent === "all" || agent === "codex") {
|
|
4398
|
+
rows.push(...collectCodexConfigs());
|
|
4399
|
+
}
|
|
4400
|
+
return rows;
|
|
4401
|
+
}
|
|
4402
|
+
function collectClaudeConfigs() {
|
|
4403
|
+
const currentPath = join8(homedir2(), ".claude", "settings.json");
|
|
4404
|
+
return [
|
|
4405
|
+
summarizeClaudeJson(currentPath, "current", "current"),
|
|
4406
|
+
...listProfileFiles(DEFAULT_CLAUDE_SETTINGS_DIR).map(
|
|
4407
|
+
(filePath) => summarizeClaudeProfile(filePath, basename3(filePath, extname3(filePath)))
|
|
4408
|
+
)
|
|
4409
|
+
];
|
|
4410
|
+
}
|
|
4411
|
+
function collectCodexConfigs() {
|
|
4412
|
+
const currentPath = join8(DEFAULT_CODEX_HOME, "config.toml");
|
|
4413
|
+
return [
|
|
4414
|
+
summarizeCodexToml(currentPath, "current", "current", readCodexAuthState()),
|
|
4415
|
+
...listProfileFiles(DEFAULT_CODEX_SETTINGS_DIR).map(
|
|
4416
|
+
(filePath) => summarizeCodexProfile(filePath, basename3(filePath, extname3(filePath)))
|
|
4417
|
+
)
|
|
4418
|
+
];
|
|
4419
|
+
}
|
|
4420
|
+
function listProfileFiles(dir) {
|
|
4421
|
+
if (!existsSync7(dir)) return [];
|
|
4422
|
+
return readdirSync5(dir, { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => join8(dir, entry.name)).filter((filePath) => SUPPORTED_EXTENSIONS.has(extname3(filePath).toLowerCase())).sort((a, b) => a.localeCompare(b));
|
|
4423
|
+
}
|
|
4424
|
+
function summarizeClaudeProfile(filePath, name) {
|
|
4425
|
+
const extension = extname3(filePath).toLowerCase();
|
|
4426
|
+
if (extension !== ".json" && extension !== ".jsonc") {
|
|
4427
|
+
return {
|
|
4428
|
+
agent: "claude",
|
|
4429
|
+
scope: "profile",
|
|
4430
|
+
name,
|
|
4431
|
+
source: filePath,
|
|
4432
|
+
exists: true,
|
|
4433
|
+
error: `Unsupported Claude profile format: ${extension}`
|
|
4434
|
+
};
|
|
4435
|
+
}
|
|
4436
|
+
return summarizeClaudeJson(filePath, name, "profile");
|
|
4437
|
+
}
|
|
4438
|
+
function summarizeClaudeJson(filePath, name, scope) {
|
|
4439
|
+
const base = {
|
|
4440
|
+
agent: "claude",
|
|
4441
|
+
scope,
|
|
4442
|
+
name,
|
|
4443
|
+
source: filePath,
|
|
4444
|
+
exists: existsSync7(filePath)
|
|
4445
|
+
};
|
|
4446
|
+
if (!base.exists) return base;
|
|
4447
|
+
try {
|
|
4448
|
+
const parsed = parseJsonFile(filePath);
|
|
4449
|
+
const env = isRecord5(parsed.env) ? parsed.env : parsed;
|
|
4450
|
+
const model = stringValue3(env.ANTHROPIC_MODEL) || stringValue3(env.CLAUDE_MODEL) || stringValue3(env.ANTHROPIC_DEFAULT_SONNET_MODEL) || stringValue3(parsed.model);
|
|
4451
|
+
const provider = inferProviderName(stringValue3(env.ANTHROPIC_BASE_URL) || stringValue3(env.CLAUDE_BASE_URL));
|
|
4452
|
+
return {
|
|
4453
|
+
...base,
|
|
4454
|
+
model,
|
|
4455
|
+
provider,
|
|
4456
|
+
baseUrl: stringValue3(env.ANTHROPIC_BASE_URL) || stringValue3(env.CLAUDE_BASE_URL),
|
|
4457
|
+
reasoning: stringValue3(env.ANTHROPIC_REASONING_EFFORT) || stringValue3(parsed.reasoning),
|
|
4458
|
+
auth: describeAuth(env, ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "CLAUDE_API_KEY"])
|
|
4459
|
+
};
|
|
4460
|
+
} catch (error) {
|
|
4461
|
+
return { ...base, error: formatError(error) };
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
function summarizeCodexProfile(filePath, name) {
|
|
4465
|
+
const extension = extname3(filePath).toLowerCase();
|
|
4466
|
+
if (extension === ".toml") {
|
|
4467
|
+
return summarizeCodexToml(filePath, name, "profile", "profile");
|
|
4468
|
+
}
|
|
4469
|
+
if (extension !== ".json" && extension !== ".jsonc") {
|
|
4470
|
+
return {
|
|
4471
|
+
agent: "codex",
|
|
4472
|
+
scope: "profile",
|
|
4473
|
+
name,
|
|
4474
|
+
source: filePath,
|
|
4475
|
+
exists: true,
|
|
4476
|
+
error: `Unsupported Codex profile format: ${extension}`
|
|
4477
|
+
};
|
|
4478
|
+
}
|
|
4479
|
+
const base = {
|
|
4480
|
+
agent: "codex",
|
|
4481
|
+
scope: "profile",
|
|
4482
|
+
name,
|
|
4483
|
+
source: filePath,
|
|
4484
|
+
exists: true
|
|
4485
|
+
};
|
|
4486
|
+
try {
|
|
4487
|
+
const parsed = parseJsonFile(filePath);
|
|
4488
|
+
const config = isRecord5(parsed.config) ? parsed.config : parsed;
|
|
4489
|
+
const auth = isRecord5(parsed.auth) ? describeAuth(parsed.auth, ["OPENAI_API_KEY", "api_key", "apiKey"]) : "none";
|
|
4490
|
+
return summarizeCodexConfigObject(base, config, auth);
|
|
4491
|
+
} catch (error) {
|
|
4492
|
+
return { ...base, error: formatError(error) };
|
|
4493
|
+
}
|
|
4494
|
+
}
|
|
4495
|
+
function summarizeCodexToml(filePath, name, scope, auth) {
|
|
4496
|
+
const base = {
|
|
4497
|
+
agent: "codex",
|
|
4498
|
+
scope,
|
|
4499
|
+
name,
|
|
4500
|
+
source: filePath,
|
|
4501
|
+
exists: existsSync7(filePath),
|
|
4502
|
+
auth
|
|
4503
|
+
};
|
|
4504
|
+
if (!base.exists) return base;
|
|
4505
|
+
try {
|
|
4506
|
+
const raw = readFileSync6(filePath, "utf-8");
|
|
4507
|
+
const provider = parseTomlValue(raw, "model_provider");
|
|
4508
|
+
const providerSection = provider ? parseTomlSection(raw, `model_providers.${provider}`) : {};
|
|
4509
|
+
return {
|
|
4510
|
+
...base,
|
|
4511
|
+
model: parseTomlValue(raw, "model"),
|
|
4512
|
+
provider: stringValue3(providerSection.name) || provider,
|
|
4513
|
+
baseUrl: stringValue3(providerSection.base_url),
|
|
4514
|
+
reasoning: parseTomlValue(raw, "model_reasoning_effort"),
|
|
4515
|
+
wireApi: stringValue3(providerSection.wire_api)
|
|
4516
|
+
};
|
|
4517
|
+
} catch (error) {
|
|
4518
|
+
return { ...base, error: formatError(error) };
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
function summarizeCodexConfigObject(base, config, auth) {
|
|
4522
|
+
const providerKey = stringValue3(config.model_provider);
|
|
4523
|
+
const providers = isRecord5(config.model_providers) ? config.model_providers : {};
|
|
4524
|
+
const providerConfig = providerKey && isRecord5(providers[providerKey]) ? providers[providerKey] : {};
|
|
4525
|
+
const providerRecord = isRecord5(providerConfig) ? providerConfig : {};
|
|
4526
|
+
return {
|
|
4527
|
+
...base,
|
|
4528
|
+
model: stringValue3(config.model),
|
|
4529
|
+
provider: stringValue3(providerRecord.name) || providerKey,
|
|
4530
|
+
baseUrl: stringValue3(providerRecord.base_url),
|
|
4531
|
+
reasoning: stringValue3(config.model_reasoning_effort),
|
|
4532
|
+
wireApi: stringValue3(providerRecord.wire_api),
|
|
4533
|
+
auth
|
|
4534
|
+
};
|
|
4535
|
+
}
|
|
4536
|
+
function readCodexAuthState() {
|
|
4537
|
+
const authPath = join8(DEFAULT_CODEX_HOME, "auth.json");
|
|
4538
|
+
if (!existsSync7(authPath)) return "none";
|
|
4539
|
+
try {
|
|
4540
|
+
const parsed = parseJsonFile(authPath);
|
|
4541
|
+
if (hasAnySecret(parsed, ["OPENAI_API_KEY", "api_key", "apiKey", "access_token", "refresh_token"])) {
|
|
4542
|
+
return "stored";
|
|
4543
|
+
}
|
|
4544
|
+
return Object.keys(parsed).length > 0 ? "stored" : "none";
|
|
4545
|
+
} catch {
|
|
4546
|
+
return "unreadable";
|
|
4547
|
+
}
|
|
4548
|
+
}
|
|
4549
|
+
function printModelTable(rows) {
|
|
4550
|
+
if (rows.length === 0) {
|
|
4551
|
+
console.log(chalk7.yellow("No model configurations found."));
|
|
4552
|
+
return;
|
|
4553
|
+
}
|
|
4554
|
+
const claudeRows = rows.filter((row) => row.agent === "claude");
|
|
4555
|
+
const codexRows = rows.filter((row) => row.agent === "codex");
|
|
4556
|
+
if (claudeRows.length > 0) {
|
|
4557
|
+
console.log(chalk7.bold("Claude"));
|
|
4558
|
+
console.log(formatModelTable(claudeRows));
|
|
4559
|
+
}
|
|
4560
|
+
if (codexRows.length > 0) {
|
|
4561
|
+
if (claudeRows.length > 0) console.log("");
|
|
4562
|
+
console.log(chalk7.bold("Codex"));
|
|
4563
|
+
console.log(formatModelTable(codexRows));
|
|
4564
|
+
}
|
|
4565
|
+
}
|
|
4566
|
+
function formatModelTable(rows) {
|
|
4567
|
+
const table = new Table4({
|
|
4568
|
+
head: [
|
|
4569
|
+
chalk7.green("Name"),
|
|
4570
|
+
chalk7.green("Model"),
|
|
4571
|
+
chalk7.green("Auth"),
|
|
4572
|
+
chalk7.green("Source")
|
|
4573
|
+
],
|
|
4574
|
+
colWidths: [12, 28, 12, 76],
|
|
4575
|
+
wordWrap: true,
|
|
4576
|
+
style: { head: [] }
|
|
4577
|
+
});
|
|
4578
|
+
for (const row of rows) {
|
|
4579
|
+
const source = row.exists ? row.source : chalk7.gray(`${row.source} (missing)`);
|
|
4580
|
+
const model = row.error ? chalk7.red("error") : row.model || "-";
|
|
4581
|
+
const auth = row.error ? truncate(row.error, 10) : row.auth || "-";
|
|
4582
|
+
table.push([
|
|
4583
|
+
row.scope === "current" && row.name === "current" ? "default" : row.name,
|
|
4584
|
+
model,
|
|
4585
|
+
auth,
|
|
4586
|
+
source
|
|
4587
|
+
]);
|
|
4588
|
+
}
|
|
4589
|
+
return table.toString();
|
|
4590
|
+
}
|
|
4591
|
+
function parseJsonFile(filePath) {
|
|
4592
|
+
const raw = readFileSync6(filePath, "utf-8");
|
|
4593
|
+
const parsed = JSON.parse(stripJsonComments2(raw));
|
|
4594
|
+
if (!isRecord5(parsed)) {
|
|
4595
|
+
throw new Error("JSON root is not an object");
|
|
4596
|
+
}
|
|
4597
|
+
return parsed;
|
|
4598
|
+
}
|
|
4599
|
+
function stripJsonComments2(raw) {
|
|
4600
|
+
return raw.replace(/^\s*\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
4601
|
+
}
|
|
4602
|
+
function parseTomlValue(raw, key) {
|
|
4603
|
+
const pattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*(.+?)\\s*(?:#.*)?$`, "m");
|
|
4604
|
+
const match = raw.match(pattern);
|
|
4605
|
+
if (!match) return "";
|
|
4606
|
+
return unquoteTomlValue(match[1].trim());
|
|
4607
|
+
}
|
|
4608
|
+
function parseTomlSection(raw, section) {
|
|
4609
|
+
const result = {};
|
|
4610
|
+
const lines = raw.split(/\r?\n/);
|
|
4611
|
+
let inSection = false;
|
|
4612
|
+
for (const line of lines) {
|
|
4613
|
+
const sectionMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
4614
|
+
if (sectionMatch) {
|
|
4615
|
+
inSection = sectionMatch[1] === section;
|
|
4616
|
+
continue;
|
|
4617
|
+
}
|
|
4618
|
+
if (!inSection) continue;
|
|
4619
|
+
const kv = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*(.+?)\s*(?:#.*)?$/);
|
|
4620
|
+
if (kv) result[kv[1]] = unquoteTomlValue(kv[2].trim());
|
|
4621
|
+
}
|
|
4622
|
+
return result;
|
|
4623
|
+
}
|
|
4624
|
+
function unquoteTomlValue(value) {
|
|
4625
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
4626
|
+
return value.slice(1, -1);
|
|
4627
|
+
}
|
|
4628
|
+
return value;
|
|
4629
|
+
}
|
|
4630
|
+
function describeAuth(source, keys) {
|
|
4631
|
+
return hasAnySecret(source, keys) ? "configured" : "none";
|
|
4632
|
+
}
|
|
4633
|
+
function hasAnySecret(source, keys) {
|
|
4634
|
+
return keys.some((key) => typeof source[key] === "string" && source[key].trim().length > 0);
|
|
4635
|
+
}
|
|
4636
|
+
function inferProviderName(baseUrl) {
|
|
4637
|
+
if (!baseUrl) return "";
|
|
4638
|
+
try {
|
|
4639
|
+
const host = new URL(baseUrl).hostname.replace(/^api\./, "");
|
|
4640
|
+
return host.split(".")[0] || "";
|
|
4641
|
+
} catch {
|
|
4642
|
+
return "";
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
function truncate(value, max) {
|
|
4646
|
+
return value.length > max ? `${value.slice(0, max - 1)}\u2026` : value;
|
|
4647
|
+
}
|
|
4648
|
+
function formatError(error) {
|
|
4649
|
+
return error instanceof Error ? error.message : String(error);
|
|
4650
|
+
}
|
|
4651
|
+
function stringValue3(value) {
|
|
4652
|
+
return typeof value === "string" ? value : "";
|
|
4653
|
+
}
|
|
4654
|
+
function isRecord5(value) {
|
|
4655
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4656
|
+
}
|
|
4657
|
+
function escapeRegex(value) {
|
|
4658
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
// src/index.ts
|
|
4662
|
+
var program = new Command7();
|
|
4663
|
+
program.enablePositionalOptions();
|
|
4664
|
+
program.name("starling").description("Agent session manager \u2014 discover, pin, and organize AI coding sessions").version("0.1.0");
|
|
4665
|
+
registerSessionCommand(program);
|
|
4666
|
+
registerPinCommand(program);
|
|
4667
|
+
registerSpaceCommand(program);
|
|
4668
|
+
registerProjectCommand(program);
|
|
4669
|
+
registerRunCommand(program);
|
|
4670
|
+
registerModelCommand(program);
|
|
4671
|
+
program.command("resume <session-id>").description("Resume an agent session directly").action(async (sessionId) => {
|
|
4672
|
+
await resumeSession(sessionId);
|
|
4673
|
+
});
|
|
4674
|
+
program.parse();
|