@vincemakes/kiso-code 0.45.3 → 0.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,8 @@ kiso
32
32
  Named model profiles live in `~/.kiso/config.json` (the config stores
33
33
  the NAME of the env var holding each key, never the key itself); switch
34
34
  in-session with `/model`. Approval tiers: `--mode
35
- manual|default|accept-edits|plan|bypass` or `/mode` in-session.
35
+ default|accept-edits|plan|full-access` or `/mode` in-session; `--dont-ask`
36
+ (or `/dont-ask`) never stops for a person.
36
37
 
37
38
  ## The commands
38
39
 
package/dist/builtin.d.ts CHANGED
@@ -6,10 +6,11 @@ import type { KisoExtension } from "@vincemakes/kiso-runtime";
6
6
  * that could only decline them. A LIVE gate, not a load-time one: the
7
7
  * registry reads an extension's tools on every request (registerLive) and
8
8
  * the run recomposes the tool table, snippet and guidelines included, so
9
- * entering dontAsk takes ask_user away from the next run and leaving it
10
- * brings ask_user back. In dontAsk the table is byte-identical to the pipe
11
- * path's; each switch in or out costs one prompt-cache break. The decline
12
- * path (trust-ui) stays for a turn already in flight when the mode changes.
9
+ * turning the switch on takes ask_user away from the next run and turning
10
+ * it off brings ask_user back — in every tier. With the switch on the
11
+ * table is byte-identical to the pipe path's; each flip costs one
12
+ * prompt-cache break. The decline path (trust-ui) stays for a turn already
13
+ * in flight when the switch flips.
13
14
  */
14
15
  export declare function offInDontAsk(ext: KisoExtension): KisoExtension;
15
16
  export declare function builtInLayer(user: readonly KisoExtension[], project: readonly KisoExtension[],
package/dist/builtin.js CHANGED
@@ -24,24 +24,26 @@ import createMcp from "@vincemakes/kiso-mcp-ext";
24
24
  import createSkills from "@vincemakes/kiso-skills-ext";
25
25
  import createSubagent from "@vincemakes/kiso-subagent-ext";
26
26
  import createAsk, {} from "@vincemakes/kiso-ask-ext";
27
- import { getMode } from "./mode.js";
27
+ import { getDontAsk } from "./mode.js";
28
+ import { tasksFor } from "./state.js";
28
29
  /**
29
30
  * 0.40.0 (the owner's dogfood): dontAsk never asks, so its tool table never
30
31
  * offers ask_user — a model once put four questions to a dontAsk session
31
32
  * that could only decline them. A LIVE gate, not a load-time one: the
32
33
  * registry reads an extension's tools on every request (registerLive) and
33
34
  * the run recomposes the tool table, snippet and guidelines included, so
34
- * entering dontAsk takes ask_user away from the next run and leaving it
35
- * brings ask_user back. In dontAsk the table is byte-identical to the pipe
36
- * path's; each switch in or out costs one prompt-cache break. The decline
37
- * path (trust-ui) stays for a turn already in flight when the mode changes.
35
+ * turning the switch on takes ask_user away from the next run and turning
36
+ * it off brings ask_user back — in every tier. With the switch on the
37
+ * table is byte-identical to the pipe path's; each flip costs one
38
+ * prompt-cache break. The decline path (trust-ui) stays for a turn already
39
+ * in flight when the switch flips.
38
40
  */
39
41
  export function offInDontAsk(ext) {
40
42
  const tools = ext.tools ?? [];
41
43
  return {
42
44
  ...ext,
43
45
  get tools() {
44
- return getMode() === "dontAsk" ? [] : tools;
46
+ return getDontAsk() ? [] : tools;
45
47
  },
46
48
  };
47
49
  }
@@ -56,7 +58,10 @@ ask,
56
58
  * constructed, so the names have to arrive here — after construction is
57
59
  * too late. Only the config knows them; nothing else can. */
58
60
  secretEnvNames = []) {
59
- const all = await Promise.all([createMcp({ secretEnvNames }), createSkills(), createSubagent(), ...(ask === undefined ? [] : [createAsk(ask).then(offInDontAsk)])]);
61
+ // ADR-0058 3d: the session's task manager makes `delegate` offer
62
+ // background children (explorer/reviewer; the cap and the turn budget
63
+ // are the extension's defaults)
64
+ const all = await Promise.all([createMcp({ secretEnvNames }), createSkills(), createSubagent({ tasks: tasksFor }), ...(ask === undefined ? [] : [createAsk(ask).then(offInDontAsk)])]);
60
65
  const shadowed = all.filter((b) => user.some((u) => u.name === b.name));
61
66
  for (const s of shadowed) {
62
67
  console.error(`[extensions] user extension "${s.name}" shadows the built-in — the built-in is not loaded`);
package/dist/chat.js CHANGED
@@ -5,10 +5,11 @@
5
5
  * estimates. All bodies moved verbatim from index.ts.
6
6
  */
7
7
  import { readFileSync, statSync } from "node:fs";
8
- import { escapeTerminal, cacheHitPct, decodeRate, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, kUnit, } from "@vincemakes/kiso-tui";
8
+ import { escapeTerminal, cacheHitPct, decodeRate, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, kUnit, namedPickView, } from "@vincemakes/kiso-tui";
9
9
  import { askView, coldResumeLine, coldResumeView, deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath, isProtectedPath, protectedIdentity, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
- import { queuedSwitchLines } from "./state.js";
11
+ import { liveTasks, mergedConfig, queuedSwitchLines, seenUnknownTasks, setExitTasks, tasksFor } from "./state.js";
12
+ import { taskCounts, taskNoticeRow } from "./task-notice.js";
12
13
  import { echoText } from "@vincemakes/kiso-tui-cells/render";
13
14
  import { canonicalizeUsage, RunClosedError } from "@vincemakes/kiso-runtime";
14
15
  import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
@@ -21,7 +22,7 @@ import { learnedWindowFor } from "./learned-windows.js";
21
22
  import { lookupContextWindow, lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
22
23
  import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertains } from "./trust-ui.js";
23
24
  import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
24
- import { OFFERED_MODES, getMode, setMode } from "./mode.js";
25
+ import { MODE_LABEL, OFFERED_MODES, getDontAsk, getMode, modeDisplay, setMode } from "./mode.js";
25
26
  /** B area: default context window for the ~ctx estimate (config overridable).
26
27
  * CW-1 batch 2: 128,000, down from 200,000 — the figure a model nobody
27
28
  * states a window for is assumed to hold (the reference implementation's
@@ -950,6 +951,11 @@ onInputLanded) {
950
951
  // the log. Since 0.44.0 (the verify offer retired) the CLI
951
952
  // starts no such run itself; the branch keeps the rule for
952
953
  // any run that carries one, as replay.ts does for old logs.
954
+ // ADR-0058: a task notice is a row, never the person's chip
955
+ if (ev.via?.kind === "tasks") {
956
+ body.notice(taskNoticeRow(ev.via.items));
957
+ break;
958
+ }
953
959
  if (ev.source === "system") {
954
960
  // R2 (law 1.3): the ◆ said nothing the words did not. What
955
961
  // makes this row honest is that it NAMES itself machinery.
@@ -959,7 +965,7 @@ onInputLanded) {
959
965
  }
960
966
  // 0.40.0: a skill turn's chip is the line the person TYPED — the
961
967
  // SKILL.md body is what the model read, not what they said.
962
- if (ev.via !== undefined) {
968
+ if (ev.via?.kind === "skill") {
963
969
  body.userLine(ev.via.line);
964
970
  break;
965
971
  }
@@ -1072,7 +1078,7 @@ onInputLanded) {
1072
1078
  // denied HERE, at the one place kiso asks, so every allow the
1073
1079
  // chain gave still stands (a tier deny would outrank them). The
1074
1080
  // reason is the tool result: the model sees why and goes on.
1075
- if (getMode() === "dontAsk") {
1081
+ if (getDontAsk()) {
1076
1082
  body.notice(`[dontAsk] ${escapeTerminal(name)} would ask — denied`);
1077
1083
  await session.approve(decisionId, false, `dontAsk: ${name} needs a human's approval, and this session never asks — denied`);
1078
1084
  break;
@@ -1267,9 +1273,39 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1267
1273
  const exitAtEmptyPrompt = () => {
1268
1274
  if (pendingAsk !== null || input.line() !== "")
1269
1275
  return;
1270
- cancelled = true;
1271
- console.log("\n[exit requested]");
1272
- input.close();
1276
+ void confirmExit().then((leave) => {
1277
+ if (!leave) {
1278
+ eotSeen = false; // stayed: a later run's end must not ask again
1279
+ input.prompt();
1280
+ return;
1281
+ }
1282
+ cancelled = true;
1283
+ console.log("\n[exit requested]");
1284
+ input.close();
1285
+ });
1286
+ };
1287
+ /** ADR-0058 (3e): a clean exit with live tasks asks first — on a dock
1288
+ * only; a pipe or a task-file run stops them all, as before. The
1289
+ * question says beforehand what each answer does. True: exit. */
1290
+ const confirmExit = async () => {
1291
+ const live = liveTasks();
1292
+ const total = live.durable + live.moved;
1293
+ if (total === 0 || !dock.active)
1294
+ return true;
1295
+ const n = (k, what) => `${k} ${what}${k === 1 ? "" : "s"}`;
1296
+ const options = [{ label: "stop all and exit" }];
1297
+ if (live.durable > 0)
1298
+ options.push({ label: live.moved > 0 ? `leave ${n(live.durable, "background task")} running; ${n(live.moved, "moved command")} will stop` : `leave ${n(live.durable, "background task")} running` });
1299
+ options.push({ label: "stay" });
1300
+ const picked = await new Promise((resolve) => {
1301
+ input.panelAsk(namedPickView("exit", { header: `${n(total, "task")} ${total === 1 ? "is" : "are"} running`, options }, `▸ ${modeDisplay()}`, "stop the running tasks and exit? "), (v) => resolve(v.action === "picked" ? v.result : null));
1302
+ });
1303
+ paintIdle();
1304
+ const label = picked !== null && "index" in picked ? options[picked.index]?.label : undefined;
1305
+ if (label === undefined || label === "stay")
1306
+ return false;
1307
+ setExitTasks(label.startsWith("leave") ? "leave" : "stop");
1308
+ return true;
1273
1309
  };
1274
1310
  const turn = (text, via) => new Promise((resolve, reject) => {
1275
1311
  queued = Math.max(0, queued - 1); // a queued turn starts
@@ -1284,7 +1320,8 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1284
1320
  // SKILL.md body, and a body that mentions `diagram.png` must not
1285
1321
  // attach a file from the workspace the person never pointed at.
1286
1322
  const content = via !== undefined ? text : attachImages(text, input.attachments?.(), protectedFiles());
1287
- const run = via !== undefined ? session.run(content, { via }) : session.run(content);
1323
+ // ADR-0058: a task notice is the runtime's input — source "system"
1324
+ const run = via !== undefined ? session.run(content, { via, ...(via.kind === "tasks" ? { source: "system" } : {}) }) : session.run(content);
1288
1325
  currentRun = run;
1289
1326
  turnNo += 1;
1290
1327
  const myTurn = turnNo;
@@ -1362,9 +1399,15 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1362
1399
  pendingAsk?.(); // a startup/trust question — cancel it
1363
1400
  }
1364
1401
  else if (input.line() === "") {
1365
- cancelled = true;
1366
- console.log("\n[exit requested]");
1367
- input.close();
1402
+ void confirmExit().then((leave) => {
1403
+ if (!leave) {
1404
+ input.prompt();
1405
+ return;
1406
+ }
1407
+ cancelled = true;
1408
+ console.log("\n[exit requested]");
1409
+ input.close();
1410
+ });
1368
1411
  }
1369
1412
  else {
1370
1413
  input.clearLine(); // v2c: Ctrl+C on a non-empty line clears it
@@ -1483,6 +1526,29 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1483
1526
  // nullish, so the guard is `> 0` rather than `??` (the same shape the
1484
1527
  // compositor's own width call uses).
1485
1528
  const rowWidth = () => (process.stdout.columns > 0 ? process.stdout.columns : 80);
1529
+ // ADR-0058 (3e): the session's tasks on the rows. Each read walks the
1530
+ // journals, so the running row (repainted many times a second) reads at
1531
+ // most once a second; the idle row (repainted rarely) always reads —
1532
+ // a task promoted or moved to the background makes no transition to
1533
+ // hear, and the row the turn ends on must already count it
1534
+ let taskRow = null;
1535
+ const taskCountsNow = (fresh = false) => {
1536
+ const manager = tasksFor(session.id);
1537
+ if (manager === undefined)
1538
+ return undefined;
1539
+ if (fresh || taskRow === null || Date.now() - taskRow.at > 1_000 || taskRow.seen !== seenUnknownTasks.size) {
1540
+ let list = [];
1541
+ try {
1542
+ list = manager.list();
1543
+ }
1544
+ catch {
1545
+ // a corrupt journal is reported where it is read on purpose
1546
+ }
1547
+ taskRow = { at: Date.now(), seen: seenUnknownTasks.size, counts: taskCounts(list, seenUnknownTasks) };
1548
+ }
1549
+ return taskRow.counts;
1550
+ };
1551
+ const detachableNow = () => (tasksFor(session.id)?.detachable().length ?? 0) > 0;
1486
1552
  // KC2 §5: the STATE (the glyph, the run's start, the usage, the dock)
1487
1553
  // stays here; the ROW's text is the tui's status formatter.
1488
1554
  const paintRunning = () => {
@@ -1491,7 +1557,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1491
1557
  // composed blind it would be cut from the END — the context figure,
1492
1558
  // a fact, going before the gesture hints.
1493
1559
  if (dock.active)
1494
- dock.setStatus(runningStatus(runGlyph, runStart, runUsage.out, displayCtxRatio(session), lastTokPerSec, rowWidth(), retryOnRow()));
1560
+ dock.setStatus(runningStatus(runGlyph, runStart, runUsage.out, displayCtxRatio(session), lastTokPerSec, rowWidth(), retryOnRow(), taskCountsNow(), detachableNow()));
1495
1561
  };
1496
1562
  // W19: under plan the idle row makes the posture unmistakable — the W4
1497
1563
  // parentheses idiom names the read-only constraint. The tier is the
@@ -1502,7 +1568,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1502
1568
  // TUI2-R1 (E): the meter rides the idle row — both fields omitted
1503
1569
  // when unknown, so a session that has not called the model paints
1504
1570
  // exactly the pre-round row.
1505
- dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session), {
1571
+ dock.setStatus(idleStatus(modeDisplay(), statusModelLabel(session), displayCtxRatio(session), {
1506
1572
  cacheHitPct: cacheHitPct(runUsage),
1507
1573
  costUsd: spentUsd,
1508
1574
  tokPerSec: lastTokPerSec,
@@ -1510,7 +1576,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1510
1576
  // DF-0330-F1: the row's budget. Without it the row is composed
1511
1577
  // blind and invariant ① cuts whatever sits last — which is how
1512
1578
  // a measured rate went missing at 100 columns.
1513
- rowWidth(), !floorOn));
1579
+ rowWidth(), !floorOn, taskCountsNow(true)));
1514
1580
  };
1515
1581
  // TUI2-R2 ⑥ — the BOOT status line. The row is the product's one
1516
1582
  // persistent claim about itself (the tier, how to change it, the model,
@@ -1555,7 +1621,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1555
1621
  spentUsd = (spentUsd ?? 0) + usd;
1556
1622
  };
1557
1623
  const queueTurn = (line, via) => {
1558
- const slot = { line: via?.line ?? line, content: line, ...(via !== undefined ? { via } : {}), cancelled: false };
1624
+ const slot = { line: via?.kind === "skill" ? via.line : via?.kind === "tasks" ? taskNoticeRow(via.items) : line, content: line, ...(via !== undefined ? { via } : {}), cancelled: false };
1559
1625
  pendingTurns.push(slot);
1560
1626
  queued += 1;
1561
1627
  chainRef.current = chainRef.current.then(async () => {
@@ -1619,13 +1685,41 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1619
1685
  return false; // the run is ending: this is the next turn
1620
1686
  throw err;
1621
1687
  }
1622
- steering.push({ line: via?.line ?? line, content: line, ...(via !== undefined ? { via } : {}) });
1688
+ steering.push({ line: (via?.kind === "skill" ? via.line : undefined) ?? line, content: line, ...(via !== undefined ? { via } : {}) });
1623
1689
  return true;
1624
1690
  };
1625
1691
  const submitTurn = (line, via) => {
1626
1692
  if (!steer(line, via))
1627
1693
  queueTurn(line, via);
1628
1694
  };
1695
+ // ADR-0058 (3c): the session's task transitions reach the model — into a
1696
+ // live run at its next safe point, or, idle, as one wake turn through
1697
+ // the same chain a person's turn takes (taskWake: false keeps it for
1698
+ // the next message). Released when this chat ends.
1699
+ const taskManager = tasksFor(session.id);
1700
+ // KISO_AUTO_DETACH_MS — the test rigs' knob (like KISO_STREAM_IDLE_MS):
1701
+ // how old a command must be before a steer detaches it (3e D1). The
1702
+ // rigs that hold a steer pending behind a long command raise it.
1703
+ const autoDetachFromEnv = Number.parseInt(process.env.KISO_AUTO_DETACH_MS ?? "", 10);
1704
+ const stopDelivery = taskManager === undefined
1705
+ ? () => { }
1706
+ : session.useTasks(taskManager, {
1707
+ wake: mergedConfig.taskWake !== false,
1708
+ onWake: (w) => queueTurn(w.content, w.via),
1709
+ ...(Number.isFinite(autoDetachFromEnv) && autoDetachFromEnv >= 0 ? { autoDetachMinAgeMs: autoDetachFromEnv } : {}),
1710
+ });
1711
+ // 3e: a task's change repaints the row it is counted on
1712
+ const stopHearing = taskManager?.subscribe(() => {
1713
+ taskRow = null;
1714
+ if (currentRun !== null)
1715
+ paintRunning();
1716
+ else
1717
+ paintIdle();
1718
+ }) ?? (() => { });
1719
+ const stopTasks = () => {
1720
+ stopHearing();
1721
+ stopDelivery();
1722
+ };
1629
1723
  /** The mirror of the last `n` steers the run handed back; clears it. */
1630
1724
  const handed = (n) => {
1631
1725
  const out = n > 0 ? steering.slice(-n) : [];
@@ -1677,6 +1771,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1677
1771
  session,
1678
1772
  input,
1679
1773
  chainRef,
1774
+ confirmExit,
1680
1775
  isRunning: () => currentRun !== null,
1681
1776
  paintIdle,
1682
1777
  modelSwitched,
@@ -1741,6 +1836,8 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1741
1836
  // "expand" turn is never intercepted).
1742
1837
  input.onExpand(() => dispatch("\x12expand", dispatchCtx));
1743
1838
  input.onThink?.(() => dispatch("\x14think", dispatchCtx));
1839
+ // ADR-0058 (3e): ctrl+b — the running command to the background, now
1840
+ input.onBackground?.(() => dispatch("\x02background", dispatchCtx));
1744
1841
  input.onEditor?.(() => dispatch("\x07editor", dispatchCtx));
1745
1842
  // E1 §3 — ctrl+x and `/copy` are the same action reached two ways, so
1746
1843
  // they are the same sentinel: one implementation, one behaviour.
@@ -1751,9 +1848,13 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1751
1848
  // row repaints at once with a one-line notice.
1752
1849
  input.onModeCycle?.(() => {
1753
1850
  const next = OFFERED_MODES[(OFFERED_MODES.indexOf(getMode()) + 1) % OFFERED_MODES.length];
1851
+ const wasOn = getDontAsk();
1754
1852
  setMode(next);
1755
1853
  paintIdle();
1756
- body.notice(`mode → ${next} (shift+tab cycles)`);
1854
+ body.notice(`mode → ${MODE_LABEL[next]} (shift+tab cycles)`);
1855
+ // the switch came with the old dontAsk tier, and left with it
1856
+ if (getDontAsk() !== wasOn)
1857
+ body.notice("don't ask → off");
1757
1858
  });
1758
1859
  // Recovery first: a session with a dangling pause or uncertain
1759
1860
  // executions must resolve them BEFORE the REPL accepts new turns —
@@ -1775,7 +1876,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1775
1876
  // before the run resumes: the run continues on the compacted projection.
1776
1877
  const cold = !cancelled && process.stdin.isTTY ? coldResumeOffer(session) : null;
1777
1878
  if (cold !== null) {
1778
- if (getMode() === "dontAsk") {
1879
+ if (getDontAsk()) {
1779
1880
  body.notice(`[dontAsk] ${coldResumeLine(cold.tokens, cold.minutes)} — compacting first`);
1780
1881
  dispatch("/compact", dispatchCtx);
1781
1882
  }
@@ -1835,6 +1936,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1835
1936
  // runs before the exit or the chain is already settled. One level is
1836
1937
  // enough: the /compact segment appends nothing of its own.
1837
1938
  await chainRef.current;
1939
+ stopTasks();
1838
1940
  if (switchTo !== null)
1839
1941
  return { next: "switch", id: switchTo, lines: queuedLines.slice() };
1840
1942
  // a switch beats a reload: /resume and /clear are going somewhere else,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ADR-0058 3d — how a background child ends: `kiso chat <id> --task-file
3
+ * <f> --max-turns N --result-file <r>`.
4
+ *
5
+ * The budget (D6) is the session's `maxTurns`: the kernel stops the run at
6
+ * a model boundary, after the round's tool results, before the next
7
+ * request — nothing is killed. Such a run usually ends on a tool round with
8
+ * no answer, so the child gets ONE wrap-up request: a system input naming
9
+ * the spent budget, in a run limited to that one request. The outcome is
10
+ * then `incomplete`, never a plain failure.
11
+ *
12
+ * The answer is the child's own — the last assistant text in its session's
13
+ * projection, never its printed output — written atomically to `<r>`
14
+ * (result.md) with `{ outcome, requests, budget }` beside it (result.json),
15
+ * before the process exits: the runner records the exit after both exist.
16
+ */
17
+ import type { AgentSession } from "@vincemakes/kiso-runtime";
18
+ export interface ChildEnd {
19
+ readonly maxTurns?: number;
20
+ readonly resultFile?: string;
21
+ }
22
+ export declare const wrapUpInput: (budget: number) => string;
23
+ export declare function finishChild(session: AgentSession, end: ChildEnd): Promise<void>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * ADR-0058 3d — how a background child ends: `kiso chat <id> --task-file
3
+ * <f> --max-turns N --result-file <r>`.
4
+ *
5
+ * The budget (D6) is the session's `maxTurns`: the kernel stops the run at
6
+ * a model boundary, after the round's tool results, before the next
7
+ * request — nothing is killed. Such a run usually ends on a tool round with
8
+ * no answer, so the child gets ONE wrap-up request: a system input naming
9
+ * the spent budget, in a run limited to that one request. The outcome is
10
+ * then `incomplete`, never a plain failure.
11
+ *
12
+ * The answer is the child's own — the last assistant text in its session's
13
+ * projection, never its printed output — written atomically to `<r>`
14
+ * (result.md) with `{ outcome, requests, budget }` beside it (result.json),
15
+ * before the process exits: the runner records the exit after both exist.
16
+ */
17
+ import { renameSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ export const wrapUpInput = (budget) => `Your turn budget of ${budget} model requests is spent. Answer now from what you have found, without further tool calls. End with the UNRESOLVED section, naming what you did not get to.`;
20
+ export async function finishChild(session, end) {
21
+ const lastOutcome = () => {
22
+ const t = [...session.log.all].reverse().find((e) => e.type === "terminal");
23
+ return t === undefined ? undefined : t.outcome.kind;
24
+ };
25
+ let outcome = lastOutcome() === "completed" ? "completed" : "failed";
26
+ if (end.maxTurns !== undefined && lastOutcome() === "max_turns") {
27
+ for await (const _ of session.run(wrapUpInput(end.maxTurns), { source: "system", maxTurns: 1 })) {
28
+ // drained: the wrap-up's events are durable in the session
29
+ }
30
+ outcome = "incomplete";
31
+ }
32
+ if (end.resultFile === undefined)
33
+ return;
34
+ const answer = lastAnswer(session);
35
+ const requests = session.log.all.filter((e) => e.type === "stop").length;
36
+ writeAtomic(end.resultFile, answer === "" ? "" : `${answer}\n`);
37
+ writeAtomic(join(dirname(end.resultFile), "result.json"), `${JSON.stringify({ outcome, requests, ...(end.maxTurns !== undefined ? { budget: end.maxTurns } : {}) })}\n`);
38
+ // the runner's terminal is the exit: a failed child must not read as a clean one
39
+ if (outcome === "failed")
40
+ process.exitCode = 1;
41
+ }
42
+ /** The last assistant message's text in the session's projection. */
43
+ function lastAnswer(session) {
44
+ const messages = session.projected();
45
+ for (let i = messages.length - 1; i >= 0; i--) {
46
+ const m = messages[i];
47
+ if (m.role !== "assistant")
48
+ continue;
49
+ const text = m.blocks.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
50
+ if (text.trim() !== "")
51
+ return text.trimEnd();
52
+ }
53
+ return "";
54
+ }
55
+ function writeAtomic(path, content) {
56
+ const tmp = `${path}.tmp`;
57
+ writeFileSync(tmp, content, "utf8");
58
+ renameSync(tmp, path);
59
+ }
package/dist/config.d.ts CHANGED
@@ -13,7 +13,10 @@
13
13
  * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
14
14
  * baseUrl?: string, model: string, apiKeyEnv: string,
15
15
  * upstream?: string } } — upstream: never where requests go
16
- * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"|"dontAsk"
16
+ * mode?: "default"|"accept-edits"|"plan"|"full-access" — and every older
17
+ * spelling: "manual", "bypass" (full-access's old name),
18
+ * "dontAsk" (default with the don't-ask switch on)
19
+ * dontAsk?: boolean — the don't-ask switch (mode.ts)
17
20
  * contextWindow?: number — tokens
18
21
  * autoCompact?: { thresholdRatio: number } — 0<r<1; default off
19
22
  * projectTrust?: "ask" | "never" — no "always" (the ruling)
@@ -28,7 +31,6 @@
28
31
  * with an invalid value is likewise loud; unknown top-level keys are
29
32
  * ignored (forward compatibility).
30
33
  */
31
- import type { Mode } from "./mode.js";
32
34
  /** OR-1: `openai-responses` is a DIALECT, not a vendor — the same kind
33
35
  * drives the first-party Responses API and the ChatGPT subscription
34
36
  * backend, and which one it is follows the profile's baseUrl (and so
@@ -92,9 +94,17 @@ export interface AutoCompactConfig {
92
94
  export interface KisoConfig {
93
95
  readonly model?: string;
94
96
  readonly models?: Readonly<Record<string, ModelProfile>>;
95
- readonly mode?: Mode;
97
+ /** As written — an old name stays an old name here, and mode.ts
98
+ * resolves it (resolveModeLayers), so /settings can say which. */
99
+ readonly mode?: string;
100
+ /** The don't-ask switch. Either config may set it: it only ever
101
+ * refuses more, so a project turning it on lowers nothing. */
102
+ readonly dontAsk?: boolean;
96
103
  readonly contextWindow?: number;
97
104
  readonly autoCompact?: AutoCompactConfig;
105
+ /** ADR-0058 §8.4: false turns every task wake into a notice that waits
106
+ * for your next message. Default on. */
107
+ readonly taskWake?: boolean;
98
108
  readonly projectTrust?: "ask" | "never";
99
109
  /** DC-3 §3 rung 1, persisted. The terminal's light/dark, for terminals
100
110
  * that answer neither `CSI ? 996 n` nor OSC 11. USER-LEVEL ONLY: a
@@ -194,9 +204,6 @@ export declare function directWriteProfile(value: string): ModelProfile | null;
194
204
  * Returns null when nothing resolves (faux mode).
195
205
  */
196
206
  export declare function resolveModel(modelFlag: string | undefined, merged: KisoConfig): ResolvedModel | null;
197
- /** Mode: env (KISO_MODE) beats config.mode; the --mode flag is applied by
198
- * main before this runs (flags are the top of the chain). */
199
- export declare function resolveModeFromConfig(merged: KisoConfig): Mode | undefined;
200
207
  /** Context window: env (KISO_CONTEXT_WINDOW) > config.contextWindow >
201
208
  * default (200k — the caller's default). */
202
209
  export declare function resolveContextWindow(merged: KisoConfig, profile?: ModelProfile): number | undefined;
package/dist/config.js CHANGED
@@ -13,7 +13,10 @@
13
13
  * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
14
14
  * baseUrl?: string, model: string, apiKeyEnv: string,
15
15
  * upstream?: string } } — upstream: never where requests go
16
- * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"|"dontAsk"
16
+ * mode?: "default"|"accept-edits"|"plan"|"full-access" — and every older
17
+ * spelling: "manual", "bypass" (full-access's old name),
18
+ * "dontAsk" (default with the don't-ask switch on)
19
+ * dontAsk?: boolean — the don't-ask switch (mode.ts)
17
20
  * contextWindow?: number — tokens
18
21
  * autoCompact?: { thresholdRatio: number } — 0<r<1; default off
19
22
  * projectTrust?: "ask" | "never" — no "always" (the ruling)
@@ -30,8 +33,9 @@
30
33
  */
31
34
  import { readFileSync, statSync } from "node:fs";
32
35
  import { homedir } from "node:os";
33
- import { join } from "node:path";
36
+ import { join, win32 } from "node:path";
34
37
  import { kisoHome } from "./state.js";
38
+ import { MODE_VALUES, parseMode } from "./mode.js";
35
39
  import { AuthError, effectiveBaseUrl, endpointCredentialId, getCredential, providerIdOf } from "./auth/credentials.js";
36
40
  import { BUILTIN_MANIFESTS } from "@vincemakes/kiso-runtime/internal";
37
41
  /** Header names a profile may not set: credentials (a key never lives in the
@@ -56,7 +60,6 @@ function parseHeaders(field, raw, fail) {
56
60
  return out;
57
61
  }
58
62
  const KINDS = ["openai-compat", "anthropic", "openai-responses"];
59
- const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass", "dontAsk"];
60
63
  export class ConfigError extends Error {
61
64
  }
62
65
  /** Parse + validate one config file. Broken JSON / invalid known values →
@@ -96,6 +99,11 @@ export function parseConfig(text, source) {
96
99
  fail("floor", "belongs in the USER config — a project must never be able to lower the floor");
97
100
  out.floor = obj.floor;
98
101
  }
102
+ // Windows P3: absolute for the host — on Windows a drive path with
103
+ // either separator (or a UNC share), never a drive-rooted `\x`, which
104
+ // would mean a different file on every drive
105
+ const windows = process.platform === "win32";
106
+ const absolute = (p) => (windows ? win32.isAbsolute(p) && !/^[\\/](?![\\/])/.test(p) : p.startsWith("/"));
99
107
  if (obj.protectedPaths !== undefined) {
100
108
  if (source.startsWith("<cwd>"))
101
109
  fail("protectedPaths", "belongs in the USER config — a project must never be able to change what kiso guards");
@@ -104,7 +112,7 @@ export function parseConfig(text, source) {
104
112
  for (const [i, p] of obj.protectedPaths.entries()) {
105
113
  // a relative path would be read against whatever directory kiso
106
114
  // runs in — a different file in every project
107
- if (typeof p !== "string" || !(p.startsWith("/") || p.startsWith("~/")))
115
+ if (typeof p !== "string" || !(absolute(p) || p.startsWith("~/")))
108
116
  fail(`protectedPaths[${i}]`, "expected an absolute path or one starting with ~/");
109
117
  // kiso protects FILES. A directory listed here would protect
110
118
  // nothing under it — a security setting that silently does
@@ -113,7 +121,7 @@ export function parseConfig(text, source) {
113
121
  // caught at the next start or /reload.)
114
122
  const path = p;
115
123
  const full = path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
116
- if (path.endsWith("/") || statSync(full, { throwIfNoEntry: false })?.isDirectory() === true) {
124
+ if (path.endsWith("/") || (windows && path.endsWith("\\")) || statSync(full, { throwIfNoEntry: false })?.isDirectory() === true) {
117
125
  fail(`protectedPaths[${i}]`, `${path} is a directory — kiso protects files, so list the files in it (for ~/.aws: "~/.aws/credentials", "~/.aws/config")`);
118
126
  }
119
127
  }
@@ -134,7 +142,7 @@ export function parseConfig(text, source) {
134
142
  if (!Array.isArray(obj.evaluators))
135
143
  fail("evaluators", "expected a list of absolute paths to evaluator scripts");
136
144
  for (const path of obj.evaluators) {
137
- if (typeof path !== "string" || !path.startsWith("/"))
145
+ if (typeof path !== "string" || !absolute(path))
138
146
  fail("evaluators", `expected an absolute path, got ${JSON.stringify(path)}`);
139
147
  }
140
148
  out.evaluators = obj.evaluators;
@@ -195,10 +203,15 @@ export function parseConfig(text, source) {
195
203
  out.models = models;
196
204
  }
197
205
  if (obj.mode !== undefined) {
198
- if (typeof obj.mode !== "string" || !MODES_LIST.includes(obj.mode))
199
- fail("mode", `expected one of ${MODES_LIST.join(", ")}`);
206
+ if (typeof obj.mode !== "string" || parseMode(obj.mode) === undefined)
207
+ fail("mode", `expected one of ${MODE_VALUES.join(", ")}`);
200
208
  out.mode = obj.mode;
201
209
  }
210
+ if (obj.dontAsk !== undefined) {
211
+ if (typeof obj.dontAsk !== "boolean")
212
+ fail("dontAsk", "expected true or false");
213
+ out.dontAsk = obj.dontAsk;
214
+ }
202
215
  if (obj.contextWindow !== undefined) {
203
216
  if (typeof obj.contextWindow !== "number" || !Number.isFinite(obj.contextWindow) || obj.contextWindow <= 0) {
204
217
  fail("contextWindow", "expected a positive token count");
@@ -213,6 +226,11 @@ export function parseConfig(text, source) {
213
226
  fail("autoCompact.thresholdRatio", "expected a number strictly between 0 and 1");
214
227
  out.autoCompact = { thresholdRatio: r };
215
228
  }
229
+ if (obj.taskWake !== undefined) {
230
+ if (typeof obj.taskWake !== "boolean")
231
+ fail("taskWake", "expected true or false");
232
+ out.taskWake = obj.taskWake;
233
+ }
216
234
  if (obj.projectTrust !== undefined) {
217
235
  if (obj.projectTrust !== "ask" && obj.projectTrust !== "never")
218
236
  fail("projectTrust", 'expected "ask" or "never" (there is deliberately no "always")');
@@ -254,10 +272,14 @@ export function mergeConfigs(user, project) {
254
272
  ...(p.models !== undefined ? { models: { ...u.models, ...p.models } } : u.models !== undefined ? { models: u.models } : {}),
255
273
  ...(u.mode !== undefined ? { mode: u.mode } : {}),
256
274
  ...(p.mode !== undefined ? { mode: p.mode } : {}),
275
+ ...(u.dontAsk !== undefined ? { dontAsk: u.dontAsk } : {}),
276
+ ...(p.dontAsk !== undefined ? { dontAsk: p.dontAsk } : {}),
257
277
  ...(u.contextWindow !== undefined ? { contextWindow: u.contextWindow } : {}),
258
278
  ...(p.contextWindow !== undefined ? { contextWindow: p.contextWindow } : {}),
259
279
  ...(u.autoCompact !== undefined ? { autoCompact: u.autoCompact } : {}),
260
280
  ...(p.autoCompact !== undefined ? { autoCompact: p.autoCompact } : {}),
281
+ ...(u.taskWake !== undefined ? { taskWake: u.taskWake } : {}),
282
+ ...(p.taskWake !== undefined ? { taskWake: p.taskWake } : {}),
261
283
  ...(u.projectTrust !== undefined ? { projectTrust: u.projectTrust } : {}),
262
284
  ...(p.projectTrust !== undefined ? { projectTrust: p.projectTrust } : {}),
263
285
  // DT-1a: checks merge per name — a (trusted) project's check wins over the user's
@@ -474,14 +496,6 @@ function resolveProfile(name, p) {
474
496
  const auth = authForProfile(name, p);
475
497
  return auth.type === "oauth" ? { name, profile: p, oauthProviderId: auth.providerId } : { name, profile: p, apiKey: auth.apiKey };
476
498
  }
477
- /** Mode: env (KISO_MODE) beats config.mode; the --mode flag is applied by
478
- * main before this runs (flags are the top of the chain). */
479
- export function resolveModeFromConfig(merged) {
480
- const fromEnv = process.env.KISO_MODE;
481
- if (fromEnv !== undefined)
482
- return fromEnv;
483
- return merged.mode;
484
- }
485
499
  /** Context window: env (KISO_CONTEXT_WINDOW) > config.contextWindow >
486
500
  * default (200k — the caller's default). */
487
501
  export function resolveContextWindow(merged, profile) {