oakode 0.0.0-stage → 0.5.0

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +9 -0
  3. package/README.md +243 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +570 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +102 -0
  10. package/dist/agents/host.js +289 -0
  11. package/dist/agents/orchestrator.js +286 -0
  12. package/dist/agents/plan.js +143 -0
  13. package/dist/agents/types.js +20 -0
  14. package/dist/agents/verify.js +94 -0
  15. package/dist/app.js +619 -0
  16. package/dist/auth.js +456 -0
  17. package/dist/brief.js +97 -0
  18. package/dist/commands.js +587 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +82 -0
  21. package/dist/diff.js +98 -0
  22. package/dist/doctor.js +106 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/fileio.js +688 -0
  25. package/dist/input.js +244 -0
  26. package/dist/jobs.js +262 -0
  27. package/dist/main.js +560 -0
  28. package/dist/mask.js +63 -0
  29. package/dist/mcp.js +721 -0
  30. package/dist/models.js +106 -0
  31. package/dist/modes.js +114 -0
  32. package/dist/oicki.js +475 -0
  33. package/dist/output.js +101 -0
  34. package/dist/permissions.js +389 -0
  35. package/dist/permissions_ui.js +134 -0
  36. package/dist/print.js +76 -0
  37. package/dist/proc.js +63 -0
  38. package/dist/runners.js +264 -0
  39. package/dist/serial.js +9 -0
  40. package/dist/session.js +452 -0
  41. package/dist/shell.js +40 -0
  42. package/dist/skills.js +322 -0
  43. package/dist/syntax.js +80 -0
  44. package/dist/tools.js +480 -0
  45. package/dist/tui/editor.js +279 -0
  46. package/dist/tui/highlight.js +78 -0
  47. package/dist/tui/markdown.js +254 -0
  48. package/dist/tui/screen.js +146 -0
  49. package/dist/tui/tui.js +505 -0
  50. package/dist/tui/view.js +337 -0
  51. package/dist/ui.js +105 -0
  52. package/dist/update.js +64 -0
  53. package/package.json +57 -4
package/dist/main.js ADDED
@@ -0,0 +1,560 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { realpathSync, rmSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { Agent } from "./agent.js";
6
+ import { App } from "./app.js";
7
+ import { Auth, clientIdSetting } from "./auth.js";
8
+ import { billingLines } from "./account.js";
9
+ import { CreditRate } from "./agents/budget.js";
10
+ import { SubagentHost } from "./agents/host.js";
11
+ import { subagentSettings } from "./agents/types.js";
12
+ import { doctor } from "./doctor.js";
13
+ import { refreshUpdateCache, updateCheckEnabled, updateNotice } from "./update.js";
14
+ import { VERSION, baseUrl, configProblem, home, loadConfig, readJson, saveConfig, writeJson } from "./config.js";
15
+ import { join } from "node:path";
16
+ import { explain } from "./errors.js";
17
+ import { allowedModel, compatibilityNotice, curatedModels, defaultModel, describeModel, experimentalHint, experimentalModels, ignoredDefaultNotice, matchModels, notSupported, selectableModels } from "./models.js";
18
+ import { makePrintEmitter, readAll } from "./print.js";
19
+ import { OickiClient, OickiError } from "./oicki.js";
20
+ import { Session } from "./session.js";
21
+ import { killAllChildren } from "./tools.js";
22
+ import { removeOutput, sweepOldOutput } from "./jobs.js";
23
+ import { Tui } from "./tui/tui.js";
24
+ import { ProjectTrust } from "./permissions.js";
25
+ import { Mcp } from "./mcp.js";
26
+ import { Skills } from "./skills.js";
27
+ import { ago, bold, dim, gray, green, red, yellow } from "./ui.js";
28
+ const HELP = `oakode ${VERSION} - coding agent powered by OICKI
29
+
30
+ Usage:
31
+ oakode [options] [prompt] interactive session (or one-shot with -p)
32
+ oakode login sign in with your OICKI account in the browser
33
+ --no-browser print the sign-in link instead of opening it (remote machines)
34
+ --expensive also allow expensive models
35
+ --api-key use an OICKI API key instead (hidden input, verified with OICKI)
36
+ oakode logout remove the credentials stored on this computer
37
+ oakode status account, credits and which credentials are in use
38
+ oakode doctor check the installation, credentials and connection
39
+ oakode models [all|text] supported models, or search them (the whole catalog with experimentalModels)
40
+ oakode sessions your previous sessions in this directory
41
+
42
+ Options:
43
+ -p, --print run the prompt non-interactively and exit
44
+ -m, --model ID a supported model (default: auto; others need "experimentalModels": true in config.json)
45
+ -c, --continue resume the latest session in this directory
46
+ -r, --resume ID resume a session by id (see: oakode sessions)
47
+ -y, --yes NO APPROVAL PROMPTS: writes, edits and shell commands run unattended
48
+ -h, --help -v, --version
49
+
50
+ Inside a session type / for commands (/help lists them). Enter sends; Ctrl+J, Alt+Enter or Shift+Enter (kitty-style
51
+ terminals) insert a newline. Pasted text is never sent by itself. Typing while the agent works queues a message.
52
+ Esc or Ctrl+C interrupts a running turn. Every write, edit and shell command asks first. Approval is not a sandbox.
53
+ Environment: OICKI_API_KEY, OAKODE_HOME, OAKODE_BASE_URL, OAKODE_CLIENT_ID, OAKODE_REDIRECT_PORT, OAKODE_MAX_OUTPUT_TOKENS,
54
+ OAKODE_REASONING_EFFORT, OAKODE_NO_UPDATE_CHECK. Docs: README.md · problems: oakode doctor`;
55
+ // a mistyped flag gets one line and a pointer to --help, not a stack trace
56
+ const { values: opt, positionals } = (() => {
57
+ try {
58
+ return parseArgs({
59
+ allowPositionals: true,
60
+ options: {
61
+ print: { type: "boolean", short: "p" },
62
+ model: { type: "string", short: "m" },
63
+ continue: { type: "boolean", short: "c" },
64
+ resume: { type: "string", short: "r" },
65
+ yes: { type: "boolean", short: "y" },
66
+ help: { type: "boolean", short: "h" },
67
+ version: { type: "boolean", short: "v" },
68
+ "client-id": { type: "string" },
69
+ expensive: { type: "boolean" },
70
+ "api-key": { type: "boolean" },
71
+ "no-browser": { type: "boolean" },
72
+ },
73
+ });
74
+ }
75
+ catch (e) {
76
+ console.error(`oakode: ${e.message}\nSee \`oakode --help\`.`);
77
+ process.exit(2);
78
+ }
79
+ })();
80
+ if (!opt.help && !opt.version && configProblem())
81
+ console.error(yellow(configProblem())); // config.json is edited by hand
82
+ const auth = new Auth();
83
+ sweepOldOutput(); // output folders a crashed OAKODE left behind (a day old)
84
+ auth.onSwitch = (m) => process.stderr.write(`oakode: ${m}\n`); // the interactive app shows it in the conversation
85
+ const client = new OickiClient(baseUrl(), auth);
86
+ const cwd = realpathSync.native(process.cwd()); // native: one spelling of a Windows drive letter, so sessions are found again
87
+ const interactive = !opt.print && process.stdin.isTTY === true;
88
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
89
+ let tui;
90
+ let printAbort;
91
+ let printTurn;
92
+ let app;
93
+ let shuttingDown = false;
94
+ let activeSession;
95
+ /**
96
+ * The one teardown order for every exit path. The terminal comes LAST because restoring it can block when the
97
+ * terminal has stopped reading; everything that matters (commands, session data, locks) is settled before that.
98
+ */
99
+ let cleaned = false;
100
+ function cleanup() {
101
+ if (cleaned)
102
+ return;
103
+ cleaned = true;
104
+ try {
105
+ killAllChildren("SIGKILL");
106
+ }
107
+ catch { }
108
+ try {
109
+ removeOutput();
110
+ }
111
+ catch { }
112
+ try {
113
+ const dump = activeSession && !activeSession.flush() ? activeSession.emergencyDump() : undefined;
114
+ if (dump)
115
+ console.error(`Unsaved session entries were written to ${dump}`);
116
+ }
117
+ catch { }
118
+ try {
119
+ activeSession?.release();
120
+ }
121
+ catch { }
122
+ tui?.stop();
123
+ }
124
+ /** SIGTERM/SIGHUP/SIGINT: stop the turn (so every tool call gets a recorded result), then tear down. */
125
+ async function shutdown(code) {
126
+ if (shuttingDown)
127
+ return;
128
+ shuttingDown = true;
129
+ if (app?.currentTurn)
130
+ app.session.recordTurnEnd("terminated", `OAKODE received a stop signal while a turn was running`);
131
+ app?.abort?.abort();
132
+ printAbort?.abort();
133
+ tui?.cancelMenu();
134
+ await Promise.race([app?.currentTurn ?? printTurn ?? Promise.resolve(), sleep(3000)]);
135
+ cleanup();
136
+ process.exit(code);
137
+ }
138
+ process.on("SIGTERM", () => void shutdown(143));
139
+ process.on("SIGHUP", () => void shutdown(129));
140
+ process.on("SIGINT", () => void shutdown(130)); // raw-mode Ctrl+C is a key, not a signal; this is an external SIGINT
141
+ process.on("exit", cleanup);
142
+ process.on("uncaughtException", (e) => {
143
+ console.error(red(`Fatal: ${e.stack ?? e}`));
144
+ cleanup();
145
+ process.exit(1);
146
+ });
147
+ const howToSignIn = () => clientIdSetting()
148
+ ? "Run `oakode login` to sign in with your OICKI account (or set OICKI_API_KEY)."
149
+ : "Create an API key at https://oicki.vercel.app/dashboard/developer, then run `oakode login --api-key` (or set OICKI_API_KEY).";
150
+ /** One keypress answer; Enter means yes. */
151
+ async function askYesNo(prompt) {
152
+ process.stdout.write(prompt);
153
+ const stdin = process.stdin;
154
+ if (stdin.isTTY)
155
+ stdin.setRawMode(true);
156
+ stdin.resume();
157
+ const ch = await new Promise((resolve) => stdin.once("data", (d) => resolve(d.toString()[0] ?? "")));
158
+ if (stdin.isTTY)
159
+ stdin.setRawMode(false);
160
+ stdin.pause();
161
+ process.stdout.write("\n");
162
+ if (ch === "\u0003")
163
+ process.exit(130);
164
+ return !/^[nN]/.test(ch);
165
+ }
166
+ async function readSecret(prompt) {
167
+ process.stderr.write(prompt);
168
+ return new Promise((resolve) => {
169
+ let buf = "";
170
+ const stdin = process.stdin;
171
+ if (stdin.isTTY)
172
+ stdin.setRawMode(true);
173
+ stdin.resume();
174
+ const onData = (d) => {
175
+ for (const ch of d.toString()) {
176
+ if (ch === "\r" || ch === "\n" || ch === "\u0004") {
177
+ if (stdin.isTTY)
178
+ stdin.setRawMode(false);
179
+ stdin.off("data", onData);
180
+ stdin.pause();
181
+ process.stderr.write("\n");
182
+ return resolve(buf.trim());
183
+ }
184
+ if (ch === "\u0003")
185
+ process.exit(130);
186
+ if (ch === "\u007f")
187
+ buf = buf.slice(0, -1);
188
+ else
189
+ buf += ch;
190
+ }
191
+ };
192
+ stdin.on("data", onData);
193
+ stdin.once("end", () => resolve(buf.trim())); // input closed (piped, </dev/null): whatever arrived, or nothing
194
+ });
195
+ }
196
+ /** Non-billable: GET /v1/me identifies the API key (and proves OICKI accepts it). */
197
+ async function checkKey(key) {
198
+ try {
199
+ const r = await fetch(baseUrl() + "/v1/me", { headers: { Authorization: `Bearer ${key}`, "User-Agent": `oakode/${VERSION}` }, signal: AbortSignal.timeout(10_000) });
200
+ if (r.ok)
201
+ return { state: "ok", label: String((await r.json()).label ?? "") };
202
+ if (r.status === 401)
203
+ return { state: "rejected" };
204
+ return { state: "unreachable", detail: `HTTP ${r.status}` };
205
+ }
206
+ catch (e) {
207
+ return { state: "unreachable", detail: e.message };
208
+ }
209
+ }
210
+ /** Ask for a key (hidden), verify it, store it (0600). Returns false if the user entered nothing. */
211
+ async function promptForKey() {
212
+ const key = await readSecret("OICKI API key (input hidden, Enter to cancel): ");
213
+ if (!key)
214
+ return false;
215
+ if (!key.startsWith("oicki_sk_"))
216
+ throw new Error("That does not look like an OICKI API key (they start with oicki_sk_).");
217
+ const r = await checkKey(key);
218
+ if (r.state === "rejected")
219
+ throw new Error("OICKI rejected that key (401). Check that you copied all of it and that it has not been revoked. Nothing was saved.");
220
+ auth.setApiKey(key);
221
+ console.log(green(r.state === "ok" ? `Key saved and verified${r.label ? ` (label "${r.label}")` : ""}.` : `Key saved, but it could not be verified (${r.detail}). If requests fail, check your connection.`));
222
+ return true;
223
+ }
224
+ async function cmdLogin() {
225
+ if (opt["api-key"]) {
226
+ if (!(await promptForKey())) {
227
+ console.log("Cancelled; nothing saved.");
228
+ process.exitCode = 1;
229
+ }
230
+ return;
231
+ }
232
+ if (!opt["client-id"] && !clientIdSetting()) {
233
+ console.log(`Browser sign-in is not available in this build of OAKODE yet. Use an API key instead:\n ${bold("oakode login --api-key")}`);
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+ const ctl = new AbortController();
238
+ const onInt = () => ctl.abort();
239
+ process.once("SIGINT", onInt);
240
+ try {
241
+ const { scopes } = await auth.login({ clientId: opt["client-id"], expensive: opt.expensive, noBrowser: opt["no-browser"], log: (s) => console.log(s), signal: ctl.signal });
242
+ if (opt["client-id"])
243
+ saveConfig({ clientId: opt["client-id"] });
244
+ console.log(green("\n✓ Signed in to OICKI."));
245
+ if (!scopes.includes("billing:read"))
246
+ console.log(yellow("Credits will not be shown: the sign-in did not include \"see your plan and credits\"."));
247
+ if (opt.expensive && !scopes.includes("models:expensive"))
248
+ console.log(yellow("Expensive models were not allowed in the browser; they stay unavailable."));
249
+ const b = await client.billing(AbortSignal.timeout(10_000)).catch(() => undefined);
250
+ for (const l of billingLines(b, "oauth"))
251
+ console.log(l);
252
+ console.log(dim(`Credentials saved in ${home()}/auth.json (readable only by you). Start with: oakode`));
253
+ }
254
+ finally {
255
+ process.off("SIGINT", onInt);
256
+ }
257
+ }
258
+ async function cmdLogout() {
259
+ const kind = auth.source();
260
+ const revoked = await auth.logout();
261
+ rmSync(join(home(), "models-cache.json"), { force: true });
262
+ if (kind === "none")
263
+ return console.log("You were not signed in on this computer.");
264
+ console.log("Signed out: the credentials stored on this computer were deleted.");
265
+ if (kind === "oauth")
266
+ console.log(revoked ? dim("OICKI ended this sign-in (other computers stay signed in).") : yellow("OICKI could not be reached to end the sign-in; it expires on its own within 30 days, or remove OAKODE under Connected apps in your OICKI account."));
267
+ if (kind === "env")
268
+ console.log(yellow("OICKI_API_KEY is still set in your environment and will keep being used."));
269
+ }
270
+ async function cmdStatus() {
271
+ const src = auth.source();
272
+ console.log(`${bold("OAKODE")} ${VERSION} · server ${baseUrl()} · data ${home()}`);
273
+ if (src === "none") {
274
+ console.log(red("Not signed in."), howToSignIn());
275
+ process.exitCode = 1;
276
+ return;
277
+ }
278
+ if (src === "oauth") {
279
+ console.log(`Credentials: signed in with OICKI (scopes: ${auth.scopes().join(" ") || "?"}).`);
280
+ try {
281
+ await client.whoami(AbortSignal.timeout(10_000)); // proves the sign-in works, whatever was granted
282
+ console.log(green("OICKI accepts the sign-in."));
283
+ const b = await client.billing(AbortSignal.timeout(10_000));
284
+ for (const l of billingLines(b, "oauth"))
285
+ console.log(l);
286
+ }
287
+ catch (e) {
288
+ console.log(red(explain(e)));
289
+ process.exitCode = 1;
290
+ }
291
+ }
292
+ else {
293
+ console.log(`Credentials: API key ${src === "env" ? "from $OICKI_API_KEY" : `stored in ${home()}/auth.json`}.`);
294
+ let key;
295
+ try {
296
+ key = await auth.token();
297
+ }
298
+ catch (e) {
299
+ console.log(red(explain(e)));
300
+ process.exitCode = 1;
301
+ return;
302
+ }
303
+ const r = await checkKey(key);
304
+ if (r.state === "ok") {
305
+ const u = (await fetch(baseUrl() + "/v1/usage", { headers: { Authorization: `Bearer ${await auth.token()}` } }).then((x) => x.json()).catch(() => undefined));
306
+ console.log(green(`OICKI accepts it${r.label ? ` (label "${r.label}")` : ""}.`) + (u?.requestCount !== undefined ? ` Usage on this key: ${u.requestCount} requests, ${u.inputTokens} in / ${u.outputTokens} out tokens.` : ""));
307
+ }
308
+ else if (r.state === "rejected") {
309
+ console.log(red("OICKI rejected this key (401)."), `Replace it with ${bold("oakode login --api-key")}.`);
310
+ process.exitCode = 1;
311
+ }
312
+ else
313
+ console.log(yellow(`Could not reach OICKI to verify (${r.detail}).`));
314
+ }
315
+ const def = defaultModel();
316
+ console.log(`Default model: ${def.id}${def.ignored ? yellow(` (saved "${def.ignored}" is not supported, so ignored)`) : ""} · sessions in this directory: ${Session.list(cwd).length}`);
317
+ }
318
+ function cmdSessions() {
319
+ const list = Session.list(cwd, 20);
320
+ if (!list.length)
321
+ return console.log(`No sessions for ${cwd}. Start one with ${bold("oakode")}.`);
322
+ console.log(bold(`Sessions in ${cwd}`));
323
+ for (const x of list)
324
+ console.log(` ${x.id} ${ago(x.mtime).padEnd(9)} ${(x.messages === undefined ? "large" : x.messages + " msgs").padEnd(9)} ${x.preview}`);
325
+ console.log(dim(`Resume the latest: oakode -c · a specific one: oakode -r <id>`));
326
+ }
327
+ async function main() {
328
+ if (opt.help)
329
+ return console.log(HELP);
330
+ if (opt.version)
331
+ return console.log(VERSION);
332
+ const [cmd, ...rest] = positionals;
333
+ if (cmd === "login")
334
+ return cmdLogin();
335
+ if (cmd === "logout")
336
+ return cmdLogout();
337
+ if (cmd === "status")
338
+ return cmdStatus();
339
+ if (cmd === "doctor")
340
+ return void (process.exitCode = (await doctor(auth, client, cwd)) ? 0 : 1);
341
+ if (interactive && !cmd && updateCheckEnabled()) {
342
+ const n = updateNotice();
343
+ if (n)
344
+ console.error(yellow(n));
345
+ void refreshUpdateCache();
346
+ }
347
+ if (cmd === "sessions")
348
+ return cmdSessions();
349
+ if (cmd === "models") {
350
+ const catalog = await client.listModels();
351
+ const q = rest.join(" ");
352
+ const pool = (all) => (q === "all" ? all : q ? matchModels(all, q) : curatedModels(all));
353
+ const list = pool(selectableModels(catalog));
354
+ list.forEach((m) => console.log(describeModel(m)));
355
+ const hidden = experimentalModels() ? 0 : (q ? pool(catalog).length : catalog.length) - list.length;
356
+ if (!q)
357
+ console.error(dim(experimentalModels() ? `Supported models. Experimental models are on: oakode models all, or oakode models <text>.` : `Supported models. ${hidden} more in OICKI's catalog are experimental. ${experimentalHint()}`));
358
+ else
359
+ console.error(dim(`${list.length} model(s)${hidden ? `; ${hidden} more ${q === "all" ? "" : "matching "}are experimental. ${experimentalHint()}` : ""}`));
360
+ return;
361
+ }
362
+ if (auth.kind() === "none") {
363
+ const where = "Create an API key at https://oicki.vercel.app/dashboard/developer (leave \"Allow expensive models\" off).";
364
+ if (!interactive) {
365
+ console.error(`${red("Not signed in.")} ${howToSignIn()}`);
366
+ process.exit(1);
367
+ }
368
+ console.log(`${bold("Welcome to OAKODE.")} Sign in with your OICKI account to start.\n`);
369
+ if (clientIdSetting() && (await askYesNo("Open the browser to sign in? [Y/n] (n: use an API key instead) "))) {
370
+ await cmdLogin();
371
+ if (auth.kind() === "none")
372
+ process.exit(1);
373
+ }
374
+ else {
375
+ console.log(`${where}\nPaste the key below; it is checked with OICKI and stored in ${home()}/auth.json (readable only by you).\n`);
376
+ if (!(await promptForKey())) {
377
+ console.error(dim("No key entered. Run `oakode` again when you have one."));
378
+ process.exit(1);
379
+ }
380
+ }
381
+ }
382
+ // Raw mode first: keys typed while we talk to the network are captured, not left to the kernel's line editing.
383
+ if (interactive) {
384
+ tui = new Tui(process.stdin, process.stdout);
385
+ tui.onAbort = () => void shutdown(130); // Ctrl+C / Esc while starting
386
+ tui.setBusy(true); // Enter queues messages typed during startup
387
+ tui.footer = () => gray("starting…");
388
+ tui.start();
389
+ }
390
+ const fail = (e) => {
391
+ tui?.stop();
392
+ console.error(explain(e));
393
+ process.exit(1);
394
+ };
395
+ let models = [];
396
+ const cat = loadCatalog();
397
+ try {
398
+ models = await cat.models;
399
+ }
400
+ catch (e) {
401
+ fail(e);
402
+ }
403
+ cat.fresh?.then((fresh) => void models.splice(0, models.length, ...fresh), () => { }); // the cache was used: update in place
404
+ if (opt.model && !allowedModel(opt.model)) {
405
+ tui?.stop();
406
+ console.error(red(notSupported(opt.model)));
407
+ process.exit(2);
408
+ }
409
+ const saved = defaultModel();
410
+ const wanted = opt.model || saved.id;
411
+ let session;
412
+ try {
413
+ if (opt.resume || opt.continue) {
414
+ const sessions = Session.list(cwd); // only sessions recorded for this exact directory
415
+ // -c continues the latest session that has a conversation (files from older versions may hold none)
416
+ const matches = opt.resume ? sessions.filter((s) => s.id.startsWith(opt.resume)) : sessions.filter((s) => s.messages !== 0).slice(0, 1);
417
+ if (matches.length !== 1) {
418
+ tui?.stop();
419
+ console.error(red(matches.length ? `"${opt.resume}" matches ${matches.length} sessions; use a longer id.` : opt.resume ? `No session "${opt.resume}" for this directory. \`oakode sessions\` lists them.` : "No previous session in this directory. `oakode sessions` lists them."));
420
+ process.exit(1);
421
+ }
422
+ session = Session.open(matches[0].file, wanted, cwd);
423
+ if (opt.model)
424
+ session.setModel(opt.model);
425
+ }
426
+ else {
427
+ session = Session.create(cwd, wanted);
428
+ }
429
+ }
430
+ catch (e) {
431
+ return fail(e);
432
+ }
433
+ activeSession = session;
434
+ if (!models.some((m) => m.id === session.model))
435
+ console.error(yellow(`Model "${session.model}" is not in your model list; the server may reject it. See /models.`));
436
+ const resumed = !!(opt.resume || opt.continue);
437
+ const modelNotice = resumed && !allowedModel(session.model) ? compatibilityNotice(session.model) : !resumed && !opt.model && saved.ignored ? ignoredDefaultNotice(saved.ignored) : undefined;
438
+ // ---- print mode (-p, or piped stdin): plain text, no terminal control
439
+ if (!tui) {
440
+ const args = (cmd ? [cmd, ...rest] : []).join(" ").trim();
441
+ const piped = process.stdin.isTTY ? "" : (await readAll()).trim();
442
+ // with an instruction AND piped input, the model must see where the instruction ends and the data begins
443
+ const prompt = args && piped ? `${args}\n\n<stdin>\n${piped}\n</stdin>` : args || piped;
444
+ if (!prompt) {
445
+ console.error(red("No prompt given."));
446
+ process.exit(1);
447
+ }
448
+ if (opt.yes)
449
+ console.error(yellow("--yes: approval prompts are OFF. Writes, edits and shell commands run without asking."));
450
+ if (modelNotice)
451
+ console.error(yellow(modelNotice));
452
+ const out = makePrintEmitter();
453
+ const ctl = new AbortController();
454
+ printAbort = ctl;
455
+ const trust = new ProjectTrust(cwd);
456
+ const mcp = new Mcp({ cwd, trust });
457
+ await mcp.start();
458
+ const skills = new Skills({ cwd, extraDir: process.env.OAKODE_SKILLS_DIR, projectTrusted: (s, fp) => trust.skillStatus(s.name, fp) });
459
+ trust.endLegacyMigration();
460
+ // -p never delegates, except for evaluations (eval/run.ts): this variable, --yes and subagents.enabled all together
461
+ let parentCredits = 0;
462
+ const settings = subagentSettings();
463
+ const evalDelegation = process.env.OAKODE_EVAL_UNATTENDED_DELEGATION === "1" && !!opt.yes && settings.enabled;
464
+ if (evalDelegation)
465
+ console.error(yellow("Evaluation mode: subagent plans without validation errors are approved unattended, within the subagent budget."));
466
+ const subagents = evalDelegation
467
+ ? new SubagentHost({
468
+ settings, client, session: () => session, models: () => models, rate: CreditRate.stored(),
469
+ authorize: async () => null, // read-only tools only; print mode has no permission modes
470
+ confirm: async () => !!opt.yes,
471
+ autoApprove: () => !!opt.yes,
472
+ ui: { chooseFrom: async () => -1, ask: async () => null, print: () => { }, query: () => "" },
473
+ note: (t) => console.error(dim(t)),
474
+ repaint: () => { },
475
+ parentTurnCredits: () => parentCredits,
476
+ unattended: true,
477
+ })
478
+ : undefined;
479
+ subagents?.beginTurn();
480
+ const emit = (e) => {
481
+ if (e.type === "usage" && e.credits !== undefined)
482
+ parentCredits += e.credits;
483
+ out.emit(e);
484
+ };
485
+ // -p asks nothing: without --yes every action that needs approval is declined, and the run says so in its exit code
486
+ let declined = 0;
487
+ const agent = new Agent({ client, session, model: () => models.find((m) => m.id === session.model), lookup: (id) => models.find((m) => m.id === id), emit, confirm: async () => (opt.yes ? true : (declined++, false)), extraTools: () => [skills.tool(), ...mcp.tools(), subagents?.tool].filter((t) => !!t), systemExtra: () => skills.promptSection() });
488
+ let failed = false;
489
+ const resumeHint = dim(`The session is saved; continue it with: oakode -p -r ${session.id} "…"`);
490
+ const run = agent.run(prompt, ctl.signal).then(() => void (ctl.signal.aborted && console.error(yellow("\n⏹ Interrupted."))), (e) => ((failed = true), console.error("\n" + explain(e)), console.error(resumeHint)));
491
+ printTurn = run;
492
+ await run;
493
+ out.end();
494
+ await mcp.close();
495
+ // Scripts and CI read the exit status: 0 only for a turn that completed with nothing declined.
496
+ const reason = session.lastTurn?.reason;
497
+ const interrupted = ctl.signal.aborted || reason === "interrupted" || reason === "terminated";
498
+ if (!failed && !interrupted && reason && reason !== "complete")
499
+ console.error(yellow(`The turn did not complete (${reason}${session.lastTurn?.detail ? `: ${session.lastTurn.detail}` : ""}).`), "\n" + resumeHint);
500
+ if (declined)
501
+ console.error(yellow(`${declined} action${declined > 1 ? "s" : ""} needed approval and ${declined > 1 ? "were" : "was"} declined: -p cannot ask, so changes and commands need --yes.`));
502
+ process.exitCode = interrupted ? 130 : failed || (reason && reason !== "complete") || declined ? 1 : 0;
503
+ return;
504
+ }
505
+ // ---- interactive
506
+ const t = tui;
507
+ app = new App({ tui: t, client, auth, cwd, models, session, yes: !!opt.yes });
508
+ const early = t.takeQueued(); // messages submitted while starting are processed in order, as separate messages
509
+ let prefill = t.leftover();
510
+ t.setBusy(false);
511
+ app.banner(VERSION, resumed);
512
+ if (cwd === "/" || cwd === realpathSync(homedir()))
513
+ app.view.note(`Working directory is ${cwd}: the file tools can reach everything in it. cd into a project first.`, "warn");
514
+ if (opt.yes)
515
+ app.view.note("--yes: approval prompts are OFF. Writes, edits and shell commands run without asking.", "warn");
516
+ if (resumed)
517
+ app.resumeNotice();
518
+ else if (modelNotice)
519
+ app.view.note(modelNotice, "warn"); // resumeNotice covers a resumed session's model
520
+ await app.run(positionals, early, prefill);
521
+ await app.close();
522
+ cleanup();
523
+ }
524
+ /**
525
+ * The model catalog takes seconds to fetch, so start from a cached copy (under a day old, same server and kind of
526
+ * credential) and refresh it in the background. Without a usable cache, wait for the network; if that fails, an
527
+ * older cache still beats not starting.
528
+ */
529
+ function loadCatalog() {
530
+ const file = join(home(), "models-cache.json");
531
+ const key = `${client.baseUrl} ${auth.kind()}`;
532
+ const cached = readJson(file);
533
+ const valid = cached?.key === key && Array.isArray(cached.data) && cached.data.length > 0 ? cached : undefined;
534
+ const fetched = client.listModels().then((data) => {
535
+ try {
536
+ writeJson(file, { at: Date.now(), key, data });
537
+ }
538
+ catch { }
539
+ return data;
540
+ });
541
+ fetched.catch(() => { }); // a failed background refresh is not an error: the cached copy is in use
542
+ if (valid && Date.now() - valid.at < 24 * 3600_000)
543
+ return { models: Promise.resolve(valid.data.slice()), fresh: fetched };
544
+ const models = fetched.catch((e) => {
545
+ // credentials problems must surface; only an unreachable or failing server falls back to the old copy
546
+ if (!valid || (e instanceof OickiError && (e.status === 401 || e.status === 403)))
547
+ throw e;
548
+ console.error(yellow(`Could not refresh the model list (${e.message}); using the copy from ${ago(new Date(valid.at))}.`));
549
+ return valid.data.slice();
550
+ });
551
+ return { models };
552
+ }
553
+ main().then(() => {
554
+ if (!shuttingDown)
555
+ process.exit(process.exitCode ?? 0); // a signal handler that is already shutting down owns the exit code
556
+ }, (e) => {
557
+ console.error(explain(e));
558
+ cleanup();
559
+ process.exit(1);
560
+ });
package/dist/mask.js ADDED
@@ -0,0 +1,63 @@
1
+ export const MASK_KEEP = 30;
2
+ export const MASK_STEP = 20;
3
+ const BIG = 600;
4
+ const boundary = (len, keep, step) => (len - keep < step ? 0 : Math.floor((len - keep) / step) * step);
5
+ export const maskBoundary = (len) => boundary(len, MASK_KEEP, MASK_STEP);
6
+ /**
7
+ * The model's own write/edit arguments are the biggest thing in a long coding session, and once applied the content is
8
+ * on disk. They are shrunk much sooner than everything else (still in steps, to keep the cached prefix stable).
9
+ */
10
+ export const FILE_ARGS_KEEP = 8;
11
+ export const FILE_ARGS_STEP = 8;
12
+ export const fileArgsBoundary = (len) => boundary(len, FILE_ARGS_KEEP, FILE_ARGS_STEP);
13
+ const FILE_TOOLS = new Set(["write", "edit"]);
14
+ const bigFileCall = (c) => FILE_TOOLS.has(c.function.name) && c.function.arguments.length > BIG;
15
+ const omitted = (n) => `[omitted ${n} chars to save context]`;
16
+ function shrink(v) {
17
+ if (typeof v === "string")
18
+ return v.length > BIG ? omitted(v.length) : v;
19
+ if (Array.isArray(v))
20
+ return v.map(shrink);
21
+ if (v && typeof v === "object")
22
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, shrink(x)]));
23
+ return v;
24
+ }
25
+ function shrinkCall(c) {
26
+ if (c.function.arguments.length <= BIG)
27
+ return c;
28
+ try {
29
+ return { ...c, function: { ...c.function, arguments: JSON.stringify(shrink(JSON.parse(c.function.arguments))) } };
30
+ }
31
+ catch {
32
+ return c;
33
+ }
34
+ }
35
+ export function maskFileArgs(m) {
36
+ if (m.role !== "assistant" || !m.tool_calls?.some(bigFileCall))
37
+ return m;
38
+ return { ...m, tool_calls: m.tool_calls.map((c) => (bigFileCall(c) ? shrinkCall(c) : c)) };
39
+ }
40
+ export function maskMessage(m) {
41
+ if (m.role === "tool") {
42
+ if (m.content.length <= BIG)
43
+ return m;
44
+ const first = m.content.trimStart().split("\n")[0].slice(0, 120);
45
+ return { ...m, content: `[older tool result omitted to save context (${m.content.length} chars); it began: ${first}. Run the tool again if you need it.]` };
46
+ }
47
+ if (m.role === "assistant" && m.tool_calls?.length) {
48
+ return {
49
+ ...m,
50
+ tool_calls: m.tool_calls.map(shrinkCall),
51
+ };
52
+ }
53
+ if (m.role === "user" && m.images?.length) {
54
+ const { images, ...rest } = m;
55
+ return { ...rest, content: `${m.content}\n[${images.length} image${images.length > 1 ? "s" : ""} no longer in context: ${images.map((i) => i.path).join(", ")}; read the file again to see ${images.length > 1 ? "them" : "it"}]` };
56
+ }
57
+ return m;
58
+ }
59
+ export function maskTail(tail) {
60
+ const b = maskBoundary(tail.length);
61
+ const f = fileArgsBoundary(tail.length);
62
+ return b || f ? tail.map((m, i) => (i < b ? maskMessage(m) : i < f ? maskFileArgs(m) : m)) : tail;
63
+ }