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
@@ -0,0 +1,587 @@
1
+ /** Slash commands: one registry drives dispatch, /help and completion. */
2
+ import { AGENTS_OFF } from "./app.js";
3
+ import { VERSION, home, loadConfig, saveConfig } from "./config.js";
4
+ import { renderDiff } from "./diff.js";
5
+ import { allJobs, jobById, runningJobs, stateOf } from "./jobs.js";
6
+ import { describe as describeRule } from "./permissions.js";
7
+ import { MODES, MODE_LABEL, MODE_SUMMARY, parseMode } from "./modes.js";
8
+ import { permissionsUi } from "./permissions_ui.js";
9
+ import { basename } from "node:path";
10
+ import { accessNote, allowedModel, curatedModels, defaultModel, curatedNote, describeModel, experimentalHint, experimentalModels, isSupported, matchModels, notSupported, selectableModels } from "./models.js";
11
+ import { REASONING_EFFORTS } from "./oicki.js";
12
+ import { reasoningEffort } from "./agent.js";
13
+ import { billingLines } from "./account.js";
14
+ import { clientIdSetting } from "./auth.js";
15
+ import { Session } from "./session.js";
16
+ import { MAX_SKILL_ENTRIES, skillFingerprint } from "./skills.js";
17
+ import { ago, bold, dim, fmtCredits, fmtTokens, green, red, stripAnsi, wrap, yellow } from "./ui.js";
18
+ const SHOWN_MODELS = 60;
19
+ const stamp = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
20
+ /** Every word of the query must appear in the session's first prompts, id, project name or date. */
21
+ function filterSessions(list, q) {
22
+ const words = q.toLowerCase().split(/\s+/).filter(Boolean);
23
+ if (!words.length)
24
+ return list;
25
+ return list.filter((x) => {
26
+ const hay = [x.preview, ...x.prompts, x.id, basename(x.cwd), stamp(x.mtime), x.created ? stamp(x.created) : ""].join("\n").toLowerCase();
27
+ return words.every((w) => hay.includes(w));
28
+ });
29
+ }
30
+ export function buildCommands() {
31
+ return [
32
+ {
33
+ name: "help",
34
+ desc: "show commands and keys",
35
+ run: (_a, app) => {
36
+ const w = Math.max(...app.commands.map((c) => c.name.length + (c.args ? c.args.length + 1 : 0)));
37
+ app.view.block([
38
+ bold("Commands"),
39
+ ...app.commands.map((c) => ` ${bold("/" + c.name)}${c.args ? " " + dim(c.args) : ""}${" ".repeat(w - c.name.length - (c.args ? c.args.length + 1 : 0))} ${c.desc}`),
40
+ "",
41
+ bold("Keys"),
42
+ " Enter send · Ctrl+J / Alt+Enter newline · ↑/↓ history · Tab complete a /command",
43
+ " Esc or Ctrl+C interrupts a running turn · Ctrl+C twice or Ctrl+D exits",
44
+ " Typing while the agent works queues your message · pasted text is never sent by itself",
45
+ ]);
46
+ },
47
+ },
48
+ {
49
+ name: "model",
50
+ desc: "show or switch the model",
51
+ args: "[id or text]",
52
+ complete: (p, app) => matchModels(selectableModels(app.matchModels("")), p).slice(0, 40).map((m) => ({ value: m.id, desc: modelHint(m) })),
53
+ run: async (args, app) => {
54
+ if (!app.models.length)
55
+ app.models = await app.client.listModels();
56
+ const choices = selectableModels(app.models);
57
+ const exp = experimentalModels();
58
+ let m;
59
+ if (!args) {
60
+ // the supported models; with experimental models on, the rest of the catalog one step further
61
+ const supported = curatedModels(app.models);
62
+ const others = choices.filter((x) => !isSupported(x.id));
63
+ const i = await app.tui.chooseFrom({
64
+ title: "Select a model",
65
+ options: () => [...supported.map((x) => ({ label: x.id + (x.id === app.session.model ? green(" (current)") : ""), hint: modelHint(x) })), ...(others.length ? [{ label: "All models (experimental)…", hint: `${others.length} more, unsupported (or /model <text>)` }] : [])],
66
+ initial: Math.max(0, supported.findIndex((x) => x.id === app.session.model)),
67
+ });
68
+ if (i < 0)
69
+ return;
70
+ m = i < supported.length ? supported[i] : await pickModel(app, others);
71
+ if (!m)
72
+ return;
73
+ }
74
+ else {
75
+ const found = matchModels(choices, args);
76
+ if (found.length === 1)
77
+ m = found[0];
78
+ else if (found.length > 1)
79
+ m = await pickModel(app, found);
80
+ else if (exp || isSupported(args))
81
+ m = await app.client.getModel(args).catch(() => undefined); // an exact id the cached list does not have yet
82
+ else {
83
+ const other = matchModels(app.models, args);
84
+ if (other.length)
85
+ return app.view.note(other.length === 1 ? notSupported(other[0].id) : `"${args}" matches ${other.length} models, none of them supported. ${experimentalHint()}`, "warn");
86
+ }
87
+ if (!m)
88
+ return found.length > 1 ? undefined : app.view.note(`No ${exp ? "" : "supported "}model matches "${args}".`, "warn");
89
+ }
90
+ if (!allowedModel(m.id))
91
+ return app.view.note(notSupported(m.id), "warn");
92
+ if (!app.models.some((x) => x.id === m.id))
93
+ app.models.push(m);
94
+ app.session.setModel(m.id);
95
+ saveConfig({ model: m.id });
96
+ const lines = [describeModel(m, m.id)];
97
+ const note = accessNote(m);
98
+ if (note)
99
+ lines.push(yellow(`This model ${note}; such requests are refused (403) for this credential.`));
100
+ if (!m.oicki.capabilities.tools)
101
+ lines.push(yellow("This model does not support tools: OAKODE can chat with it but cannot read files, edit or run commands."));
102
+ if (!isSupported(m.id))
103
+ lines.push(yellow("Experimental model: not supported by OAKODE; tool use, caching and limits may not work as with the supported ones."));
104
+ app.view.block(lines);
105
+ },
106
+ },
107
+ {
108
+ name: "models",
109
+ desc: "list the supported models (all, or filter by text)",
110
+ args: "[all | text]",
111
+ run: async (args, app) => {
112
+ app.models = await app.client.listModels();
113
+ const exp = experimentalModels();
114
+ const supported = curatedModels(app.models);
115
+ const rest = app.models.length - supported.length;
116
+ if (!args)
117
+ return app.view.block([
118
+ ...supported.map((m) => describeModel(m, app.session.model)),
119
+ dim(exp ? `Supported models. Experimental models are on: /models all or /models <text> for the other ${rest}.` : `Supported models. ${rest} more in OICKI's catalog are experimental. ${experimentalHint()}`),
120
+ ]);
121
+ const pool = (all) => (args === "all" ? all : matchModels(all, args));
122
+ const list = pool(selectableModels(app.models));
123
+ const hidden = exp ? 0 : pool(app.models).length - list.length;
124
+ app.view.block([
125
+ ...list.slice(0, SHOWN_MODELS).map((m) => describeModel(m, app.session.model)),
126
+ ...(list.length > SHOWN_MODELS ? [dim(`… ${list.length - SHOWN_MODELS} more; narrow with /models <text>`)] : []),
127
+ ...(hidden ? [dim(`${hidden} more ${args === "all" ? "" : "matching "}model${hidden === 1 ? " is" : "s are"} experimental. ${experimentalHint()}`)] : []),
128
+ ...(!list.length && !hidden ? [dim(`No model matches "${args}".`)] : []),
129
+ ]);
130
+ },
131
+ },
132
+ {
133
+ name: "login",
134
+ desc: "sign in with your OICKI account (again)",
135
+ args: "[--expensive]",
136
+ run: async (args, app) => {
137
+ if (process.env.OICKI_API_KEY?.trim())
138
+ return app.view.note("OICKI_API_KEY is set, so it is used instead of a sign-in. Unset it to sign in with your account.", "warn");
139
+ if (!clientIdSetting())
140
+ return app.view.note("Browser sign-in is not available in this build. Quit and run `oakode login --api-key`.", "warn");
141
+ const ctl = (app.abort = new AbortController());
142
+ app.tui.setBusy(true);
143
+ try {
144
+ const { scopes } = await app.auth.login({ expensive: /--expensive/.test(args), log: (s) => app.view.block(s.split("\n")), signal: ctl.signal });
145
+ app.view.note(`✓ Signed in to OICKI${scopes.includes("billing:read") ? "" : " (credits will not be shown: the plan-and-credits permission was not granted)"}.`);
146
+ await app.refreshBilling(true);
147
+ }
148
+ catch (e) {
149
+ app.view.note(e.message, "warn");
150
+ }
151
+ finally {
152
+ app.abort = null;
153
+ app.tui.setBusy(false);
154
+ }
155
+ },
156
+ },
157
+ {
158
+ name: "status",
159
+ desc: "model, session, context, credentials",
160
+ run: async (_a, app) => {
161
+ await app.refreshBilling(true);
162
+ const s = app.session;
163
+ const win = app.agent.contextWindow();
164
+ const src = app.auth.source();
165
+ app.view.block([
166
+ `${bold("OAKODE")} ${VERSION} · ${app.cwd}`,
167
+ `model ${s.model}${app.agent.servedBy && app.agent.servedBy !== s.model ? ` (served by ${app.agent.servedBy})` : ""}`,
168
+ `session ${s.id} · ${s.raw.length} messages · ${s.file}`,
169
+ `context ~${fmtTokens(s.estimateTokens())}${win ? ` of ${fmtTokens(win)}` : ""}${s.summary ? " (compacted)" : ""}`,
170
+ `credentials ${src === "env" ? "API key from $OICKI_API_KEY" : src === "stored-apikey" ? `API key stored in ${home()}/auth.json` : src === "oauth" ? "OAuth session" : "none"}`,
171
+ `approvals ${app.autoApprove ? yellow("OFF (--yes): everything runs unattended") : "every write, edit and command asks first"}${app.permissions.all().length ? dim(` · ${app.permissions.all().filter((r) => r.scope === "session").length} session / ${app.permissions.all().filter((r) => r.scope === "project").length} project permissions (/permissions)`) : ""}`,
172
+ ...billingLines(app.billing, app.auth.kind()),
173
+ `default ${defaultModel().id}${defaultModel().ignored ? yellow(` (saved "${defaultModel().ignored}" is not supported, so ignored)`) : ""}`,
174
+ ]);
175
+ },
176
+ },
177
+ {
178
+ name: "effort",
179
+ desc: "reasoning effort for models that reason",
180
+ args: `[${REASONING_EFFORTS.join("|")}|default]`,
181
+ complete: (p) => [...REASONING_EFFORTS, "default"].filter((x) => x.startsWith(p)).map((value) => ({ value })),
182
+ run: (args, app) => {
183
+ const want = args.trim();
184
+ const reasons = !!app.currentModel()?.oicki.capabilities.reasoning;
185
+ const support = reasons ? "" : dim(" (the current model does not take a reasoning effort; it applies when you switch to one that does)");
186
+ if (!want)
187
+ return app.view.block([`reasoning effort: ${reasoningEffort() ?? "provider default"}${support}`]);
188
+ if (want === "default")
189
+ saveConfig({ reasoningEffort: undefined });
190
+ else if (REASONING_EFFORTS.includes(want))
191
+ saveConfig({ reasoningEffort: want });
192
+ else
193
+ return app.view.note(`Unknown effort "${want}". Use one of: ${REASONING_EFFORTS.join(", ")}, default.`, "warn");
194
+ app.view.note(`Reasoning effort: ${reasoningEffort() ?? "provider default"}.${support}`);
195
+ },
196
+ },
197
+ {
198
+ name: "agents",
199
+ desc: "subagents: status, details, transcript, cancel (works while they run)",
200
+ args: "[id | log <id> | cancel <id|all>]",
201
+ run: (args, app) => (app.subagents ? app.view.block(app.subagents.command(args)) : app.view.note(AGENTS_OFF)),
202
+ },
203
+ {
204
+ name: "cost",
205
+ desc: "tokens, cost and credits",
206
+ run: async (_a, app) => {
207
+ await app.refreshBilling(true);
208
+ const u = app.usage;
209
+ app.view.block([
210
+ `${u.requests} request${u.requests === 1 ? "" : "s"} this run · ${fmtTokens(u.prompt)} tokens in · ${fmtTokens(u.completion)} out`,
211
+ ...(u.cached || u.reasoning ? [`${fmtTokens(u.cached)} of the input read from the prompt cache (${u.prompt ? Math.round((100 * u.cached) / u.prompt) : 0}%) · ${fmtTokens(u.reasoning)} reasoning tokens`] : []),
212
+ ...(u.settled ? [`charged ${fmtCredits(u.credits)} credits (as settled by OICKI${u.settled < u.requests ? `; ${u.requests - u.settled} request(s) did not report a charge` : ""})`] : []),
213
+ u.priced ? `estimated cost ≈ $${u.usd < 0.01 ? u.usd.toFixed(5) : u.usd.toFixed(3)} (catalog prices, cache-aware)` : dim("no price known for the model(s) used"),
214
+ ...(app.subagents?.costLines(u.credits) ?? []),
215
+ ...billingLines(app.billing, app.auth.kind()),
216
+ dim("Compaction summaries count as requests. The figures above the subagent lines are the main agent's own."),
217
+ ]);
218
+ },
219
+ },
220
+ {
221
+ name: "permissions",
222
+ desc: "view and edit what is allowed, asked or denied",
223
+ args: "[list|revoke <id>|all]",
224
+ complete: (p, app) => {
225
+ const rules = app.permissions.all();
226
+ const items = [{ value: "revoke", desc: "remove a permission", space: true }, ...rules.map((r) => ({ value: `revoke ${r.id}`, desc: `[${r.scope}] ${describeRule(r)}` })), ...(rules.length ? [{ value: "revoke all", desc: "remove every permission" }] : [])];
227
+ return items.filter((i) => i.value.startsWith(p));
228
+ },
229
+ run: async (args, app) => {
230
+ const [sub, id] = args.split(/\s+/);
231
+ if (sub === "revoke") {
232
+ if (!id)
233
+ return app.view.note("Usage: /permissions revoke <id> | all", "warn");
234
+ const n = app.permissions.revoke(id);
235
+ return app.view.note(n ? `Revoked ${n} permission${n > 1 ? "s" : ""}.` : `No permission with id "${id}".`, n ? "info" : "warn");
236
+ }
237
+ if (!sub)
238
+ return permissionsUi(app);
239
+ if (sub !== "list")
240
+ return app.view.note("Usage: /permissions [list|revoke <id>|all]", "warn");
241
+ const rules = app.permissions.all();
242
+ const pol = app.permissions.policyKeys().map((k) => ({ k, ...app.permissions.shownPolicy(k) }));
243
+ app.view.block([
244
+ bold(`Permissions · ${MODE_LABEL[app.mode]}`),
245
+ dim(` ${MODE_SUMMARY[app.mode]}${app.autoApprove ? yellow(" (--yes is ON: nothing asks)") : ""}`),
246
+ ...(pol.length ? ["", bold("Custom policy entries"), ...pol.map((p) => ` ${p.k.padEnd(28)} ${p.policy.padEnd(6)} ${p.scope === "session" ? "session only" : "saved"}`)] : []),
247
+ ...(rules.length ? ["", bold("Allowed without asking"), ...rules.map((r) => ` ${r.id.padEnd(4)} ${r.scope.padEnd(8)} ${describeRule(r)}`)] : ["", dim("No granted permissions. Choose \"Allow for this session/project\" in an approval menu to add one.")]),
248
+ ...(app.permissions.storagePath ? [dim(`Saved settings live outside the repository: ${app.permissions.storagePath}`)] : []),
249
+ ]);
250
+ },
251
+ },
252
+ {
253
+ name: "mode",
254
+ desc: "show or switch the permission mode (Shift+Tab cycles)",
255
+ args: "[default|auto-edit|plan|custom]",
256
+ complete: (p) => MODES.map((m) => ({ value: m === "autoedit" ? "auto-edit" : m, desc: MODE_SUMMARY[m] })).filter((i) => i.value.startsWith(p)),
257
+ run: (args, app) => {
258
+ if (!args)
259
+ return app.view.note(`Mode: ${MODE_LABEL[app.mode]} — ${MODE_SUMMARY[app.mode]}. Shift+Tab or /mode <name> switches (default, auto-edit, plan, custom).`);
260
+ const m = parseMode(args);
261
+ if (!m)
262
+ return app.view.note(`Unknown mode "${args}". Use default, auto-edit, plan or custom.`, "warn");
263
+ app.setMode(m, true);
264
+ app.view.note(`Mode: ${MODE_LABEL[m]} — ${MODE_SUMMARY[m]}${app.autoApprove && m !== "plan" ? " (--yes is still ON)" : ""}`);
265
+ },
266
+ },
267
+ {
268
+ name: "history",
269
+ desc: "show earlier messages of a resumed conversation",
270
+ args: "[count|all]",
271
+ complete: (p) => [{ value: "all", desc: "everything before what is shown" }].filter((i) => i.value.startsWith(p)),
272
+ run: (args, app) => app.history(args === "all" ? "all" : Math.max(1, Math.min(500, Number(args) || 50))),
273
+ },
274
+ {
275
+ name: "diff",
276
+ desc: "review file changes made in this session",
277
+ args: "[number|path|all]",
278
+ complete: (p, app) => [{ value: "all", desc: "every change" }, ...[...new Set(app.session.changes.map((c) => c.diff.path))].map((v) => ({ value: v }))].filter((i) => i.value.startsWith(p)),
279
+ run: (args, app) => {
280
+ const ch = app.session.changes;
281
+ if (!ch.length)
282
+ return app.view.note("No file changes in this session yet.");
283
+ const cols = app.tui.screen.cols;
284
+ const row = (c, i) => ` ${String(i + 1).padStart(2)}. ${new Date(c.ts).toLocaleTimeString()} ${c.tool.padEnd(5)} ${c.diff.path} ${green("+" + c.diff.added)} ${red("−" + c.diff.removed)}`;
285
+ const show = (idx) => idx.flatMap((i) => ["", ...renderDiff(ch[i].diff, cols, 200).lines]);
286
+ let pick;
287
+ if (!args)
288
+ pick = [ch.length - 1];
289
+ else if (args === "all")
290
+ pick = ch.map((_, i) => i);
291
+ else if (/^\d+$/.test(args))
292
+ pick = ch[Number(args) - 1] ? [Number(args) - 1] : [];
293
+ else
294
+ pick = ch.map((c, i) => (c.diff.path === args ? i : -1)).filter((i) => i >= 0);
295
+ if (!pick.length)
296
+ return app.view.note(`No change matches "${args}". /diff lists them.`, "warn");
297
+ app.view.block([bold(`File changes this session (${ch.length})`), ...ch.map(row), ...show(pick), ...(!args && ch.length > 1 ? [dim("/diff <number> or /diff all shows the others")] : [])]);
298
+ },
299
+ },
300
+ {
301
+ name: "skills",
302
+ desc: "list available skills; trust this project's skills",
303
+ args: "[name|trust|untrust|reload]",
304
+ complete: (p, app) => [...["trust", "untrust", "reload"].map((v) => ({ value: v, desc: v === "trust" ? "enable skills shipped in this project" : v === "untrust" ? "disable them again" : "rescan the skill folders" })), ...app.skills.list().map((s) => ({ value: s.name, desc: s.description.slice(0, 60) }))].filter((i) => i.value.startsWith(p)),
305
+ run: async (args, app) => {
306
+ const sk = app.skills;
307
+ if (args === "reload") {
308
+ sk.load();
309
+ return app.view.note(`Rescanned: ${sk.list().length} skill${sk.list().length === 1 ? "" : "s"} available.`);
310
+ }
311
+ if (args === "untrust") {
312
+ app.trust.untrustSkills();
313
+ sk.load();
314
+ return app.view.note("Project skills are disabled again.");
315
+ }
316
+ if (args === "trust") {
317
+ const pending = sk.inactive.filter((x) => x.skill.source === "project" && /not trusted|changed since/.test(x.reason));
318
+ if (app.trust.skills && !pending.length)
319
+ return app.view.note("This project's skills are already trusted.");
320
+ if (!pending.length)
321
+ return app.view.note("This project has no skills in .agents/skills/.");
322
+ const i = await app.tui.chooseFrom({
323
+ title: "Trust the skills in this project?",
324
+ lines: [...pending.map((x) => ` ${x.skill.name} ${dim(x.skill.description.slice(0, 80))}${/changed since/.test(x.reason) ? yellow(" (changed since you trusted it)") : ""}`), dim(` from ${sk.dirs()[0].dir}`), dim(" Trust covers their current contents; if they change later, they are disabled until you review them again."), yellow(" Skills are instructions written by whoever made this repository. The model may read and follow them;"), yellow(" it still needs your approval to edit files or run commands.")],
325
+ options: () => [{ label: "Trust this project's skills" }, { label: "Cancel" }],
326
+ initial: 1,
327
+ });
328
+ if (i !== 0)
329
+ return app.view.note("Not enabled.");
330
+ const fps = pending.map((x) => ({ name: x.skill.name, fingerprint: skillFingerprint(x.skill.dir) }));
331
+ app.trust.trustSkills(fps);
332
+ sk.load();
333
+ const tooBig = fps.filter((f) => !f.fingerprint).map((f) => f.name);
334
+ if (tooBig.length)
335
+ app.view.note(`Not enabled: ${tooBig.join(", ")} (more than ${MAX_SKILL_ENTRIES} files, too many to verify).`, "warn");
336
+ return app.view.note(`Enabled. ${sk.list().length} skill${sk.list().length === 1 ? "" : "s"} available.`);
337
+ }
338
+ if (args) {
339
+ const s = sk.get(args) ?? sk.inactive.find((x) => x.skill.name === args)?.skill;
340
+ if (!s)
341
+ return app.view.note(`No skill "${args}". /skills lists them.`, "warn");
342
+ const off = sk.inactive.find((x) => x.skill === s);
343
+ return app.view.block([
344
+ `${bold(s.name)} ${dim(`${s.source} skill${off ? " · NOT ACTIVE: " + off.reason : ""}`)}`,
345
+ ...wrap(s.description, app.tui.screen.cols),
346
+ dim(`directory ${s.dir}`),
347
+ ...(s.license ? [dim(`license ${s.license}`)] : []),
348
+ ...(s.compatibility ? [dim(`requires ${s.compatibility}`)] : []),
349
+ ...(s.allowedTools ? [dim(`allowed-tools: ${s.allowedTools} (ignored: skills cannot pre-approve tools)`)] : []),
350
+ ...Object.entries(s.metadata ?? {}).map(([k, v]) => dim(`${k}: ${v}`)),
351
+ ]);
352
+ }
353
+ const active = sk.list();
354
+ app.view.block([
355
+ bold(`Skills (${active.length} available)`),
356
+ ...(active.length ? active.map((s) => ` ${bold(s.name.padEnd(Math.min(24, Math.max(...active.map((x) => x.name.length)))))} ${dim(s.source.padEnd(8))} ${s.description.length > 90 ? s.description.slice(0, 89) + "…" : s.description}`) : [dim(" none found")]),
357
+ ...(sk.inactive.length ? ["", bold("Not active"), ...sk.inactive.map((x) => ` ${x.skill.name.padEnd(24)} ${dim(x.reason)}`)] : []),
358
+ ...(sk.diagnostics.length ? ["", bold("Problems"), ...sk.diagnostics.map((d) => yellow(` ${d.path.replace(home(), "~")}: ${d.problem}`))] : []),
359
+ "",
360
+ dim(`Searched: ${sk.dirs().map((d) => d.dir.replace(home().replace(/\.oakode$/, ""), "~/")).join(" · ")}`),
361
+ dim("The model loads a skill itself when your request matches its description. /skills <name> shows details."),
362
+ ]);
363
+ },
364
+ },
365
+ {
366
+ name: "jobs",
367
+ desc: "Background jobs the agent started: list, or stop one or all",
368
+ args: "[kill <id|all>]",
369
+ complete: (p) => ["kill all", ...runningJobs().map((j) => `kill ${j.id}`)].filter((v) => v.startsWith(p)).map((value) => ({ value })),
370
+ run: async (args, app) => {
371
+ const [sub, which] = args.trim().split(/\s+/);
372
+ if (sub === "kill") {
373
+ const targets = which === "all" ? runningJobs() : [jobById(Number(which))].filter((j) => j?.alive);
374
+ if (!targets.length)
375
+ return app.view.note(which === "all" ? "No background jobs are running." : `No running job ${which ?? ""}. /jobs lists them.`, "warn");
376
+ await Promise.all(targets.map((j) => j.stop("kill")));
377
+ return app.view.note(`Stopped ${targets.map((j) => `job ${j.id}`).join(", ")}.`);
378
+ }
379
+ const all = allJobs();
380
+ if (!all.length)
381
+ return app.view.note("No background jobs. The agent starts one with bash {background: true} (a dev server, a watcher, a slow test suite); it asks first like any command.");
382
+ app.view.block([
383
+ bold("Background jobs"),
384
+ ...all.map((j) => ` ${bold(String(j.id))} ${j.running ? green("running") : dim(stateOf(j))} ${j.command.split("\n")[0].slice(0, 80)} ${dim(j.log)}`),
385
+ dim("/jobs kill <id|all> stops them; all are stopped when OAKODE exits."),
386
+ ]);
387
+ },
388
+ },
389
+ {
390
+ name: "mcp",
391
+ desc: "MCP servers: status, tools, approve, reconnect",
392
+ args: "[name|approve|revoke|reconnect|reload]",
393
+ complete: (p, app) => {
394
+ const names = app.mcp.servers().map((x) => x.def.name);
395
+ const items = [
396
+ ...["approve", "revoke", "reconnect", "reload"].map((v) => ({ value: v, desc: v === "approve" ? "start a server this project configures" : v === "revoke" ? "stop trusting a project server" : v === "reconnect" ? "retry a failed server" : "re-read the config files", space: v !== "reload" })),
397
+ ...["approve", "revoke", "reconnect"].flatMap((v) => names.map((n) => ({ value: `${v} ${n}` }))),
398
+ ...names.map((n) => ({ value: n })),
399
+ ];
400
+ return items.filter((i) => i.value.startsWith(p));
401
+ },
402
+ run: async (args, app) => {
403
+ const m = app.mcp;
404
+ const [sub, name] = args.split(/\s+/);
405
+ const need = (n) => {
406
+ const s = n ? m.get(n) : undefined;
407
+ if (!s)
408
+ throw new Error(n ? `No MCP server "${n}". /mcp lists them.` : `Usage: /mcp ${sub} <server>`);
409
+ return s;
410
+ };
411
+ if (sub === "reload") {
412
+ m.load();
413
+ void m.start();
414
+ return app.view.note(`Config re-read: ${m.servers().length} server${m.servers().length === 1 ? "" : "s"}.`);
415
+ }
416
+ if (sub === "reconnect") {
417
+ if (name === "all" || !name) {
418
+ await Promise.all(m.servers().filter((s) => s.def.source === "user" || s.state !== "needs-approval").map((s) => m.reconnect(s.def.name).catch(() => { })));
419
+ return;
420
+ }
421
+ need(name);
422
+ return void (await m.reconnect(name));
423
+ }
424
+ if (sub === "revoke") {
425
+ need(name);
426
+ m.revoke(name);
427
+ return app.view.note(`Stopped ${name} and withdrew its approval.`);
428
+ }
429
+ if (sub === "approve") {
430
+ const s = need(name);
431
+ if (s.def.source === "user")
432
+ return app.view.note(`${name} comes from your own config and needs no approval.`);
433
+ const spec = s.def.spec;
434
+ const i = await app.tui.chooseFrom({
435
+ title: `Start MCP server "${name}" from this project?`,
436
+ lines: [
437
+ spec.type === "stdio" ? ` runs ${bold([spec.command, ...spec.args].join(" "))}` : ` connects ${bold(spec.url)}`,
438
+ ...(spec.type === "stdio" && Object.keys(spec.env).length ? [dim(` env ${Object.keys(spec.env).join(", ")} (values not shown)`)] : []),
439
+ ...(spec.type === "http" && Object.keys(spec.headers).length ? [dim(` headers ${Object.keys(spec.headers).join(", ")} (values not shown)`)] : []),
440
+ dim(` from ${s.def.configPath}`),
441
+ yellow(spec.type === "stdio" ? " This program would run on your computer with your permissions, started by a file in the repository." : " The server's tools and replies are untrusted; every tool call still asks you first."),
442
+ yellow(" If the configuration changes later, you will be asked again."),
443
+ ],
444
+ options: () => [{ label: "Start this server" }, { label: "Cancel" }],
445
+ initial: 1,
446
+ });
447
+ if (i !== 0)
448
+ return app.view.note("Not started.");
449
+ m.approve(name);
450
+ return void (await m.reconnect(name));
451
+ }
452
+ if (sub && !m.get(sub))
453
+ return app.view.note(`No MCP server "${sub}". /mcp lists them.`, "warn");
454
+ const cols = app.tui.screen.cols;
455
+ const icon = (s) => (s.state === "ready" ? green("●") : s.state === "starting" ? yellow("◌") : s.state === "failed" ? red("✗") : s.state === "needs-approval" ? yellow("!") : dim("○"));
456
+ const label = (s) => (s.state === "needs-approval" ? yellow("needs approval: /mcp approve " + s.def.name) : s.state === "ready" ? `${s.tools.length} tool${s.tools.length === 1 ? "" : "s"}` : s.state === "failed" ? red("failed") : s.state);
457
+ const servers = sub ? [m.get(sub)] : m.servers();
458
+ if (!servers.length && !m.problems.length)
459
+ return app.view.block([bold("MCP servers"), dim(" none configured"), dim(" Add servers to ~/.oakode/mcp.json (yours) or .mcp.json in a project (needs your approval):"), dim(' {"mcpServers": {"files": {"command": "node", "args": ["server.js"]}, "web": {"type": "http", "url": "https://…"}}}')]);
460
+ app.view.block([
461
+ bold(`MCP servers (${m.servers().filter((s) => s.state === "ready").length} of ${m.servers().length} ready)`),
462
+ ...servers.flatMap((s) => [
463
+ ` ${icon(s)} ${bold(s.def.name)} ${dim(`${s.def.spec.type === "stdio" ? "stdio" : "http"} · ${s.def.source === "user" ? "your config" : "project config"}`)} ${label(s)}${s.serverName ? dim(" " + s.serverName) : ""}`,
464
+ dim(` ${s.def.spec.type === "stdio" ? "$ " : ""}${s.description.slice(0, cols - 10)}`),
465
+ ...(s.state === "failed" && s.error ? [red(` ${s.error.slice(0, cols - 10)}`)] : []),
466
+ ...(sub ? s.tools.flatMap((t) => [` ${bold(t.name)}${dim(" — " + (t.description.replace(/\s+/g, " ").slice(0, 90) || "(no description)"))}`]) : []),
467
+ ]),
468
+ ...(m.problems.length ? ["", bold("Config problems"), ...m.problems.map((p) => yellow(` ${p.where}: ${p.problem}`))] : []),
469
+ "",
470
+ dim("Calls to MCP tools always ask first; server descriptions and replies are treated as untrusted. /mcp <name> lists a server's tools."),
471
+ ]);
472
+ },
473
+ },
474
+ {
475
+ name: "compact",
476
+ desc: "summarize older history to free context",
477
+ run: async (_a, app) => {
478
+ await app.compact();
479
+ },
480
+ },
481
+ {
482
+ name: "clear",
483
+ desc: "start a fresh conversation (the old one stays resumable)",
484
+ run: (_a, app) => {
485
+ // a fresh conversation keeps the model, unless it is one that may no longer be chosen
486
+ const before = app.session.model;
487
+ app.adoptSession(Session.create(app.cwd, allowedModel(before) ? before : defaultModel().id));
488
+ app.tui.clearScreen();
489
+ app.banner(VERSION, false);
490
+ if (app.session.model !== before)
491
+ app.view.note(`The new conversation uses ${app.session.model}: "${before}" is not one of OAKODE's supported models. ${experimentalHint()}`, "warn");
492
+ },
493
+ },
494
+ {
495
+ name: "sessions",
496
+ desc: "list previous sessions in this directory",
497
+ run: (_a, app) => {
498
+ const list = Session.list(app.cwd, 20);
499
+ if (!list.length)
500
+ return app.view.note("No previous sessions in this directory.");
501
+ app.view.block([
502
+ bold(`Sessions in ${app.cwd}`),
503
+ ...list.map((x, i) => ` ${String(i + 1).padStart(2)}. ${x.id} ${stamp(x.mtime)} ${ago(x.mtime).padEnd(9)} ${(x.messages === undefined ? "large" : x.messages + " msgs").padEnd(9)} ${x.preview}${x.id === app.session.id ? green(" (current)") : ""}`),
504
+ dim("Resume one with /resume <number or id>, or /resume to search and pick."),
505
+ ]);
506
+ },
507
+ },
508
+ {
509
+ name: "resume",
510
+ desc: "search and resume a previous session",
511
+ args: "[number or id]",
512
+ complete: (p, app) => Session.list(app.cwd, 20).filter((x) => x.id.startsWith(p)).map((x) => ({ value: x.id, desc: `${ago(x.mtime)} · ${x.preview}` })),
513
+ run: async (args, app) => {
514
+ const all = Session.list(app.cwd, 60); // same numbering as /sessions
515
+ const others = all.filter((x) => x.id !== app.session.id);
516
+ if (!others.length)
517
+ return app.view.note("No other sessions in this directory.");
518
+ let pick = args ? all[Number(args) - 1] ?? all.find((x) => x.id.startsWith(args)) : undefined;
519
+ if (!pick && args)
520
+ return app.view.note(`No session "${args}". /sessions lists them.`, "warn");
521
+ if (pick?.id === app.session.id)
522
+ return app.view.note("That is the current session.");
523
+ if (!pick) {
524
+ const i = await app.tui.chooseFrom({
525
+ title: `Resume a session · ${app.cwd}`,
526
+ search: true,
527
+ options: (q) => filterSessions(others, q).map((x) => ({ label: x.preview, hint: `${basename(x.cwd)} · ${stamp(x.mtime)} (${ago(x.mtime)}) · ${x.messages === undefined ? "large" : x.messages + " msgs"} · ${x.id}` })),
528
+ hint: "type to search (text, id, date) · ↑/↓ choose · Enter resume · Esc cancel",
529
+ });
530
+ if (i < 0)
531
+ return;
532
+ pick = filterSessions(others, app.tui.menuQuery)[i];
533
+ if (!pick)
534
+ return;
535
+ }
536
+ app.adoptSession(Session.open(pick.file, app.session.model, app.cwd));
537
+ app.tui.clearScreen();
538
+ app.banner(VERSION, true);
539
+ app.resumeNotice();
540
+ },
541
+ },
542
+ {
543
+ name: "retry",
544
+ desc: "send the last request again (may be billed)",
545
+ run: async (_a, app) => {
546
+ await app.turn(null);
547
+ },
548
+ },
549
+ {
550
+ name: "exit",
551
+ desc: "Quit OAKODE",
552
+ run: (_a, app) => app.exit(),
553
+ },
554
+ ];
555
+ }
556
+ /** Choose among several models (at most 100 shown). */
557
+ async function pickModel(app, list) {
558
+ const shown = list.slice(0, 100);
559
+ const i = await app.tui.chooseFrom({
560
+ title: `Select a model${list.length > shown.length ? ` (${list.length} match; narrow with /model <text>)` : ""}`,
561
+ options: () => shown.map((x) => ({ label: x.id + (x.id === app.session.model ? green(" (current)") : ""), hint: modelHint(x) })),
562
+ initial: Math.max(0, shown.findIndex((x) => x.id === app.session.model)),
563
+ });
564
+ return i >= 0 ? shown[i] : undefined;
565
+ }
566
+ const modelHint = (m) => {
567
+ const o = m.oicki;
568
+ const note = curatedNote(m.id) ?? "experimental";
569
+ return `${note ? `${note} · ` : ""}ctx ${o.context_window ? fmtTokens(o.context_window) : "?"}${o.pricing ? ` · $${o.pricing.input_usd_per_million}/${o.pricing.output_usd_per_million}` : ""}${o.capabilities.tools ? "" : " · no tools"}${accessNote(m) ? " · needs permission" : ""}`;
570
+ };
571
+ /** Completion for the text before the cursor, or null if it is not a /command. */
572
+ export function completeInput(before, cmds, app) {
573
+ if (!before.startsWith("/") || before.includes("\n"))
574
+ return null;
575
+ const sp = before.indexOf(" ");
576
+ if (sp < 0) {
577
+ const p = before.slice(1).toLowerCase();
578
+ const items = cmds.filter((c) => c.name.startsWith(p)).map((c) => ({ value: "/" + c.name, desc: (c.args ? c.args + " " : "") + c.desc, space: !!c.args }));
579
+ return { from: 0, items };
580
+ }
581
+ const cmd = cmds.find((c) => c.name === before.slice(1, sp).toLowerCase());
582
+ if (!cmd?.complete)
583
+ return null;
584
+ const partial = before.slice(sp + 1);
585
+ return { from: sp + 1, items: cmd.complete(partial, app) };
586
+ }
587
+ export { red, stripAnsi };