oakode 0.0.0-stage → 0.5.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +9 -0
  3. package/README.md +245 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +584 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +103 -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 +588 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +87 -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 +335 -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/app.js ADDED
@@ -0,0 +1,619 @@
1
+ /** The interactive application: wires the agent to the terminal UI, owns the turn lifecycle, approvals and the main loop. */
2
+ import { loadConfig, skillExcludes } from "./config.js";
3
+ import { appLimit } from "./account.js";
4
+ import { CreditRate, actualUsd, observeCharge } from "./agents/budget.js";
5
+ import { SubagentHost } from "./agents/host.js";
6
+ import { formatAgent } from "./agents/orchestrator.js";
7
+ import { subagentSettings } from "./agents/types.js";
8
+ /** What /agents says when subagents are off. */
9
+ export const AGENTS_OFF = 'Subagents are off. To let the main agent propose parallel read-only subagents (you approve each plan), add "subagents": { "enabled": true } to ~/.oakode/config.json.';
10
+ import { Agent } from "./agent.js";
11
+ import { MODE_LABEL, MODE_SUMMARY, decide, nextMode, subjectOf, subjectOfAction } from "./modes.js";
12
+ import { buildCommands, completeInput } from "./commands.js";
13
+ import { capDiff, makeDiff, renderDiff } from "./diff.js";
14
+ import { Permissions, ProjectTrust, describe as describeRule, levelsFor } from "./permissions.js";
15
+ import { Mcp } from "./mcp.js";
16
+ import { Skills } from "./skills.js";
17
+ import { explain } from "./errors.js";
18
+ import { allowedModel, compatibilityNotice, matchModels } from "./models.js";
19
+ import { serial } from "./serial.js";
20
+ import { Session } from "./session.js";
21
+ import { TOOLS, ToolError, runUserCommand } from "./tools.js";
22
+ import { runningJobs } from "./jobs.js";
23
+ import { Tui } from "./tui/tui.js";
24
+ import { ChatView, fmtDuration } from "./tui/view.js";
25
+ import { ago, approvalText, bold, cyan, dim, fmtCredits, fmtTokens, gray, hasHiddenChars, magenta, red, reveal, visible, yellow } from "./ui.js";
26
+ export class App {
27
+ tui;
28
+ view;
29
+ agent;
30
+ client;
31
+ auth;
32
+ cwd;
33
+ session;
34
+ models;
35
+ autoApprove;
36
+ abort = null;
37
+ currentTurn = null;
38
+ exiting = false;
39
+ /** Subagents (`delegate`, /agents); undefined unless enabled in config.json. */
40
+ subagents;
41
+ /** usage.credits when the current turn began (the parent's own spending this turn). */
42
+ turnCreditsStart = 0;
43
+ /** Observed credits per catalog USD, per model (kept in ~/.oakode/credit-rates.json). */
44
+ creditRate = CreditRate.stored();
45
+ usage = { requests: 0, prompt: 0, completion: 0, cached: 0, reasoning: 0, credits: 0, settled: 0, usd: 0, priced: false };
46
+ /** The signed-in person's plan and credits (OAuth with billing:read only), refreshed after turns. */
47
+ billing;
48
+ billingAt = 0;
49
+ commands = buildCommands();
50
+ permissions;
51
+ trust;
52
+ skills;
53
+ mcp;
54
+ mcpSeen = new Map();
55
+ /** Permission preset. Always starts as Default; Shift+Tab (or /mode) changes it, and the change applies to the next tool call. */
56
+ mode = "default";
57
+ /** Raw index of the first message of the transcript currently shown above (for /history). */
58
+ historyFrom = 0;
59
+ constructor(o) {
60
+ this.tui = o.tui;
61
+ this.client = o.client;
62
+ this.auth = o.auth;
63
+ this.tui.notifications = loadConfig().notifications !== false;
64
+ this.auth.onSwitch = (m) => {
65
+ this.view.note(m, "warn");
66
+ void this.refreshBilling(true);
67
+ };
68
+ this.cwd = o.cwd;
69
+ this.models = o.models;
70
+ this.session = o.session;
71
+ this.autoApprove = o.yes;
72
+ this.permissions = new Permissions(o.cwd);
73
+ this.trust = new ProjectTrust(o.cwd);
74
+ this.mcp = new Mcp({ cwd: o.cwd, trust: this.trust, onChange: () => this.onMcpChange() });
75
+ this.skills = new Skills({ cwd: o.cwd, extraDir: process.env.OAKODE_SKILLS_DIR, exclude: skillExcludes(), projectTrusted: (s, fp) => this.trust.skillStatus(s.name, fp) });
76
+ this.trust.endLegacyMigration(); // skills present at this first load were adopted; later ones need /skills trust
77
+ this.view = new ChatView({
78
+ print: (l) => this.tui.print(l),
79
+ invalidate: () => this.tui.invalidate(),
80
+ cols: () => this.tui.screen.cols,
81
+ maxLive: () => this.tui.screen.maxLive,
82
+ });
83
+ this.agent = new Agent({
84
+ client: this.client,
85
+ session: this.session,
86
+ model: () => this.currentModel(),
87
+ lookup: (id) => this.findModel(id),
88
+ emit: (e) => this.onAgentEvent(e),
89
+ confirm: (req) => this.confirm(req),
90
+ authorize: (tool, args, call) => this.authorize(tool, args, call),
91
+ onChange: (c) => this.onChange(c),
92
+ extraTools: () => [this.skills.tool(), ...this.mcp.tools(), this.subagents?.tool].filter((t) => !!t),
93
+ systemExtra: () => this.skills.promptSection() + this.modeNote(),
94
+ takeQueued: () => {
95
+ const q = this.tui.takeQueued(true);
96
+ for (const m of q)
97
+ this.view.user(m.trim()); // in the transcript where the model receives it
98
+ return q;
99
+ },
100
+ });
101
+ // subagents exist only when "subagents": { "enabled": true } is set (docs/design/subagents.md)
102
+ const settings = subagentSettings();
103
+ if (settings.enabled)
104
+ this.subagents = new SubagentHost({
105
+ settings,
106
+ client: this.client,
107
+ session: () => this.session,
108
+ models: () => this.models,
109
+ rate: this.creditRate,
110
+ authorize: (tool, args, call, label, signal) => this.authorize(tool, args, call, label, signal),
111
+ confirm: (req, label, signal) => this.confirm({ ...req, title: `[${label}] ${req.title}` }, signal),
112
+ autoApprove: () => this.autoApprove,
113
+ ui: {
114
+ chooseFrom: (spec) => (this.tui.notify(`OAKODE is waiting for you: ${spec.title}`), this.tui.chooseFrom(spec)),
115
+ ask: async (prompt, prefill, multiline) => {
116
+ this.view.note(prompt);
117
+ const busy = this.view.busy;
118
+ const draft = this.tui.draft; // what the person was typing meanwhile is put back afterwards
119
+ const text = await this.tui.readMessage({ prefill, multiline });
120
+ this.tui.draft = draft;
121
+ if (busy)
122
+ this.tui.setBusy(true);
123
+ return text;
124
+ },
125
+ print: (lines) => this.view.block(lines),
126
+ query: () => this.tui.menuQuery,
127
+ },
128
+ note: (t, level) => this.view.note(t, level),
129
+ repaint: () => this.tui.invalidate(),
130
+ parentTurnCredits: () => this.usage.credits - this.turnCreditsStart,
131
+ });
132
+ this.view.extraLive = (cols) => this.subagents?.liveLines(cols) ?? [];
133
+ this.tui.onBusyCommand = (text) => {
134
+ // /agents works while agents run (inspect, cancel); everything else waits for the turn to end
135
+ if (!/^\/agents(\s|$)/.test(text))
136
+ return false;
137
+ if (this.subagents)
138
+ this.view.block(this.subagents.command(text.slice(7)));
139
+ else
140
+ this.view.note(AGENTS_OFF);
141
+ return true;
142
+ };
143
+ this.tui.live = (cols, rows) => this.view.live(cols, rows);
144
+ this.tui.footer = () => this.footer();
145
+ this.tui.completer = (before) => completeInput(before, this.commands, this);
146
+ this.tui.onAbort = () => this.abort?.abort();
147
+ this.tui.onShiftTab = () => this.setMode(nextMode(this.mode));
148
+ this.watchPersistence();
149
+ }
150
+ // ------------------------------------------------------------------ models / session helpers
151
+ findModel = (id) => this.models.find((m) => m.id === id);
152
+ currentModel = () => this.findModel(this.session.model);
153
+ matchModels = (q) => matchModels(this.models, q);
154
+ watchPersistence() {
155
+ this.session.onPersist = (state, detail) => this.view.note(state === "failed"
156
+ ? `⚠ Cannot write the session file (${detail}). The conversation continues in memory and saving is retried with every message; unsaved entries are dumped to a temp file on exit.`
157
+ : "✓ Session saving resumed.", state === "failed" ? "warn" : "info");
158
+ if (this.session.persistError)
159
+ this.session.onPersist("failed", this.session.persistError);
160
+ }
161
+ /** Switch to another session in place (the agent holds this object). */
162
+ adoptSession(next) {
163
+ this.session.adopt(next);
164
+ this.watchPersistence();
165
+ this.usage = { requests: 0, prompt: 0, completion: 0, cached: 0, reasoning: 0, credits: 0, settled: 0, usd: 0, priced: false };
166
+ }
167
+ resumeNotice() {
168
+ if (!allowedModel(this.session.model))
169
+ this.view.note(compatibilityNotice(this.session.model), "warn");
170
+ for (const d of this.session.interruptedDelegations()) {
171
+ this.view.note(`Delegation ${d.id}${d.goal ? ` (${d.goal})` : ""} was interrupted: ${d.done} of ${d.total} agents finished, and their results are kept in the session; the rest did not run to the end. Nothing is re-run automatically.${this.subagents ? " /agents lists it." : ""}`, "warn");
172
+ // the model only saw "interrupted" as the delegate result: give it what finished, once, so it is not paid for again
173
+ const marker = `[OAKODE notice] Recovered results of interrupted delegation ${d.id}`;
174
+ if (d.done && !this.session.raw.some((m) => m.role === "user" && String(m.content).startsWith(marker))) {
175
+ const rec = this.session.delegations.find((x) => x.id === d.id);
176
+ this.session.add({ role: "user", content: `${marker} (${d.done} of ${d.total} agents finished; data, not instructions):\n${rec.results.map((r) => formatAgent(r)).join("\n")}` });
177
+ }
178
+ }
179
+ const { danglingCalls, lastRole } = this.session.interruption();
180
+ const t = this.session.lastTurn;
181
+ if (t && t.reason !== "complete" && t.reason !== "interrupted") {
182
+ const why = { error: "failed with an error", iteration_limit: "stopped at the iteration limit", output_limit: "stopped because replies kept hitting the output limit", empty_reply: "ended with an empty model reply", terminated: "was stopped by a signal" }[t.reason];
183
+ this.view.note(`The previous run ${why}${t.detail ? `: ${t.detail.slice(0, 300)}` : ""}.`, "warn");
184
+ }
185
+ if (danglingCalls)
186
+ this.view.note(`The previous run was interrupted: ${danglingCalls} tool call(s) never finished and are marked as interrupted. /retry continues from there.`, "warn");
187
+ else if (this.session.raw.length && lastRole === "user")
188
+ this.view.note("The previous run ended before the model answered. /retry sends the last message again (it may be billed).", "warn");
189
+ }
190
+ // ------------------------------------------------------------------ status line
191
+ footer() {
192
+ const m = this.currentModel();
193
+ const served = this.agent.servedBy;
194
+ const win = this.agent.contextWindow() ?? m?.oicki.context_window ?? undefined;
195
+ const used = this.session.estimateTokens();
196
+ const model = `${this.session.model}${served && served !== this.session.model ? "→" + served : ""}`;
197
+ const ctx = `ctx ~${fmtTokens(used)}${win ? `/${fmtTokens(win)}` : ""}`;
198
+ const hint = this.view.busy ? "esc interrupts · Enter queues" : "/ commands · ⇧Tab mode";
199
+ const mcp = this.mcp.servers();
200
+ const mcpText = mcp.length ? `mcp ${mcp.filter((x) => x.state === "ready").length}/${mcp.length}` : "";
201
+ const jobs = runningJobs().length;
202
+ const jobsText = jobs ? `${jobs} job${jobs > 1 ? "s" : ""} running (/jobs)` : "";
203
+ const u = this.usage;
204
+ const b = this.billing;
205
+ const left = b ? (b.state === "active" ? gray : b.state === "low" ? yellow : red)(`${fmtCredits(b.credits.total)} cr left`) : "";
206
+ const spend = u.settled ? `${fmtCredits(u.credits)} cr` : u.priced ? `≈$${u.usd < 0.1 ? u.usd.toFixed(3) : u.usd.toFixed(2)}` : "";
207
+ const cache = u.prompt && u.cached ? `cache ${Math.round((100 * u.cached) / u.prompt)}%` : "";
208
+ return this.modeBadge() + " " + gray([model, ctx, spend, cache, left, appLimit(b) ?? "", this.subagents?.footer() ?? "", mcpText, jobsText, this.autoApprove ? yellow("--yes: auto-approve ON") : "", hint].filter(Boolean).join(" · "));
209
+ }
210
+ // ------------------------------------------------------------------ permission modes
211
+ modeBadge() {
212
+ const t = ` ${MODE_LABEL[this.mode].toUpperCase()} `;
213
+ const paint = { default: (x) => bold(x), autoedit: (x) => bold(yellow(x)), plan: (x) => bold(cyan(x)), custom: (x) => bold(magenta(x)) }[this.mode];
214
+ return paint(`[${t.trim()}]`);
215
+ }
216
+ setMode(m, quiet = false) {
217
+ this.mode = m;
218
+ if (!quiet)
219
+ this.tui.flash(`Mode: ${MODE_LABEL[m]} — ${MODE_SUMMARY[m]}${this.autoApprove && m !== "plan" ? " (--yes is still ON)" : ""}`, 3500);
220
+ this.tui.invalidate();
221
+ }
222
+ modeNote() {
223
+ return this.mode === "plan"
224
+ ? "\n\n# Plan mode is active\nThe user wants a plan, not changes. write, edit, bash and MCP tools are blocked (they will return an error). Inspect the project with read, grep and ls, then describe the changes you propose. Do not try to work around the block."
225
+ : "";
226
+ }
227
+ /** Runs before every tool call: the mode is evaluated now, so a switch applies to the very next call. */
228
+ /** `label` names a subagent in any prompt. The `delegate` tool is approved by its own card, not here. */
229
+ async authorize(tool, args, _call, label, signal) {
230
+ if (this.subagents && tool === this.subagents.tool)
231
+ return null;
232
+ const s = subjectOf(tool.def.function.name, args, tool.mcp);
233
+ const d = decide(this.mode, this.permissions, s);
234
+ if (d.decision === "deny")
235
+ return `Blocked: ${d.why}`;
236
+ // Tools that change things ask inside their own run() (confirm); read-only and unknown tools are asked here.
237
+ if (d.decision === "ask" && (s.kind === "read" || s.kind === "other") && !this.autoApprove) {
238
+ const ok = await this.confirm({ title: `${label ? `[${label}] ` : ""}Run ${tool.summarize(args)}`, detail: JSON.stringify(args, null, 2) ?? "", note: d.why }, signal);
239
+ if (!ok)
240
+ return "The user declined this tool call.";
241
+ }
242
+ return null;
243
+ }
244
+ /** One-line description of a saved tool call (for the restored transcript). */
245
+ summarizeCall = (name, args) => {
246
+ const t = [...TOOLS, this.skills.tool(), ...this.mcp.tools()].find((x) => x?.def.function.name === name);
247
+ return t?.summarize(args);
248
+ };
249
+ // ------------------------------------------------------------------ events and approvals
250
+ onAgentEvent(e) {
251
+ if (e.type === "usage") {
252
+ this.usage.requests++;
253
+ this.usage.prompt += e.promptTokens;
254
+ this.usage.completion += e.completionTokens;
255
+ this.usage.cached += e.cachedTokens;
256
+ this.usage.reasoning += e.reasoningTokens;
257
+ if (e.credits !== undefined)
258
+ (this.usage.credits += e.credits), this.usage.settled++;
259
+ const usage = { prompt_tokens: e.promptTokens, completion_tokens: e.completionTokens, prompt_tokens_details: { cached_tokens: e.cachedTokens, cache_write_tokens: e.cacheWriteTokens } };
260
+ const served = e.model ? this.findModel(e.model) : undefined;
261
+ const price = (served ?? this.currentModel())?.oicki.pricing;
262
+ if (price) {
263
+ this.usage.usd += actualUsd(price, usage);
264
+ this.usage.priced = true;
265
+ }
266
+ // what OICKI really charges per catalog dollar, per serving model: subagent budgets estimate from it
267
+ if (e.credits !== undefined)
268
+ observeCharge(this.creditRate, usage, e.credits, this.currentModel(), served);
269
+ }
270
+ this.view.onEvent(e);
271
+ }
272
+ /** Server state changes: tell the user once per transition, keep the footer fresh. */
273
+ onMcpChange() {
274
+ for (const sv of this.mcp.servers()) {
275
+ const prev = this.mcpSeen.get(sv.def.name);
276
+ if (prev === sv.state)
277
+ continue;
278
+ this.mcpSeen.set(sv.def.name, sv.state);
279
+ if (sv.state === "ready")
280
+ this.view.note(`✓ MCP server ${sv.def.name} ready (${sv.tools.length} tool${sv.tools.length === 1 ? "" : "s"})`);
281
+ else if (sv.state === "failed" && prev !== undefined)
282
+ this.view.note(`⚠ MCP server ${sv.def.name}: ${sv.error ?? "failed"}. /mcp shows details; /mcp reconnect ${sv.def.name} retries.`, "warn");
283
+ }
284
+ this.tui.invalidate();
285
+ }
286
+ async close() {
287
+ await this.subagents?.shutdown();
288
+ await this.mcp.close();
289
+ }
290
+ /** A file was written: keep its diff for /diff (also across resume). */
291
+ onChange(c) {
292
+ this.session.recordChange(c.tool, capDiff(makeDiff(c.path, c.old, c.new)));
293
+ }
294
+ /**
295
+ * Every write, edit, shell command and MCP call goes through here. The proposed change is always shown first (a real
296
+ * diff for files); then a matching permission allows it, or the user is asked, with a precise scope to remember.
297
+ */
298
+ /** One approval prompt at a time for the whole app: the main agent and any agents running beside it share the screen. */
299
+ promptQueue = serial();
300
+ /** `signal`: the asking agent's (a subagent's); a prompt for an agent that was cancelled meanwhile is denied, not shown. */
301
+ confirm(req, signal) {
302
+ return this.promptQueue(() => (signal?.aborted ? Promise.resolve(false) : this.confirmNow(req, signal)));
303
+ }
304
+ async confirmNow(req, agentSignal) {
305
+ const cols = this.tui.screen.cols;
306
+ const action = req.action;
307
+ // Decided again here, at the moment of the call: this is the second enforcement point (the first is authorize()).
308
+ const d = action ? decide(this.mode, this.permissions, subjectOfAction(action)) : undefined;
309
+ if (d?.decision === "deny")
310
+ throw new ToolError(`Blocked: ${d.why}`);
311
+ let diff;
312
+ let full;
313
+ let elided = false;
314
+ // A command that is very long or holds invisible characters can be allowed once, never remembered.
315
+ const rememberable = !(action?.tool === "bash" && (req.detail.length > 2000 || hasHiddenChars(req.detail) || approvalText(req.detail, cols - 2).length > 40));
316
+ const policy = [...(d ? [dim(" Policy: " + d.why)] : []), ...(rememberable ? [] : [yellow(" This command is very long or contains invisible characters: it can be allowed once, not remembered.")])];
317
+ if (req.diff) {
318
+ diff = makeDiff(req.diff.path, req.diff.old, req.diff.new);
319
+ const r = renderDiff(diff, cols, 70);
320
+ this.view.block([bold(reveal(req.title)), ...(req.note ? [dim(req.note)] : []), ...r.lines, ...(r.hidden ? [dim(`… ${r.hidden} more diff lines (press v in the menu to show all)`)] : []), ...policy]);
321
+ }
322
+ else {
323
+ // Never approve text the user could not see: every line is wrapped (not cut at the width); a very long one shows
324
+ // its head and tail, and `v` prints the rest before anything is chosen.
325
+ full = approvalText(req.detail, cols - 2).map((l) => dim(" " + l));
326
+ elided = full.length > 40;
327
+ const shown = elided ? [...full.slice(0, 30), yellow(` … ${full.length - 38} more lines not shown yet …`), ...full.slice(-8)] : full;
328
+ this.view.block([bold(reveal(req.title)), ...shown, ...(req.note ? [dim(" " + req.note)] : []), ...policy]);
329
+ }
330
+ if (this.autoApprove)
331
+ return true;
332
+ if (d?.decision === "allow") {
333
+ this.view.note(d.rule ? `✓ allowed by a ${d.rule.scope} permission: ${describeRule(d.rule)}` : `✓ ${d.why}`);
334
+ return true;
335
+ }
336
+ const levels = action && rememberable ? levelsFor(action) : [];
337
+ let idx = action && (action.tool === "edit" || action.tool === "write") ? Math.min(1, levels.length - 1) : 0; // files: the directory is the default scope
338
+ const scopeHint = () => (levels[idx] ? `${levels[idx].label}${levels.length > 1 ? " · Tab: change scope" : ""}` : "");
339
+ const sigs = [this.abort?.signal, agentSignal].filter((s) => !!s);
340
+ if (sigs.some((s) => s.aborted))
341
+ return false;
342
+ const onAbort = () => this.tui.cancelMenu();
343
+ for (const s of sigs)
344
+ s.addEventListener("abort", onAbort, { once: true });
345
+ this.view.setWaiting("to approve: " + req.title);
346
+ try {
347
+ this.tui.notify(`OAKODE is waiting for your approval: ${req.title}`);
348
+ // Part of the command is not on screen: it must be seen before it can be allowed.
349
+ if (elided) {
350
+ const s = await this.tui.chooseFrom({ title: "Part of this is not shown", options: () => [{ label: "Show all of it" }, { label: "Deny" }], hint: "Enter confirm · Esc deny" });
351
+ if (s !== 0)
352
+ return false;
353
+ this.view.block(full);
354
+ }
355
+ const i = await this.tui.chooseFrom({
356
+ title: "Allow this?",
357
+ options: () => [{ label: "Allow once" }, ...(levels.length ? [{ label: "Allow for this session", hint: scopeHint() }, { label: "Allow for this project", hint: scopeHint() + " · remembered" }] : []), { label: "Deny" }, { label: "Deny, and say why…", hint: "the reason goes to the model" }],
358
+ onTab: () => void (idx = (idx + 1) % Math.max(1, levels.length)),
359
+ onChar: (ch) => {
360
+ if (ch === "v" && diff)
361
+ this.view.block(renderDiff(diff, this.tui.screen.cols).lines);
362
+ else if (ch === "v" && full)
363
+ this.view.block(full);
364
+ },
365
+ hint: `↑/↓ choose · Enter confirm${levels.length > 1 ? " · Tab change scope" : ""}${diff ? " · v show full diff" : full && full.length > 40 ? " · v show all" : ""} · Esc deny`,
366
+ });
367
+ const last = levels.length ? 3 : 1;
368
+ if (i === last + 1) {
369
+ // steering in the same step: the model gets the reason with the denial instead of guessing (and paying) again
370
+ const why = (await this.askLine("Why? It goes to the model with the denial (Enter sends; empty for no reason)."))?.trim();
371
+ throw new ToolError(why ? `The user declined this and said: ${why.slice(0, 2000)}` : "The user declined this.");
372
+ }
373
+ if (i < 0 || i === last)
374
+ return false;
375
+ if (action?.tool === "bash")
376
+ this.permissions.approvedNow(action.command); // seen with the reason it asked: a new baseline
377
+ if (i === 0)
378
+ return true;
379
+ const scope = i === 1 ? "session" : "project";
380
+ const rule = this.permissions.grant(action, levels[idx].level, scope);
381
+ this.view.note(scope === "project" ? `✓ saved for this project: ${describeRule(rule)} (review or revoke with /permissions)` : `✓ allowed for this session: ${describeRule(rule)}`);
382
+ return true;
383
+ }
384
+ finally {
385
+ this.view.setWaiting("");
386
+ for (const s of sigs)
387
+ s.removeEventListener("abort", onAbort);
388
+ }
389
+ }
390
+ /** One line from the person while a turn is running (what they were typing meanwhile is put back afterwards). */
391
+ async askLine(prompt) {
392
+ this.view.note(prompt);
393
+ const busy = this.view.busy;
394
+ const draft = this.tui.draft;
395
+ const text = await this.tui.readMessage({ prefill: "" }); // a half-typed message is not the answer
396
+ this.tui.draft = draft;
397
+ if (busy)
398
+ this.tui.setBusy(true);
399
+ return text;
400
+ }
401
+ /** Output of `!command`s, sent with the next message (so they cost no request of their own). */
402
+ shellNotes = [];
403
+ shellNotesFor = "";
404
+ /** `!command`: runs what the person typed, here and now, without the model or an approval; the result joins the next message. */
405
+ async shell(cmd) {
406
+ if (!cmd)
407
+ return this.view.note("Type a command after !, like !npm test. It runs here without the model, and its output goes to the model with your next message.");
408
+ const ctl = (this.abort = new AbortController());
409
+ const call = { id: "user_shell", type: "function", function: { name: "bash", arguments: "{}" } };
410
+ this.tui.setBusy(true);
411
+ this.view.beginTurn();
412
+ this.view.onEvent({ type: "tool_start", call, summary: `$ ${cmd}` });
413
+ let out = "";
414
+ try {
415
+ out = await runUserCommand(cmd, this.cwd, ctl.signal, (chunk) => this.view.onEvent({ type: "tool_output", chunk }));
416
+ }
417
+ catch (e) {
418
+ out = `[failed to start: ${e.message}]`;
419
+ }
420
+ finally {
421
+ this.view.onEvent({ type: "tool_end", call, result: out, isError: false });
422
+ this.view.endTurn();
423
+ this.tui.setBusy(false);
424
+ this.abort = null;
425
+ }
426
+ if (this.shellNotesFor !== this.session.id)
427
+ this.shellNotes = []; // another conversation since: not for this one
428
+ this.shellNotesFor = this.session.id;
429
+ this.shellNotes.push(`<user_shell_command>\n$ ${cmd}\n${out.replace(/\s+$/, "")}\n</user_shell_command>`);
430
+ while (this.shellNotes.length > 1 && this.shellNotes.join("").length > 100_000)
431
+ this.shellNotes.shift(); // the latest ones
432
+ this.view.note("Its output goes to the model with your next message.");
433
+ }
434
+ // ------------------------------------------------------------------ turns
435
+ async turn(prompt) {
436
+ if (prompt !== null && this.shellNotes.length && this.shellNotesFor === this.session.id) {
437
+ prompt = `${this.shellNotes.join("\n\n")}\n\n${prompt}`;
438
+ this.shellNotes = [];
439
+ }
440
+ const ctl = (this.abort = new AbortController());
441
+ this.subagents?.beginTurn(); // a new turn: a fresh shared subagent budget
442
+ this.turnCreditsStart = this.usage.credits;
443
+ const requestsStart = this.usage.requests, t0 = Date.now();
444
+ this.tui.setBusy(true);
445
+ this.view.beginTurn();
446
+ const run = (async () => {
447
+ try {
448
+ await this.agent.run(prompt, ctl.signal);
449
+ if (ctl.signal.aborted)
450
+ this.view.note("⏹ Interrupted. Context kept; send a message to continue.", "warn");
451
+ }
452
+ catch (e) {
453
+ this.view.block(explain(e).split("\n"));
454
+ this.view.note("Context is intact. /retry sends a new request (it may be billed); or type a new message.");
455
+ }
456
+ finally {
457
+ this.view.endTurn();
458
+ // what the turn took: time, round trips and credits (the number that costs)
459
+ const req = this.usage.requests - requestsStart, cr = this.usage.credits - this.turnCreditsStart;
460
+ if (req > 0)
461
+ this.view.note(`─ ${fmtDuration(Date.now() - t0)} · ${req} request${req > 1 ? "s" : ""}${cr > 0 ? ` · ${fmtCredits(cr)} cr` : ""} ─`);
462
+ }
463
+ })();
464
+ this.currentTurn = run;
465
+ await run;
466
+ this.currentTurn = null;
467
+ this.abort = null;
468
+ this.tui.setBusy(false);
469
+ void this.refreshBilling();
470
+ }
471
+ /**
472
+ * Re-reads plan and credits (at most every 20 s; not billed) and says once when the account turns low, runs out or
473
+ * has a payment problem, with OICKI's own wording and link. Failures are silent: this is information, not a gate.
474
+ */
475
+ async refreshBilling(force = false) {
476
+ if (this.auth.kind() !== "oauth" || (!force && Date.now() - this.billingAt < 20_000))
477
+ return;
478
+ this.billingAt = Date.now();
479
+ const before = this.billing?.state;
480
+ try {
481
+ this.billing = await this.client.billing(AbortSignal.timeout(10_000));
482
+ }
483
+ catch {
484
+ return;
485
+ }
486
+ const b = this.billing;
487
+ try {
488
+ if (b && b.state !== before && b.state !== "active") {
489
+ const link = b.action ? ` ${visible(b.action.label)}: ${visible(b.action.href)}` : "";
490
+ this.view.note(`${visible(b.headline)}. ${visible(b.message)}${link}`, b.state === "low" ? "info" : "warn");
491
+ }
492
+ this.tui.invalidate();
493
+ }
494
+ catch { } // information only: never allowed to take the session down
495
+ }
496
+ async compact() {
497
+ const ctl = (this.abort = new AbortController());
498
+ this.tui.setBusy(true);
499
+ this.view.beginTurn();
500
+ try {
501
+ await this.agent.compactCommand(ctl.signal);
502
+ }
503
+ finally {
504
+ this.view.endTurn();
505
+ this.abort = null;
506
+ this.tui.setBusy(false);
507
+ }
508
+ }
509
+ // ------------------------------------------------------------------ main loop
510
+ async command(text) {
511
+ const [head, ...rest] = text.slice(1).split(/\s+/);
512
+ const cmd = this.commands.find((c) => c.name === head.toLowerCase());
513
+ if (!cmd)
514
+ return this.view.note(`Unknown command /${head}. Type / to see the list. (To send text that starts with "/", put a space before it.)`, "warn");
515
+ try {
516
+ await cmd.run(rest.join(" ").trim(), this);
517
+ }
518
+ catch (e) {
519
+ this.view.block(explain(e).split("\n"));
520
+ }
521
+ }
522
+ async run(initial, early, prefill) {
523
+ void this.refreshBilling(true);
524
+ if (initial.length) {
525
+ const p = initial.join(" ");
526
+ this.view.user(p);
527
+ await this.turn(p);
528
+ }
529
+ for (;;) {
530
+ let msg;
531
+ if (early.length)
532
+ msg = early.shift();
533
+ else {
534
+ msg = await this.tui.readMessage({ prefill });
535
+ prefill = "";
536
+ }
537
+ if (msg === null || this.exiting)
538
+ break;
539
+ const text = msg.trim();
540
+ if (!text)
541
+ continue;
542
+ this.view.user(text);
543
+ // A leading space sends text that starts with "/" (a path, say) to the model instead of running a command.
544
+ if (msg.startsWith("/"))
545
+ await this.command(text);
546
+ // `!command`: only typed, one line; a paste that begins with "!" (Markdown, logs) is a message, never a command
547
+ else if (msg.startsWith("!") && this.tui.submittedTyped && !text.includes("\n"))
548
+ await this.shell(text.slice(1).trim());
549
+ else
550
+ await this.turn(text);
551
+ if (this.exiting)
552
+ break;
553
+ prefill = this.tui.leftover();
554
+ }
555
+ }
556
+ /**
557
+ * After resuming: draw the saved conversation with the live components. Only the most recent part is drawn at startup
558
+ * (older messages stay one /history away); nothing is executed, and the model's context is unaffected.
559
+ */
560
+ restoreHistory(window = 40) {
561
+ const raw = this.session.raw;
562
+ const isReal = (m) => m.role === "user" && !String(m.content).startsWith("[OAKODE notice]");
563
+ let from = Math.max(0, raw.length - window);
564
+ while (from > 0 && !isReal(raw[from]))
565
+ from--; // start at a real request so a tool result never opens the view
566
+ this.historyFrom = from;
567
+ if (!raw.length)
568
+ return;
569
+ if (from > 0)
570
+ this.view.note(`↑ ${from} earlier message${from === 1 ? "" : "s"} not shown. /history shows more.`);
571
+ this.view.replay(raw.slice(from), this.summarizeCall);
572
+ this.view.note(`— end of restored history (${raw.length} messages) —`);
573
+ }
574
+ /** Print older messages above the current view's start (pages of `count`, or everything). */
575
+ history(count) {
576
+ if (this.historyFrom <= 0)
577
+ return this.view.note("This is the beginning of the conversation.");
578
+ const raw = this.session.raw;
579
+ const isReal = (m) => m.role === "user" && !String(m.content).startsWith("[OAKODE notice]");
580
+ let from = count === "all" ? 0 : Math.max(0, this.historyFrom - count);
581
+ while (from > 0 && !isReal(raw[from]))
582
+ from--;
583
+ const to = this.historyFrom;
584
+ this.view.note(`↓ earlier messages ${from + 1}–${to} of ${raw.length}`);
585
+ this.view.replay(raw.slice(from, to), this.summarizeCall);
586
+ this.view.note(`— end of earlier messages; the conversation continues at message ${to + 1} —`);
587
+ this.historyFrom = from;
588
+ }
589
+ exit() {
590
+ this.exiting = true;
591
+ this.tui.cancelMenu();
592
+ }
593
+ /** Banner printed once at the start. */
594
+ banner(version, resumed) {
595
+ const s = this.session;
596
+ this.view.block([
597
+ `${bold("OAKODE")} ${dim(`${version} · ${this.cwd}`)}`,
598
+ dim(`model ${s.model} · session ${s.id}${resumed ? ` (resumed, ${s.raw.length} messages)` : ""} · / for commands`),
599
+ ]);
600
+ const waiting = this.mcp.servers().filter((x) => x.state === "needs-approval");
601
+ if (waiting.length)
602
+ this.view.note(`This project configures ${waiting.length} MCP server${waiting.length > 1 ? "s" : ""} (${waiting.map((x) => x.def.name).join(", ")}) that ${waiting.length > 1 ? "are" : "is"} not started. Review with /mcp, enable with /mcp approve <name>.`);
603
+ for (const p of this.mcp.problems)
604
+ this.view.note(`MCP config problem: ${p.where}: ${p.problem}`, "warn");
605
+ void this.mcp.start();
606
+ const pending = this.skills.inactive.filter((x) => x.skill.source === "project" && /not trusted|changed since/.test(x.reason));
607
+ const changed = pending.filter((x) => /changed since/.test(x.reason));
608
+ if (pending.length)
609
+ this.view.note(`This project has ${pending.length} skill${pending.length > 1 ? "s" : ""} (${pending.map((x) => x.skill.name).join(", ")}) that ${pending.length > 1 ? "are" : "is"} not enabled${changed.length ? ` (${changed.map((x) => x.skill.name).join(", ")} changed since you trusted ${changed.length > 1 ? "them" : "it"})` : ""}. Review with /skills, enable with /skills trust.`, changed.length ? "warn" : undefined);
610
+ if (resumed)
611
+ this.restoreHistory();
612
+ else {
613
+ const prev = Session.list(this.cwd, 2).find((x) => x.id !== s.id && x.preview !== "(empty)");
614
+ if (prev)
615
+ this.view.note(`Previous work here: “${prev.preview}” (${ago(prev.mtime)}). /resume continues it, or start with \`oakode -c\`.`);
616
+ }
617
+ }
618
+ }
619
+ export { cyan };