@vincemakes/kiso-code 0.48.0 → 0.50.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.
package/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # @vincemakes/kiso-code
1
+ <h1 align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/vincemakes/kiso/main/assets/readme/lockup-dark.png"><img src="https://raw.githubusercontent.com/vincemakes/kiso/main/assets/readme/lockup-light.png" height="46" alt="kiso"></picture></h1>
2
+
3
+ <p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/vincemakes/kiso/main/assets/readme/features-dark.png"><img src="https://raw.githubusercontent.com/vincemakes/kiso/main/assets/readme/features-light.png" width="830" alt="Built in, not bolted on: it asks before it guesses; subagents in parallel; background tasks; approval modes; it resumes after a crash; yours to extend."></picture></p>
4
+
5
+ <p align="center"><img src="https://raw.githubusercontent.com/vincemakes/kiso/main/assets/readme/screenshot.png" width="830" alt="kiso in a dark terminal window: a green diff of new tests, then the test suite started as a background task, with 1 task running in the status row"></p>
2
6
 
3
7
  The coding agent that survives kill -9: durable sessions (append-only
4
8
  JSONL), pre-effect approvals, and honest recovery — an interrupted side
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 0.49.0 B6.2 / I4 — `kiso apply-patch <taskDir> [--allow-partial]
3
+ * [--with-markers]`: adopt an implementer's collected patch into the
4
+ * workspace. A writer owns an isolated workspace; adoption is a separate,
5
+ * crash-honest effect.
6
+ *
7
+ * The collection (the subagent extension's collectWriter) left in the task
8
+ * directory: `patch.json` (the files, their modes, whether the patch is
9
+ * complete), `versions/<n>.base` and `versions/<n>.child` (each changed
10
+ * file before and after the child), and the child's `acceptance.json`.
11
+ *
12
+ * PREPARE writes nothing: each file is merged three ways — base (the
13
+ * snapshot the child started from), ours (the workspace now), theirs (the
14
+ * child's) — with `git merge-file`'s algorithm, which never reads or writes
15
+ * the index. Any conflict, or an entry this release does not adopt
16
+ * (symlinks, submodules, a mode change on an existing file, a file and a
17
+ * directory trading places, two names differing only in case), stops here
18
+ * with nothing written.
19
+ *
20
+ * PUBLISH is per file and journaled (`<taskDir>/apply.jsonl`): each target
21
+ * is revalidated against what PREPARE saw; a creation is no-clobber (link
22
+ * or refuse, WR-1A ②'s rule); a modification is revalidated right before
23
+ * its rename (WR-1A ③); a deletion is revalidated, then unlinked — the
24
+ * same narrow check-to-effect window WR-1's conditional write has, never
25
+ * called a compare-and-swap. A filesystem has no multi-file transaction,
26
+ * so the promise is all-or-none PREPARE, never an atomic write: a late
27
+ * revalidation failure ends `partial`, naming what was and was not
28
+ * published, and nothing retries it.
29
+ *
30
+ * VERIFY: after a clean publication the patch's acceptance always runs
31
+ * again in the workspace (the child's ran against the snapshot; the
32
+ * workspace has what the snapshot never held — ignored files, a build
33
+ * cache). It is re-resolved from the configuration by its NAME, never run
34
+ * from the record; it touches no git state. Both verdicts are printed.
35
+ * There is no automatic rollback. An interrupted verification is reported
36
+ * as not completed (before it started) or unknown (while it ran), and never
37
+ * retried by itself.
38
+ *
39
+ * Exit codes: 0 clean (whatever the acceptance said — two facts, printed),
40
+ * 3 conflicted, 4 partial, 5 refused or unsupported, 1 failed.
41
+ */
42
+ export declare function applyPatch(args: readonly string[], cwd?: string): Promise<number>;
@@ -0,0 +1,381 @@
1
+ /**
2
+ * 0.49.0 B6.2 / I4 — `kiso apply-patch <taskDir> [--allow-partial]
3
+ * [--with-markers]`: adopt an implementer's collected patch into the
4
+ * workspace. A writer owns an isolated workspace; adoption is a separate,
5
+ * crash-honest effect.
6
+ *
7
+ * The collection (the subagent extension's collectWriter) left in the task
8
+ * directory: `patch.json` (the files, their modes, whether the patch is
9
+ * complete), `versions/<n>.base` and `versions/<n>.child` (each changed
10
+ * file before and after the child), and the child's `acceptance.json`.
11
+ *
12
+ * PREPARE writes nothing: each file is merged three ways — base (the
13
+ * snapshot the child started from), ours (the workspace now), theirs (the
14
+ * child's) — with `git merge-file`'s algorithm, which never reads or writes
15
+ * the index. Any conflict, or an entry this release does not adopt
16
+ * (symlinks, submodules, a mode change on an existing file, a file and a
17
+ * directory trading places, two names differing only in case), stops here
18
+ * with nothing written.
19
+ *
20
+ * PUBLISH is per file and journaled (`<taskDir>/apply.jsonl`): each target
21
+ * is revalidated against what PREPARE saw; a creation is no-clobber (link
22
+ * or refuse, WR-1A ②'s rule); a modification is revalidated right before
23
+ * its rename (WR-1A ③); a deletion is revalidated, then unlinked — the
24
+ * same narrow check-to-effect window WR-1's conditional write has, never
25
+ * called a compare-and-swap. A filesystem has no multi-file transaction,
26
+ * so the promise is all-or-none PREPARE, never an atomic write: a late
27
+ * revalidation failure ends `partial`, naming what was and was not
28
+ * published, and nothing retries it.
29
+ *
30
+ * VERIFY: after a clean publication the patch's acceptance always runs
31
+ * again in the workspace (the child's ran against the snapshot; the
32
+ * workspace has what the snapshot never held — ignored files, a build
33
+ * cache). It is re-resolved from the configuration by its NAME, never run
34
+ * from the record; it touches no git state. Both verdicts are printed.
35
+ * There is no automatic rollback. An interrupted verification is reported
36
+ * as not completed (before it started) or unknown (while it ran), and never
37
+ * retried by itself.
38
+ *
39
+ * Exit codes: 0 clean (whatever the acceptance said — two facts, printed),
40
+ * 3 conflicted, 4 partial, 5 refused or unsupported, 1 failed.
41
+ */
42
+ import { spawn, spawnSync } from "node:child_process";
43
+ import { createHash } from "node:crypto";
44
+ import { appendFileSync, chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import { basename, dirname, join } from "node:path";
47
+ import { projectArtifacts, trustFor } from "@vincemakes/kiso-runtime";
48
+ import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveProjectTrustPolicy } from "./config.js";
49
+ const ABSENT = "absent";
50
+ const sha = (b) => createHash("sha256").update(b).digest("hex");
51
+ export async function applyPatch(args, cwd = process.cwd()) {
52
+ const taskDir = args.find((a) => !a.startsWith("--"));
53
+ const allowPartial = args.includes("--allow-partial");
54
+ const withMarkers = args.includes("--with-markers");
55
+ if (taskDir === undefined) {
56
+ console.error("usage: kiso apply-patch <task directory> [--allow-partial] [--with-markers]");
57
+ return 5;
58
+ }
59
+ let patch;
60
+ try {
61
+ patch = JSON.parse(readFileSync(join(taskDir, "patch.json"), "utf8"));
62
+ }
63
+ catch (err) {
64
+ console.error(`apply-patch: no collected patch in ${taskDir} (${err.message})`);
65
+ return 1;
66
+ }
67
+ const journal = join(taskDir, "apply.jsonl");
68
+ const prior = readJournal(journal);
69
+ if (prior.length > 0) {
70
+ const ended = prior.find((r) => r.type === "apply_terminal");
71
+ if (ended !== undefined) {
72
+ console.error(`apply-patch: this patch was already adopted (${String(ended.outcome)}); nothing was done`);
73
+ return 5;
74
+ }
75
+ console.error(interruptedReport(prior));
76
+ return 1;
77
+ }
78
+ if (!patch.applyable && !allowPartial) {
79
+ console.error("apply-patch: the child was stopped, so this patch is partial; adopt it only with --allow-partial");
80
+ return 5;
81
+ }
82
+ let root;
83
+ try {
84
+ root = git(cwd, ["rev-parse", "--show-toplevel"]).trim();
85
+ }
86
+ catch {
87
+ console.error("apply-patch: run it inside the workspace's git repository");
88
+ return 5;
89
+ }
90
+ const unsupported = unsupportedOf(patch, root);
91
+ if (unsupported.length > 0) {
92
+ console.error(`apply-patch: not adopted — this release adopts regular files only:\n${unsupported.map((u) => ` ${u}`).join("\n")}\nIntegrate it by hand, or delegate again.`);
93
+ return 5;
94
+ }
95
+ // ── PREPARE: nothing in the workspace changes ──────────────────────────
96
+ const planned = [];
97
+ const conflicts = [];
98
+ const scratch = mkdtempSync(join(tmpdir(), "kiso-apply-"));
99
+ try {
100
+ patch.files.forEach((f, n) => {
101
+ const target = join(root, f.path);
102
+ const ours = readOrNull(target);
103
+ const expected = ours === null ? ABSENT : sha(ours);
104
+ const base = f.status === "A" ? null : readFileSync(join(taskDir, "versions", `${n}.base`));
105
+ const theirs = f.status === "D" ? null : readFileSync(join(taskDir, "versions", `${n}.child`));
106
+ const exec = f.mode === "100755";
107
+ if (f.status === "A") {
108
+ if (ours === null)
109
+ planned.push({ path: f.path, action: "create", expected, content: theirs, exec });
110
+ else if (!ours.equals(theirs))
111
+ conflicts.push(`${f.path}: created by the child, and it exists in the workspace with other content`);
112
+ return;
113
+ }
114
+ if (f.status === "D") {
115
+ if (ours === null)
116
+ return; // already gone
117
+ if (ours.equals(base))
118
+ planned.push({ path: f.path, action: "delete", expected });
119
+ else
120
+ conflicts.push(`${f.path}: deleted by the child, and changed in the workspace since`);
121
+ return;
122
+ }
123
+ // a modification
124
+ if (ours === null) {
125
+ conflicts.push(`${f.path}: changed by the child, and deleted in the workspace since`);
126
+ return;
127
+ }
128
+ if (ours.equals(base))
129
+ planned.push({ path: f.path, action: "modify", expected, content: theirs });
130
+ else if (ours.equals(theirs))
131
+ return; // the workspace already has it
132
+ else {
133
+ const merged = mergeThree(scratch, ours, base, theirs);
134
+ if (merged.conflicts === 0)
135
+ planned.push({ path: f.path, action: "modify", expected, content: merged.content });
136
+ else if (withMarkers)
137
+ planned.push({ path: f.path, action: "modify", expected, content: merged.content });
138
+ else
139
+ conflicts.push(`${f.path}: ${merged.conflicts} conflicting ${merged.conflicts === 1 ? "hunk" : "hunks"}\n${hunks(merged.content)}`);
140
+ }
141
+ });
142
+ }
143
+ finally {
144
+ rmSync(scratch, { recursive: true, force: true });
145
+ }
146
+ if (conflicts.length > 0) {
147
+ console.error(`apply-patch: nothing was written — the patch conflicts with the workspace:\n${conflicts.map((c) => ` ${c}`).join("\n")}\nResolve by hand, apply with --with-markers and fix the markers with edit_file, or delegate again on the current tree.`);
148
+ return 3;
149
+ }
150
+ // test-only: KISO_TEST_APPLY_HOOK names a module whose afterPrepare() /
151
+ // afterPublish(path) run between the steps — the races a gate needs on cue
152
+ const hook = process.env.KISO_TEST_APPLY_HOOK === undefined ? undefined : (await import(process.env.KISO_TEST_APPLY_HOOK));
153
+ hook?.afterPrepare?.();
154
+ // ── PUBLISH: per file, revalidated, journaled ───────────────────────────
155
+ const record = (r) => appendFileSync(journal, `${JSON.stringify({ ...r, ts: Date.now() })}\n`);
156
+ record({ type: "apply_planned", patchSha256: sha(readFileSync(join(taskDir, "patch.diff"))), files: planned.map((p) => ({ path: p.path, action: p.action, expected: p.expected })) });
157
+ const published = [];
158
+ for (const p of planned) {
159
+ const target = join(root, p.path);
160
+ const why = publish(target, p);
161
+ if (why !== null) {
162
+ record({ type: "apply_terminal", outcome: "partial", published, stoppedAt: p.path, reason: why });
163
+ const rest = planned.map((q) => q.path).filter((q) => !published.includes(q));
164
+ console.error(`apply-patch: PARTIAL — ${p.path}: ${why}\n published: ${published.length > 0 ? published.join(", ") : "none"}\n not published: ${rest.join(", ")}\nNothing is retried or rolled back; read the files and decide.`);
165
+ return 4;
166
+ }
167
+ published.push(p.path);
168
+ record({ type: "apply_file_published", path: p.path, action: p.action });
169
+ hook?.afterPublish?.(p.path);
170
+ }
171
+ record({ type: "publication_complete", files: published.length });
172
+ console.log(`apply-patch: adopted ${published.length} ${published.length === 1 ? "file" : "files"}: ${published.join(", ") || "(the workspace already had every change)"}`);
173
+ // ── VERIFY: the acceptance again, in the workspace ──────────────────────
174
+ const child = readOrNull(join(taskDir, "acceptance.json"));
175
+ const childVerdict = child === null ? "none" : JSON.parse(child.toString("utf8")).passed === true ? "PASSED" : "FAILED";
176
+ if (patch.acceptance === undefined) {
177
+ record({ type: "apply_terminal", outcome: "clean" });
178
+ console.log(`child acceptance: ${childVerdict} · workspace acceptance: none named — the merge was textual only`);
179
+ return 0;
180
+ }
181
+ const resolved = await resolveAcceptance(patch.acceptance, root);
182
+ if (typeof resolved === "string") {
183
+ record({ type: "apply_terminal", outcome: "clean", acceptance: "unresolved", reason: resolved });
184
+ console.log(`child acceptance: ${childVerdict} · workspace acceptance: not run — ${resolved}`);
185
+ return 0;
186
+ }
187
+ record({ type: "acceptance_started", ...resolved.label });
188
+ const v = await runInWorkspace(resolved, root);
189
+ record({ type: "acceptance_result", passed: v.passed, exitCode: v.exitCode });
190
+ record({ type: "apply_terminal", outcome: "clean" });
191
+ console.log(`child acceptance: ${childVerdict} · workspace acceptance: ${v.passed ? "PASSED" : "FAILED"} (exit ${v.exitCode ?? "killed"})${v.passed ? "" : `\n${v.tail}`}`);
192
+ return 0;
193
+ }
194
+ /** What an interrupted adoption left — the journal's facts, nothing guessed. */
195
+ function interruptedReport(records) {
196
+ const files = records.filter((r) => r.type === "apply_file_published").map((r) => String(r.path));
197
+ const planned = records.find((r) => r.type === "apply_planned")?.files?.map((f) => f.path) ?? [];
198
+ if (records.some((r) => r.type === "acceptance_started")) {
199
+ return `apply-patch: an earlier adoption was interrupted — every file was published (${files.join(", ")}); its workspace acceptance started and its outcome is UNKNOWN. Nothing is retried: run the check yourself.`;
200
+ }
201
+ if (records.some((r) => r.type === "publication_complete")) {
202
+ return `apply-patch: an earlier adoption was interrupted — every file was published (${files.join(", ")}); its workspace acceptance was NOT COMPLETED. Nothing is retried: run the check yourself.`;
203
+ }
204
+ const rest = planned.filter((p) => !files.includes(p));
205
+ return `apply-patch: an earlier adoption was interrupted mid-publication — published: ${files.join(", ") || "none"}; not published: ${rest.join(", ") || "none"}. Nothing is retried or rolled back; read the files and decide.`;
206
+ }
207
+ /** B6.2's scope for 0.49: regular files created, modified or deleted. */
208
+ function unsupportedOf(patch, root) {
209
+ const out = [];
210
+ const lower = new Map();
211
+ for (const f of patch.files) {
212
+ const key = f.path.toLowerCase();
213
+ const seen = lower.get(key);
214
+ if (seen !== undefined && seen !== f.path)
215
+ out.push(`${f.path}: differs from ${seen} only in case`);
216
+ lower.set(key, f.path);
217
+ if (!["A", "M", "D"].includes(f.status))
218
+ out.push(`${f.path}: a type change (${f.status})`);
219
+ if ([f.mode, f.baseMode].some((m) => m === "120000"))
220
+ out.push(`${f.path}: a symlink`);
221
+ if ([f.mode, f.baseMode].some((m) => m === "160000"))
222
+ out.push(`${f.path}: a submodule`);
223
+ if (f.status === "M" && f.mode !== f.baseMode)
224
+ out.push(`${f.path}: a mode change (${f.baseMode} → ${f.mode})`);
225
+ const target = join(root, f.path);
226
+ try {
227
+ if (lstatSync(target).isDirectory())
228
+ out.push(`${f.path}: a directory in the workspace`);
229
+ }
230
+ catch {
231
+ // absent — a creation, or already deleted
232
+ }
233
+ if (f.status === "A") {
234
+ // a new name whose case-folded twin already exists (macOS, Windows)
235
+ try {
236
+ const twin = readdirSync(dirname(target)).find((e) => e.toLowerCase() === basename(target).toLowerCase() && e !== basename(target));
237
+ if (twin !== undefined)
238
+ out.push(`${f.path}: the workspace has ${twin}, differing only in case`);
239
+ }
240
+ catch {
241
+ // the directory does not exist yet
242
+ }
243
+ }
244
+ }
245
+ return out;
246
+ }
247
+ /** git merge-file's algorithm on scratch copies — the index never read. */
248
+ function mergeThree(scratch, ours, base, theirs) {
249
+ const [o, b, t] = ["ours", "base", "theirs"].map((n) => join(scratch, n));
250
+ writeFileSync(o, ours);
251
+ writeFileSync(b, base);
252
+ writeFileSync(t, theirs);
253
+ const r = spawnSync("git", ["merge-file", "-p", "-L", "workspace", "-L", "base", "-L", "child", o, b, t], { maxBuffer: 256 * 1024 * 1024 });
254
+ if (r.status === null || r.status < 0)
255
+ throw new Error(`git merge-file failed: ${String(r.stderr)}`);
256
+ return { content: r.stdout, conflicts: r.status };
257
+ }
258
+ /** The conflicting hunks of a merged text, to name them. */
259
+ function hunks(merged) {
260
+ const lines = merged.toString("utf8").split("\n");
261
+ const out = [];
262
+ let inside = false;
263
+ for (const l of lines) {
264
+ if (l.startsWith("<<<<<<< "))
265
+ inside = true;
266
+ if (inside)
267
+ out.push(` ${l}`);
268
+ if (l.startsWith(">>>>>>> "))
269
+ inside = false;
270
+ if (out.length > 60) {
271
+ out.push(" …");
272
+ break;
273
+ }
274
+ }
275
+ return out.join("\n");
276
+ }
277
+ /** One file, revalidated against PREPARE; null when published. */
278
+ function publish(target, p) {
279
+ const now = () => {
280
+ const b = readOrNull(target);
281
+ return b === null ? ABSENT : sha(b);
282
+ };
283
+ if (now() !== p.expected)
284
+ return "it changed after the merge was prepared";
285
+ if (p.action === "delete") {
286
+ unlinkSync(target);
287
+ return null;
288
+ }
289
+ mkdirSync(dirname(target), { recursive: true });
290
+ const tmp = join(dirname(target), `.${basename(target)}.kiso-apply-${process.pid}`);
291
+ writeFileSync(tmp, p.content);
292
+ if (p.action === "create") {
293
+ chmodSync(tmp, p.exec === true ? 0o755 : 0o644);
294
+ // WR-1A ②: link(2) or refusal — no-clobber, fail closed
295
+ try {
296
+ linkSync(tmp, target);
297
+ return null;
298
+ }
299
+ catch (err) {
300
+ return err.code === "EEXIST" ? "it was created in the workspace after the merge was prepared" : `it could not be created atomically (${err.code ?? "link failed"})`;
301
+ }
302
+ finally {
303
+ rmSync(tmp, { force: true });
304
+ }
305
+ }
306
+ chmodSync(tmp, statSync(target).mode & 0o7777);
307
+ // WR-1A ③: still what the merge was prepared against, right before the rename
308
+ if (now() !== p.expected) {
309
+ rmSync(tmp, { force: true });
310
+ return "it changed while the adoption was writing";
311
+ }
312
+ renameSync(tmp, target);
313
+ return null;
314
+ }
315
+ /** The acceptance by its NAME, from the configuration (a trusted project's
316
+ * too, never asked here) — or why it cannot run. */
317
+ async function resolveAcceptance(acceptance, root) {
318
+ const user = loadUserConfig();
319
+ let project = null;
320
+ if (resolveProjectTrustPolicy(user ?? {}) !== "never") {
321
+ const artifacts = await projectArtifacts(root);
322
+ if (artifacts !== null && trustFor(artifacts.root, artifacts.digest)?.decision === "granted")
323
+ project = loadProjectConfig(root, true);
324
+ }
325
+ const merged = mergeConfigs(user, project);
326
+ if (acceptance.check !== undefined) {
327
+ const command = merged.checks?.[acceptance.check];
328
+ if (command === undefined)
329
+ return `the check "${acceptance.check}" is no longer configured`;
330
+ return { command: process.platform === "win32" ? "bash" : "/bin/sh", args: ["-c", command], label: { check: acceptance.check } };
331
+ }
332
+ if (acceptance.evaluator !== undefined) {
333
+ if (!(merged.evaluators ?? []).includes(acceptance.evaluator))
334
+ return `the evaluator ${acceptance.evaluator} is no longer listed`;
335
+ return { command: acceptance.evaluator, args: [root], label: { evaluator: acceptance.evaluator } };
336
+ }
337
+ return "the patch names no acceptance";
338
+ }
339
+ /** The check in the workspace — no git state touched, its tail kept. */
340
+ function runInWorkspace(r, root) {
341
+ return new Promise((resolve) => {
342
+ let out = "";
343
+ const child = spawn(r.command, [...r.args], { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
344
+ const keep = (d) => {
345
+ out = (out + d.toString("utf8")).slice(-2_048);
346
+ };
347
+ child.stdout.on("data", keep);
348
+ child.stderr.on("data", keep);
349
+ child.on("error", (err) => resolve({ passed: false, exitCode: null, tail: err.message }));
350
+ child.on("exit", (code) => resolve({ passed: code === 0, exitCode: code, tail: out }));
351
+ });
352
+ }
353
+ function readOrNull(path) {
354
+ try {
355
+ return readFileSync(path);
356
+ }
357
+ catch {
358
+ return null;
359
+ }
360
+ }
361
+ function readJournal(path) {
362
+ if (!existsSync(path))
363
+ return [];
364
+ return readFileSync(path, "utf8")
365
+ .split("\n")
366
+ .filter((l) => l.trim() !== "")
367
+ .flatMap((l) => {
368
+ try {
369
+ return [JSON.parse(l)];
370
+ }
371
+ catch {
372
+ return [];
373
+ }
374
+ });
375
+ }
376
+ function git(cwd, args) {
377
+ const r = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
378
+ if (r.status !== 0)
379
+ throw new Error(r.stderr);
380
+ return r.stdout;
381
+ }
package/dist/builtin.js CHANGED
@@ -25,7 +25,7 @@ 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
27
  import { getDontAsk } from "./mode.js";
28
- import { tasksFor } from "./state.js";
28
+ import { bindingFor, codingToolOptions, tasksFor } from "./state.js";
29
29
  /**
30
30
  * 0.40.0 (the owner's dogfood): dontAsk never asks, so its tool table never
31
31
  * offers ask_user — a model once put four questions to a dontAsk session
@@ -59,9 +59,11 @@ ask,
59
59
  * too late. Only the config knows them; nothing else can. */
60
60
  secretEnvNames = []) {
61
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)])]);
62
+ // background children (any role; a writer works in its own snapshot and
63
+ // is collected; the caps and the turn budgets are the extension's
64
+ // defaults). 0.49.0 C1: the session's live binding
65
+ // is the model a child runs on when its task names none.
66
+ const all = await Promise.all([createMcp({ secretEnvNames }), createSkills(), createSubagent({ tasks: tasksFor, currentBinding: bindingFor, ...joinOption() }), ...(ask === undefined ? [] : [createAsk(ask).then(offInDontAsk)])]);
65
67
  const shadowed = all.filter((b) => user.some((u) => u.name === b.name));
66
68
  for (const s of shadowed) {
67
69
  console.error(`[extensions] user extension "${s.name}" shadows the built-in — the built-in is not loaded`);
@@ -73,3 +75,9 @@ secretEnvNames = []) {
73
75
  }
74
76
  return all.filter((b) => !shadowed.includes(b));
75
77
  }
78
+ /** 0.49.0 A: a reader delegation waits as long as a shell command does —
79
+ * one knob for "the foreground wait" (KISO_MIN_FOREGROUND_MS reaches both). */
80
+ function joinOption() {
81
+ const floor = codingToolOptions().limits?.minForegroundMs;
82
+ return floor !== undefined ? { joinMs: floor } : {};
83
+ }
package/dist/chat.d.ts CHANGED
@@ -134,8 +134,8 @@ export declare function statusModelLabel(session: {
134
134
  };
135
135
  }): string;
136
136
  /** ADR-0055 Amendment 2 (decision 5): with no stated window the status row
137
- * shows `ctx ?`, yet the compaction tiers still assume the fallback (128K
138
- * since CW-1 batch 2)
137
+ * shows `ctx ?`, yet the compaction tiers still assume the fallback (200K;
138
+ * 128K from CW-1 batch 2 until the owner's 2026-10-09 ruling)
139
139
  * — the assumption is said out loud once, at agent build. */
140
140
  export declare function unknownWindowNotice(model: string): string;
141
141
  /** CW-1 batch 2: said once, when an endpoint's refusal states its cap — the
package/dist/chat.js CHANGED
@@ -9,7 +9,7 @@ import { escapeTerminal, cacheHitPct, decodeRate, palette, renderEvent, renderRe
9
9
  import { askView, coldResumeLine, coldResumeView, deletionRiskHint, editFileHunksDiff, hunksOf, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath, isProtectedPath, protectedIdentity, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
11
  import { deliveryLines, liveTasks, lostToldOf, mergedConfig, queuedSwitchLines, setExitTasks, tasksFor, taskWhatOf } from "./state.js";
12
- import { taskCounts, taskNoticeLines, taskNoticeRows, tasksForDisplay } from "./task-notice.js";
12
+ import { budgetSpentLine, taskCounts, taskNoticeLines, taskNoticeRows, tasksForDisplay } from "./task-notice.js";
13
13
  import { current as inRunCompaction, takeKept as takeKeptCompaction } from "./in-run-compaction.js";
14
14
  import { echoText } from "@vincemakes/kiso-tui-cells/render";
15
15
  import { canonicalizeUsage, RunClosedError } from "@vincemakes/kiso-runtime";
@@ -27,11 +27,18 @@ import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertain
27
27
  import { FauxExhaustionError, failOnFauxExhaustion } from "./faux-glue.js";
28
28
  import { MODE_LABEL, OFFERED_MODES, getDontAsk, getMode, modeDisplay, setMode } from "./mode.js";
29
29
  /** B area: default context window for the ~ctx estimate (config overridable).
30
- * CW-1 batch 2: 128,000, down from 200,000 — the figure a model nobody
31
- * states a window for is assumed to hold (the reference implementation's
32
- * default too). Too small costs an early compaction; too large costs a
33
- * refused request on a 128K model. Registered models never reach it. */
34
- const DEFAULT_CONTEXT_WINDOW = 128_000;
30
+ * The figure a model nobody states a window for is assumed to hold.
31
+ * Registered models never reach it.
32
+ *
33
+ * 200,000 again since the owner's ruling of 2026-10-09, which reverses CW-1
34
+ * batch 2's 128,000. The same batch made an endpoint's refusal teach its
35
+ * real cap, kept across sessions and only ever lowered. So a figure too
36
+ * large costs one refused request per endpoint and model, once, while a
37
+ * figure too small compacts every session early and never corrects
38
+ * itself (gpt-6.1-sol, 272K and unregistered in 0.47.3, compacted from
39
+ * 64K). 200K, not more: a 128K model's soft tier (100K) still usually
40
+ * compacts it before its wall. */
41
+ const DEFAULT_CONTEXT_WINDOW = 200_000;
35
42
  /** The in-process fake provider's id, and the window we declare for it.
36
43
  * Ours to state: faux is not a vendor's model, so "nobody published a
37
44
  * window" — the reason a real model's capacity is unknown — cannot apply.
@@ -236,8 +243,8 @@ export function statusModelLabel(session) {
236
243
  return effort !== undefined && effort !== "default" ? `${name} · ${effort}` : name;
237
244
  }
238
245
  /** ADR-0055 Amendment 2 (decision 5): with no stated window the status row
239
- * shows `ctx ?`, yet the compaction tiers still assume the fallback (128K
240
- * since CW-1 batch 2)
246
+ * shows `ctx ?`, yet the compaction tiers still assume the fallback (200K;
247
+ * 128K from CW-1 batch 2 until the owner's 2026-10-09 ruling)
241
248
  * — the assumption is said out loud once, at agent build. */
242
249
  export function unknownWindowNotice(model) {
243
250
  return `[kiso] context window unknown for ${model} at this endpoint — compaction assumes ${DEFAULT_CONTEXT_WINDOW / 1000}K; set contextWindow on the profile to state it`;
@@ -1055,7 +1062,7 @@ onInputLanded) {
1055
1062
  if (m !== null)
1056
1063
  reason = m[1];
1057
1064
  }
1058
- body.toolResult(ev.callId, { content: text, isError: ev.isError, reason });
1065
+ body.toolResult(ev.callId, { content: text, isError: ev.isError, reason, ...((ev.tags ?? []).includes("created") ? { created: true } : {}) });
1059
1066
  break;
1060
1067
  }
1061
1068
  case "text_delta":
@@ -1771,6 +1778,11 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1771
1778
  // the same chain a person's turn takes (taskWake: false keeps it for
1772
1779
  // the next message). Released when this chat ends.
1773
1780
  const taskManager = tasksFor(session.id);
1781
+ // 0.49.0 B: writers that ended while nobody listened are collected
1782
+ // BEFORE the delivery is built — so they arrive as anything that ended
1783
+ // meanwhile does, as a notify, never a wake
1784
+ if (taskManager !== undefined)
1785
+ await taskManager.collectPending();
1774
1786
  // KISO_AUTO_DETACH_MS — the test rigs' knob (like KISO_STREAM_IDLE_MS):
1775
1787
  // how old a command must be before a steer detaches it (3e D1). The
1776
1788
  // rigs that hold a steer pending behind a long command raise it.
@@ -1790,6 +1802,8 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1790
1802
  for (const line of taskNoticeLines(ids.map((taskId) => ({ taskId, transition: "unknown" })), taskWhatOf(session.id)))
1791
1803
  body.notice(line);
1792
1804
  },
1805
+ // 0480-F9: the chain went quiet on its budget — say so, once
1806
+ onBudgetSpent: (info) => body.notice(budgetSpentLine(info)),
1793
1807
  ...(Number.isFinite(autoDetachFromEnv) && autoDetachFromEnv >= 0 ? { autoDetachMinAgeMs: autoDetachFromEnv } : {}),
1794
1808
  });
1795
1809
  // 3e: a task's change repaints the row it is counted on
@@ -11,8 +11,16 @@
11
11
  *
12
12
  * The answer is the child's own — the last assistant text in its session's
13
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.
14
+ * (result.md), then the result record beside it (result.json), before the
15
+ * process exits: the runner records the exit after both exist.
16
+ *
17
+ * 0.49.0 (I5, I6): the record COMMITS the result — result.md is read only
18
+ * beside it — and it says everything the parent shows: how the run ended
19
+ * (`endedBy`, the main run's terminal: a wrap-up is a second run, and the
20
+ * parent never counts runs to learn it), the requests, the tool calls and
21
+ * usage over the committed events, the model it ran on, and a failure's
22
+ * error. Foreground children carry a result file too, so both ways of
23
+ * waiting read the same record.
16
24
  */
17
25
  import type { AgentSession } from "@vincemakes/kiso-runtime";
18
26
  export interface ChildEnd {
@@ -11,17 +11,27 @@
11
11
  *
12
12
  * The answer is the child's own — the last assistant text in its session's
13
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.
14
+ * (result.md), then the result record beside it (result.json), before the
15
+ * process exits: the runner records the exit after both exist.
16
+ *
17
+ * 0.49.0 (I5, I6): the record COMMITS the result — result.md is read only
18
+ * beside it — and it says everything the parent shows: how the run ended
19
+ * (`endedBy`, the main run's terminal: a wrap-up is a second run, and the
20
+ * parent never counts runs to learn it), the requests, the tool calls and
21
+ * usage over the committed events, the model it ran on, and a failure's
22
+ * error. Foreground children carry a result file too, so both ways of
23
+ * waiting read the same record.
16
24
  */
17
25
  import { renameSync, writeFileSync } from "node:fs";
18
26
  import { dirname, join } from "node:path";
19
27
  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
28
  export async function finishChild(session, end) {
21
- const lastOutcome = () => {
29
+ const lastTerminal = () => {
22
30
  const t = [...session.log.all].reverse().find((e) => e.type === "terminal");
23
- return t === undefined ? undefined : t.outcome.kind;
31
+ return t === undefined ? undefined : t.outcome;
24
32
  };
33
+ const lastOutcome = () => lastTerminal()?.kind;
34
+ const main = lastTerminal();
25
35
  let outcome = lastOutcome() === "completed" ? "completed" : "failed";
26
36
  if (end.maxTurns !== undefined && lastOutcome() === "max_turns") {
27
37
  for await (const _ of session.run(wrapUpInput(end.maxTurns), { source: "system", maxTurns: 1 })) {
@@ -33,12 +43,49 @@ export async function finishChild(session, end) {
33
43
  return;
34
44
  const answer = lastAnswer(session);
35
45
  const requests = session.log.all.filter((e) => e.type === "stop").length;
46
+ const events = committed(session.log.all);
47
+ const usages = events.filter((e) => e.type === "usage");
48
+ const sum = (k) => usages.reduce((n, e) => n + (typeof e[k] === "number" ? e[k] : 0), 0);
49
+ const error = outcome === "failed" ? errorOf(lastTerminal()) : undefined;
50
+ const record = {
51
+ outcome,
52
+ endedBy: main?.kind ?? "none",
53
+ requests,
54
+ ...(end.maxTurns !== undefined ? { budget: end.maxTurns } : {}),
55
+ toolCalls: events.filter((e) => e.type === "tool_call_end").length,
56
+ usage: {
57
+ completedResponses: usages.length,
58
+ abandonedAttempts: session.log.all.filter((e) => e.type === "model_output_abandoned").length,
59
+ inputTokens: sum("inputTokens"),
60
+ outputTokens: sum("outputTokens"),
61
+ cacheRead: sum("cacheRead"),
62
+ },
63
+ model: session.model,
64
+ profile: session.profileName,
65
+ ...(error !== undefined ? { error } : {}),
66
+ };
36
67
  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`);
68
+ // I5: the record last — it commits the answer written above
69
+ writeAtomic(join(dirname(end.resultFile), "result.json"), `${JSON.stringify(record)}\n`);
38
70
  // the runner's terminal is the exit: a failed child must not read as a clean one
39
71
  if (outcome === "failed")
40
72
  process.exitCode = 1;
41
73
  }
74
+ /** The kernel's void scope (core `project.ts`): a `model_output_abandoned`
75
+ * marker voids (voidFromSeq, seq], an abandoned draft's events. A voided
76
+ * tool call never ran and a voided usage was never billed as an answer. */
77
+ function committed(events) {
78
+ const voids = events.filter((e) => e.type === "model_output_abandoned").map((e) => ({ from: e.voidFromSeq ?? -1, to: e.seq ?? -1 }));
79
+ return events.filter((e) => !voids.some((r) => (e.seq ?? -1) > r.from && (e.seq ?? -1) <= r.to));
80
+ }
81
+ /** A failed run's reason, one line: the kernel's error, or how it ended. */
82
+ function errorOf(t) {
83
+ if (t === undefined)
84
+ return "the run left no terminal";
85
+ if (t.kind === "error" && t.error !== undefined)
86
+ return [t.error.code, t.error.message].filter((s) => s !== undefined && s !== "").join(": ") || "error";
87
+ return `the run ended with ${t.kind}`;
88
+ }
42
89
  /** The last assistant message's text in the session's projection. */
43
90
  function lastAnswer(session) {
44
91
  const messages = session.projected();
@@ -9,7 +9,7 @@
9
9
  */
10
10
  /** The built-in prompt. Exported for scripts/request-surface.mjs — the
11
11
  * model-side token-rent counter measures the REAL bytes, never a copy. */
12
- export declare const SYSTEM_PROMPT = "You are kiso, a coding agent. You work in a workspace\ndirectory and change code with tools. Be concise: answer in a few lines\nunless the task genuinely needs more. Never claim a file was changed\nunless a tool confirmed it.\n\nWhat you can reach:\n- The workspace: read_file, list_dir, search_text, write_file, edit_file.\n- This machine and the network: shell \u2014 builds, tests, git, package\n managers, curl for HTTP, system queries. A request one command can\n answer is answered by running it.\n- The human: ask_user, for a decision that is theirs.\n\nTool discipline:\n- READ BEFORE YOU EDIT. For any file you are about to change, read it\n first \u2014 never guess its content.\n- Use edit_file for targeted changes and write_file for full rewrites.\n Prefer many small edits over one large write.\n- Be careful \u2014 shell has side effects and may take time.\n- search_text and list_dir are cheap \u2014 locate first, then read ranges\n with read_file offset/limit; never read a whole large file in one call.\n- Do not re-read a file you already read unchanged, or one you changed\n yourself through a confirmed edit \u2014 rely on the earlier result and\n on the change you just made.\n- When a tool fails, read the error and adjust; do not repeat the same\n call blindly.\n\nAn authorization covers what it NAMES: when the scope is not named \u2014\nwhich files, which branches, whether to push \u2014 ask before acting.\nDelivering, sharing or handing over changes means committing locally and\nstopping; pushing, publishing or sending happens only when the human\nnames it.\n\nWorkflow: understand the request, find the relevant code, make the\nsmallest change that works, then verify with a command (tests/build).\nReport what you did in one or two lines per change.";
12
+ export declare const SYSTEM_PROMPT = "You are an expert coding assistant running on Kiso, an agent runtime. Help the user complete software engineering tasks in the current workspace using the available tools.\n\nShell can access this machine and the network, including git, package managers, and HTTP. For factual questions the user asks, use existing valid tool evidence or an available tool rather than guessing or claiming the information is unavailable.\n\nWork directly toward the user's request. Make the smallest complete change that solves it, follow existing conventions, and do not expand the task into unrelated fixes, refactors, or features.\n\nInspect enough current context to act correctly. Reuse valid observations and tool results instead of repeating work. A confirmed tool result is evidence; do not re-read or re-check solely for reassurance. If a tool reports a failure or conflict, use that new evidence to decide the next action.\n\nAfter a successful edit or write, treat the change you made and the returned revision as the file's current observed state. Do not re-read it before a follow-up edit unless you need context you have not already observed or a tool reports a conflict or failure. When a conflict or refusal already provides the current relevant text and revision, use that result directly instead of reading the file again.\n\nFor behavior-changing work, use the narrowest relevant existing verification when needed. Broaden verification only when a failure, meaningful risk, or concrete unresolved concern gives a reason to do so. Do not create ad-hoc verification scripts when existing evidence is sufficient.\n\nWhen the requested work is complete and there is no concrete unresolved issue, stop. Continue investigating or checking only when new evidence gives a reason to continue.\n\nNecessary workspace changes are within the scope of the requested task. Ask the user only for decisions that require the user. Do not commit, push, publish, send, or perform other remote or irreversible actions unless explicitly requested.\n\nRequests to share, deliver, hand over, or get work ready do not by themselves authorize a commit, push, publish, or send. Perform those actions only when the user explicitly names the action; otherwise leave the changes in the workspace.\n\nBe concise.";
13
13
  /** Hard cap for injected instructions — truncate and say so. */
14
14
  /**
15
15
  * A area: read the FIRST present instruction file (AGENTS.md preferred) and
@@ -13,41 +13,25 @@ import { readFileSync } from "node:fs";
13
13
  */
14
14
  /** The built-in prompt. Exported for scripts/request-surface.mjs — the
15
15
  * model-side token-rent counter measures the REAL bytes, never a copy. */
16
- export const SYSTEM_PROMPT = `You are kiso, a coding agent. You work in a workspace
17
- directory and change code with tools. Be concise: answer in a few lines
18
- unless the task genuinely needs more. Never claim a file was changed
19
- unless a tool confirmed it.
16
+ export const SYSTEM_PROMPT = `You are an expert coding assistant running on Kiso, an agent runtime. Help the user complete software engineering tasks in the current workspace using the available tools.
20
17
 
21
- What you can reach:
22
- - The workspace: read_file, list_dir, search_text, write_file, edit_file.
23
- - This machine and the network: shell — builds, tests, git, package
24
- managers, curl for HTTP, system queries. A request one command can
25
- answer is answered by running it.
26
- - The human: ask_user, for a decision that is theirs.
18
+ Shell can access this machine and the network, including git, package managers, and HTTP. For factual questions the user asks, use existing valid tool evidence or an available tool rather than guessing or claiming the information is unavailable.
27
19
 
28
- Tool discipline:
29
- - READ BEFORE YOU EDIT. For any file you are about to change, read it
30
- first — never guess its content.
31
- - Use edit_file for targeted changes and write_file for full rewrites.
32
- Prefer many small edits over one large write.
33
- - Be careful — shell has side effects and may take time.
34
- - search_text and list_dir are cheap — locate first, then read ranges
35
- with read_file offset/limit; never read a whole large file in one call.
36
- - Do not re-read a file you already read unchanged, or one you changed
37
- yourself through a confirmed edit — rely on the earlier result and
38
- on the change you just made.
39
- - When a tool fails, read the error and adjust; do not repeat the same
40
- call blindly.
20
+ Work directly toward the user's request. Make the smallest complete change that solves it, follow existing conventions, and do not expand the task into unrelated fixes, refactors, or features.
41
21
 
42
- An authorization covers what it NAMES: when the scope is not named —
43
- which files, which branches, whether to push — ask before acting.
44
- Delivering, sharing or handing over changes means committing locally and
45
- stopping; pushing, publishing or sending happens only when the human
46
- names it.
22
+ Inspect enough current context to act correctly. Reuse valid observations and tool results instead of repeating work. A confirmed tool result is evidence; do not re-read or re-check solely for reassurance. If a tool reports a failure or conflict, use that new evidence to decide the next action.
47
23
 
48
- Workflow: understand the request, find the relevant code, make the
49
- smallest change that works, then verify with a command (tests/build).
50
- Report what you did in one or two lines per change.`;
24
+ After a successful edit or write, treat the change you made and the returned revision as the file's current observed state. Do not re-read it before a follow-up edit unless you need context you have not already observed or a tool reports a conflict or failure. When a conflict or refusal already provides the current relevant text and revision, use that result directly instead of reading the file again.
25
+
26
+ For behavior-changing work, use the narrowest relevant existing verification when needed. Broaden verification only when a failure, meaningful risk, or concrete unresolved concern gives a reason to do so. Do not create ad-hoc verification scripts when existing evidence is sufficient.
27
+
28
+ When the requested work is complete and there is no concrete unresolved issue, stop. Continue investigating or checking only when new evidence gives a reason to continue.
29
+
30
+ Necessary workspace changes are within the scope of the requested task. Ask the user only for decisions that require the user. Do not commit, push, publish, send, or perform other remote or irreversible actions unless explicitly requested.
31
+
32
+ Requests to share, deliver, hand over, or get work ready do not by themselves authorize a commit, push, publish, or send. Perform those actions only when the user explicitly names the action; otherwise leave the changes in the workspace.
33
+
34
+ Be concise.`;
51
35
  /** The project-instructions file names, in priority order (A area). */
52
36
  const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
53
37
  /** Hard cap for injected instructions — truncate and say so. */
package/dist/config.d.ts CHANGED
@@ -138,6 +138,14 @@ export interface KisoConfig {
138
138
  * unlisted path could be an interpreter running code the child wrote.
139
139
  * Same layers as `checks`. */
140
140
  readonly evaluators?: readonly string[];
141
+ /** 0.49.0 C1: which profile a delegated child runs on when its task
142
+ * names none — `{ "model": "<a profile in models>" }`. Unset, a child
143
+ * runs on the conversation's own profile. USER-LEVEL ONLY, like
144
+ * `models`: a project must never choose where your children's
145
+ * requests go. */
146
+ readonly subagents?: {
147
+ readonly model?: string;
148
+ };
141
149
  }
142
150
  /** The resolved, merged config — project wins over user, both validated. */
143
151
  export interface ResolvedConfig {
package/dist/config.js CHANGED
@@ -245,6 +245,22 @@ export function parseConfig(text, source) {
245
245
  fail("projectTrust", 'expected "ask" or "never" (there is deliberately no "always")');
246
246
  out.projectTrust = obj.projectTrust;
247
247
  }
248
+ if (obj.subagents !== undefined) {
249
+ if (source.startsWith("<cwd>"))
250
+ fail("subagents", "belongs in the USER config — a project must never choose which model your delegated children run on");
251
+ if (obj.subagents === null || typeof obj.subagents !== "object" || Array.isArray(obj.subagents))
252
+ fail("subagents", 'expected an object, e.g. { "model": "<profile>" }');
253
+ const sub = obj.subagents;
254
+ if (sub.model !== undefined) {
255
+ if (typeof sub.model !== "string" || sub.model === "")
256
+ fail("subagents.model", "expected the name of a profile in models");
257
+ // both keys are user-level only, so the profile is in this file or nowhere
258
+ const names = Object.keys(out.models ?? {});
259
+ if (!names.includes(sub.model))
260
+ fail("subagents.model", `"${sub.model}" names no profile in models (configured: ${names.length > 0 ? names.join(", ") : "none"})`);
261
+ }
262
+ out.subagents = sub.model !== undefined ? { model: sub.model } : {};
263
+ }
248
264
  return out;
249
265
  }
250
266
  function readConfigFile(path, source) {
@@ -325,6 +341,8 @@ export function mergeConfigs(user, project) {
325
341
  ...(u.checks !== undefined || p.checks !== undefined ? { checks: { ...(u.checks ?? {}), ...(p.checks ?? {}) } } : {}),
326
342
  // CS-1: the evaluator lists join — either layer's script may be named
327
343
  ...(u.evaluators !== undefined || p.evaluators !== undefined ? { evaluators: [...(u.evaluators ?? []), ...(p.evaluators ?? [])] } : {}),
344
+ // 0.49.0 C1: user-level only — parseConfig refuses a project's
345
+ ...(u.subagents !== undefined ? { subagents: u.subagents } : {}),
328
346
  };
329
347
  }
330
348
  /** Where a profile's sign-in comes from, or why it has none. The sign-in
@@ -168,14 +168,11 @@ export async function createCodingAgent(sessionId, input, modelFlag) {
168
168
  settingsLayers.project = projectCfg;
169
169
  settingsLayers.modelFlag = modelFlag;
170
170
  const secretEnvNames = secretEnvNamesOf(merged.models ?? {});
171
- const builtIn = await builtInLayer(user, proj, input !== undefined && process.stdin.isTTY ? askUi(input) : undefined, secretEnvNames);
172
- setExtensionLists(builtIn, user, proj, [...builtIn, ...user, ...proj]);
173
- // merge round B — the config surface: user config + (trusted) project config,
174
- // resolved with flags > env > project > user > default. The CLI never
175
- // imports provider SDKs directly — the runtime's lazy provider
176
- // resolution owns them (a config profile only ever NAMES an env var for
177
- // its key; the key itself never sits in a config file).
178
- setMergedConfig(merged);
171
+ // SA-F1: set BEFORE the built-in layer loads. The subagent extension reads
172
+ // it once, at load, to build the delegate schema and its executor's
173
+ // snapshot; assigned after, it read an empty config (no `acceptance` or
174
+ // `model` field, a configured check refused, `subagents.model` ignored,
175
+ // project-layout children in the legacy sessions folder).
179
176
  // DT-1a: what a delegated task may NAME — the configured checks and the
180
177
  // model profiles — handed to the (in-process) subagent extension through
181
178
  // the environment. A model never supplies a command; it names a check.
@@ -184,7 +181,22 @@ export async function createCodingAgent(sessionId, input, modelFlag) {
184
181
  // channel and not an exported KISO_SESSIONS_DIR: a variable in
185
182
  // process.env would reach every shell child, and a kiso started from a
186
183
  // shell tool would then write into this project's folder.
187
- process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({ checks: merged.checks ?? {}, evaluators: merged.evaluators ?? [], profiles: Object.keys(merged.models ?? {}), sessionsDir: sessionsDir() });
184
+ process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({
185
+ checks: merged.checks ?? {},
186
+ evaluators: merged.evaluators ?? [],
187
+ profiles: Object.keys(merged.models ?? {}),
188
+ sessionsDir: sessionsDir(),
189
+ // 0.49.0 C1: the profile a child runs on when its task names none
190
+ ...(merged.subagents?.model !== undefined ? { subagentsModel: merged.subagents.model } : {}),
191
+ });
192
+ const builtIn = await builtInLayer(user, proj, input !== undefined && process.stdin.isTTY ? askUi(input) : undefined, secretEnvNames);
193
+ setExtensionLists(builtIn, user, proj, [...builtIn, ...user, ...proj]);
194
+ // merge round B — the config surface: user config + (trusted) project config,
195
+ // resolved with flags > env > project > user > default. The CLI never
196
+ // imports provider SDKs directly — the runtime's lazy provider
197
+ // resolution owns them (a config profile only ever NAMES an env var for
198
+ // its key; the key itself never sits in a config file).
199
+ setMergedConfig(merged);
188
200
  setConfigModels(merged.models ?? {});
189
201
  // CW-1 batch 2: the windows endpoints stated by refusing — read before the
190
202
  // first window is asked for (the unknown-window notice below).
@@ -277,6 +289,11 @@ export async function createCodingAgent(sessionId, input, modelFlag) {
277
289
  // picked for it (the picker's fallback applied); the runtime reads it
278
290
  // for a new session only — a resumed one keeps its own
279
291
  ...(() => {
292
+ // 0.49.0 C1: a delegated child on its conversation's profile runs at
293
+ // the conversation's effort, not at the one remembered for the profile
294
+ const inherited = childReasoning();
295
+ if (inherited !== null)
296
+ return { reasoning: inherited };
280
297
  if (resolved === null || merged.models?.[resolved.name] !== resolved.profile)
281
298
  return {};
282
299
  const effort = startingEffort(resolved.profile, preferences().effort?.[resolved.name]);
@@ -370,3 +387,20 @@ export async function createCodingAgent(sessionId, input, modelFlag) {
370
387
  };
371
388
  return createAgent(definition);
372
389
  }
390
+ /** 0.49.0 C1: the effort the subagent extension hands a child running on its
391
+ * conversation's profile (KISO_CHILD_REASONING). Read only in a child — a
392
+ * top-level kiso ignores a stray variable — and only in its known shape. */
393
+ function childReasoning() {
394
+ if ((Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0) < 1)
395
+ return null;
396
+ const raw = process.env.KISO_CHILD_REASONING;
397
+ if (raw === undefined || raw === "")
398
+ return null;
399
+ try {
400
+ const r = JSON.parse(raw);
401
+ return typeof r.thinking === "string" && typeof r.effort === "string" ? { thinking: r.thinking, effort: r.effort } : null;
402
+ }
403
+ catch {
404
+ return null;
405
+ }
406
+ }
package/dist/dispatch.js CHANGED
@@ -880,7 +880,7 @@ export function dispatch(line, ctx) {
880
880
  if (want !== wasOn)
881
881
  body.dontAskNotice(`don't ask → ${want ? "on" : "off"}`, want);
882
882
  else
883
- body.notice(`don't ask → ${want ? "on" : "off"}`);
883
+ body.dontAskNotice(`don't ask → ${want ? "on" : "off"}`, want, true);
884
884
  ctx.paintIdle();
885
885
  }
886
886
  ctx.input.prompt();
@@ -1087,6 +1087,9 @@ export function dispatch(line, ctx) {
1087
1087
  // session still names the outgoing profile
1088
1088
  const prevProfile = currentProfileName !== null ? configModels[currentProfileName] : undefined;
1089
1089
  const payeeBefore = prevProfile !== undefined ? payeeOf(prevProfile) : null;
1090
+ // the notices round (owner, 2026-10-14): the MODEL row says what it
1091
+ // switched FROM, read before the outgoing binding is replaced
1092
+ const fromName = currentProfileName ?? agentModel;
1090
1093
  ctx.session.setModelBinding(binding);
1091
1094
  setLastBinding(binding);
1092
1095
  setAgentModel(profile.model, profile.baseUrl);
@@ -1117,14 +1120,15 @@ export function dispatch(line, ctx) {
1117
1120
  // (and the owner's) read `gpt-6-astra · CH 92%`, the new
1118
1121
  // model beside the previous model's figure.
1119
1122
  ctx.modelSwitched();
1120
- body.notice(`model → ${profName} (${profile.model}${providerLabel(profile.baseUrl, profile.upstream) === "" ? "" : ` ${providerLabel(profile.baseUrl, profile.upstream)}`}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
1123
+ const hostLabel = providerLabel(profile.baseUrl, profile.upstream);
1124
+ body.modelNotice(`model → ${profName} (${profile.model}${hostLabel === "" ? "" : ` ${hostLabel}`}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`, fromName, profName, `${profile.model}${hostLabel === "" ? "" : ` ${hostLabel}`}${effortTok !== undefined ? ` \u00b7 ${effortTok}` : ""} \u00b7 takes effect on the next turn`);
1121
1125
  // MP-1 (0.40.7): when the switch changes WHO PAYS — another
1122
1126
  // endpoint, or another credential for it — say so, once,
1123
1127
  // under the switch line. A switch between two models on the
1124
1128
  // same account says nothing more.
1125
1129
  const payeeAfter = payeeOf(profile);
1126
1130
  if (payeeBefore !== null && payeeBefore !== payeeAfter)
1127
- body.notice(`paid by ${payeeAfter} from the next turn — was ${payeeBefore}`);
1131
+ body.metaNotice(`paid by ${payeeAfter} from the next turn — was ${payeeBefore}`, [{ label: "PAYER", sentence: `${payeeAfter} from the next turn \u00b7 was ${payeeBefore}` }]);
1128
1132
  }
1129
1133
  }
1130
1134
  }
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ import { browserCommand, launchCommand } from "./launch.js";
41
41
  import { createFauxProvider } from "@vincemakes/kiso-evals";
42
42
  import { isProtectedPath, protectedIdentity, PROTECTED_REFUSAL } from "@vincemakes/kiso-tools-node";
43
43
  import { OFFERED_MODES, applyModeState, envModeLayer, getDontAsk, modeDisplay, parseMode, resolveModeLayers } from "./mode.js";
44
- import { activeStoreDir, setActiveStoreDir, agentModel, atFiles, body, bodyLog, kisoHome, workspaceRoot, projectRoot, ownSessionsDir, setOpenSessionFolder, builtInExtensions, currentFaux, dock, loadedExtensions, mergedConfig, mergedTempPaths, modelChoice, projectExtensions, configModels, configuredWindow, agentBaseUrl, currentModelName, currentAgentExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setCurrentProfileName, setExtensionLists, protectedFiles, setMergedConfig, setModelChoice, setSessionStore, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift, loadedSkillsCatalog, queuedSwitchLines } from "./state.js";
44
+ import { activeStoreDir, setActiveStoreDir, agentModel, atFiles, body, bodyLog, kisoHome, workspaceRoot, projectRoot, ownSessionsDir, setOpenSessionFolder, builtInExtensions, currentFaux, dock, loadedExtensions, mergedConfig, mergedTempPaths, modelChoice, projectExtensions, configModels, configuredWindow, agentBaseUrl, currentModelName, currentAgentExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setCurrentProfileName, setExtensionLists, setLiveSession, protectedFiles, setMergedConfig, setModelChoice, setSessionStore, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift, loadedSkillsCatalog, queuedSwitchLines } from "./state.js";
45
45
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
46
46
  import { barFor, chat, contextWindowTokens, displayCtxRatio, microcompactThresholdFor, statusModelLabel } from "./chat.js";
47
47
  import { preferences, usePreferences } from "./preferences.js";
@@ -841,6 +841,8 @@ function bindRestoredSession(session) {
841
841
  bodyLog(`binding changed — now on ${session.model}: ${ack.reasons.join("; ")}; reasoning reset to defaults${prior === "" ? "" : ` (was ${prior})`}`);
842
842
  }
843
843
  setAgentModel(session.model, session.baseUrl);
844
+ // 0.49.0 C1: the session a delegation reads its conversation's binding from
845
+ setLiveSession(session);
844
846
  session.setMicrocompactThreshold(microcompactThresholdFor({
845
847
  model: session.model,
846
848
  ...(session.baseUrl !== undefined ? { baseUrl: session.baseUrl } : {}),
@@ -1112,7 +1114,7 @@ async function chatLoop(first, firstId, input, autoCompact) {
1112
1114
  return r.status === 0 ? r.stdout.trim() : null;
1113
1115
  });
1114
1116
  if (tmuxHint !== null)
1115
- body.notice(tmuxHint);
1117
+ body.metaNotice(tmuxHint, [{ label: "HINT", sentence: tmuxHint }]);
1116
1118
  void announceUpdate();
1117
1119
  }
1118
1120
  else {
@@ -1257,6 +1259,13 @@ async function main() {
1257
1259
  // first createCodingAgent (the tier extensions read `current` live). The flag
1258
1260
  // is stripped from the positional args, so it works in any position.
1259
1261
  const args = process.argv.slice(2);
1262
+ // 0.49.0 B6.2: `kiso apply-patch <taskDir>` adopts a writer's collected
1263
+ // patch into the workspace — a one-shot effect, no session, no model
1264
+ if (args[0] === "apply-patch") {
1265
+ const { applyPatch } = await import("./apply-patch.js");
1266
+ process.exitCode = await applyPatch(args.slice(1));
1267
+ return;
1268
+ }
1260
1269
  // XP-1: --accept-drift (a flag, never an env var) authorizes opening a
1261
1270
  // session whose recorded profile materially drifted — the
1262
1271
  // acknowledgement is recorded as a new revision by the runtime.
@@ -1847,6 +1856,7 @@ async function main() {
1847
1856
  " kiso resume pick a session to continue (TTY picker)\n" +
1848
1857
  " kiso resume <id> [prompt] continue a session (one-shot)\n" +
1849
1858
  " kiso sessions [--all|--current] list durable sessions (a terminal shows this workspace's by default)\n" +
1859
+ " kiso apply-patch <dir> adopt a subagent's collected patch (its handoff prints the command)\n" +
1850
1860
  " kiso login <provider> anthropic|openai|deepseek|zai: store an API key (hidden prompt, or stdin when piped);\n" +
1851
1861
  " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
1852
1862
  " kiso login --endpoint <url> a gateway: store its API key for that URL's origin only\n" +
package/dist/replay.d.ts CHANGED
@@ -34,6 +34,7 @@ export interface ReplayBody {
34
34
  isError: boolean;
35
35
  reason?: string | null;
36
36
  untimed?: boolean;
37
+ created?: boolean;
37
38
  }): void;
38
39
  notice(text: string): void;
39
40
  metaNotice(text: string, rows: readonly {
package/dist/replay.js CHANGED
@@ -98,7 +98,8 @@ function replayTurn(body, turn, tasks) {
98
98
  if (m !== null)
99
99
  reason = m[1];
100
100
  }
101
- body.toolResult(String(e.callId), { content: text, isError: e.isError === true, reason, untimed: true });
101
+ const created = (e.tags ?? []).includes("created");
102
+ body.toolResult(String(e.callId), { content: text, isError: e.isError === true, reason, untimed: true, ...(created ? { created: true } : {}) });
102
103
  results.add(String(e.callId));
103
104
  break;
104
105
  }
package/dist/state.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { Dock, type AtItem, type Body, type PanelVerdict, type PanelView, type SaferAnswer, type SessionCardView } from "@vincemakes/kiso-tui";
9
9
  import type { TaskDeliveryItem } from "@vincemakes/kiso-core";
10
10
  import type { KisoExtension, StoreRecord } from "@vincemakes/kiso-runtime";
11
+ import type { ReasoningSetting } from "@vincemakes/kiso-runtime/internal";
11
12
  import { TaskManager } from "@vincemakes/kiso-runtime/internal";
12
13
  import { type ShellTasks } from "@vincemakes/kiso-tools-node";
13
14
  /** finding #11: KISO_HOME is the ONE root — every default path derives from
@@ -34,6 +35,7 @@ export declare function codingToolOptions(): {
34
35
  readonly secretEnvNames: readonly string[];
35
36
  readonly protectedFiles: readonly string[];
36
37
  readonly tasks: (sessionId: string | undefined) => ShellTasks | undefined;
38
+ readonly observedRevision: (sessionId: string | undefined, full: string, kind?: "edit" | "write") => string | undefined;
37
39
  readonly limits?: {
38
40
  readonly minForegroundMs: number;
39
41
  };
@@ -44,6 +46,30 @@ export declare function taskWhatOf(sessionId: string | undefined): (taskId: stri
44
46
  export declare function lostToldOf(sessionId: string): Set<string>;
45
47
  /** The transcript lines a delivery shows: a loss already told is left out. */
46
48
  export declare function deliveryLines(sessionId: string, items: readonly TaskDeliveryItem[]): string[];
49
+ /** 0.49.0 C1: the live sessions by id — what a delegation reads its
50
+ * conversation's binding from. Every entry registers its session
51
+ * (index.ts bindRestoredSession: fresh, resumed, switched, -p). */
52
+ interface LiveSession {
53
+ readonly id: string;
54
+ readonly model: string;
55
+ readonly baseUrl: string | undefined;
56
+ readonly profileName: string | null;
57
+ readonly reasoning: ReasoningSetting;
58
+ }
59
+ export declare function setLiveSession(session: LiveSession): void;
60
+ /** 0.49.0 C1: the conversation's live binding, for a child that names no
61
+ * model — the profile its session is bound to (the session's own record,
62
+ * never the display mark, which /reload and /resume clear), else the one
63
+ * configured profile with the session's (model, baseUrl), else none — and
64
+ * the effort the session runs at. */
65
+ export declare function bindingFor(sessionId: string | undefined): {
66
+ readonly profile: string | null;
67
+ readonly model: string;
68
+ readonly reasoning: ReasoningSetting;
69
+ } | null;
70
+ /** The one configured profile whose model and endpoint are these — null
71
+ * when none matches, or several do (a guess is never a binding). */
72
+ export declare function profileByIdentity(model: string, baseUrl: string | undefined): string | null;
47
73
  export declare function tasksFor(sessionId: string | undefined): TaskManager | undefined;
48
74
  export declare function setExitTasks(choice: "stop" | "leave"): void;
49
75
  /** 3e: the live tasks of every session this process opened — a runner's
@@ -416,3 +442,5 @@ export declare function setLastBinding(b: LastBinding): void;
416
442
  export declare function lastBinding(): LastBinding | null;
417
443
  export declare function setAcceptDrift(v: boolean): void;
418
444
  export declare function acceptDrift(): boolean;
445
+ export declare function observedRevisionFromLog(sessionId: string | undefined, full: string, kind?: "edit" | "write"): string | undefined;
446
+ export {};
package/dist/state.js CHANGED
@@ -8,11 +8,13 @@
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { readdirSync, readFileSync, realpathSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { AT_CAP, AT_SKIP, Dock } from "@vincemakes/kiso-tui";
14
+ import { projectMessages } from "@vincemakes/kiso-core";
14
15
  import { TaskManager } from "@vincemakes/kiso-runtime/internal";
15
16
  import { processTaskBackend } from "@vincemakes/kiso-tools-node";
17
+ import { collectWriter } from "@vincemakes/kiso-subagent-ext";
16
18
  import { canonicalPath, LEGACY_SESSIONS_DIR, projectDirFor, projectLayoutActive } from "./projects.js";
17
19
  import { taskNoticeLines, taskWhat } from "./task-notice.js";
18
20
  /** finding #11: KISO_HOME is the ONE root — every default path derives from
@@ -52,6 +54,7 @@ export function codingToolOptions() {
52
54
  secretEnvNames: secretEnvNamesOf(configModels),
53
55
  protectedFiles: protectedFiles(),
54
56
  tasks: tasksFor,
57
+ observedRevision: observedRevisionFromLog,
55
58
  ...(Number.isFinite(minForeground) && minForeground >= 0 ? { limits: { minForegroundMs: minForeground } } : {}),
56
59
  };
57
60
  }
@@ -88,13 +91,38 @@ export function deliveryLines(sessionId, items) {
88
91
  const told = lostToldOf(sessionId);
89
92
  return taskNoticeLines(items.filter((i) => !(i.transition === "unknown" && told.has(i.taskId))), taskWhatOf(sessionId));
90
93
  }
94
+ const liveSessions = new Map();
95
+ export function setLiveSession(session) {
96
+ liveSessions.set(session.id, session);
97
+ }
98
+ /** 0.49.0 C1: the conversation's live binding, for a child that names no
99
+ * model — the profile its session is bound to (the session's own record,
100
+ * never the display mark, which /reload and /resume clear), else the one
101
+ * configured profile with the session's (model, baseUrl), else none — and
102
+ * the effort the session runs at. */
103
+ export function bindingFor(sessionId) {
104
+ const s = sessionId === undefined ? undefined : liveSessions.get(sessionId);
105
+ if (s === undefined)
106
+ return null;
107
+ const profile = s.profileName !== null && configModels[s.profileName] !== undefined ? s.profileName : profileByIdentity(s.model, s.baseUrl);
108
+ return { profile, model: s.model, reasoning: s.reasoning };
109
+ }
110
+ /** The one configured profile whose model and endpoint are these — null
111
+ * when none matches, or several do (a guess is never a binding). */
112
+ export function profileByIdentity(model, baseUrl) {
113
+ const hits = Object.entries(configModels).filter(([, p]) => p.model === model && p.baseUrl === baseUrl);
114
+ return hits.length === 1 ? hits[0][0] : null;
115
+ }
91
116
  export function tasksFor(sessionId) {
92
117
  if (sessionId === undefined || activeStoreDir === "")
93
118
  return undefined;
94
119
  let manager = taskManagers.get(sessionId);
95
120
  if (manager === undefined) {
96
121
  taskBackend ??= processTaskBackend();
97
- manager = new TaskManager({ root: join(activeStoreDir, `${sessionId}.tasks`), backend: taskBackend });
122
+ // 0.49.0 B: a writer's end is its collection — the subagent
123
+ // extension's collector, called once its process has ended
124
+ const created = new TaskManager({ root: join(activeStoreDir, `${sessionId}.tasks`), backend: taskBackend, collect: (info) => collectWriter(info, created) });
125
+ manager = created;
98
126
  manager.observe();
99
127
  taskManagers.set(sessionId, manager);
100
128
  }
@@ -612,3 +640,87 @@ export function setAcceptDrift(v) {
612
640
  export function acceptDrift() {
613
641
  return acceptDriftValue;
614
642
  }
643
+ /** O1 — the hidden observed revision. The revision the model last
644
+ * OBSERVED for a file, rebuilt from the session's MODEL-VISIBLE history
645
+ * (`projectMessages` over the durable log, so a receipt cleared by
646
+ * microcompaction or covered by a summary no longer counts): the last
647
+ * [rev:…] of a successful read_file / edit_file / write_file on that
648
+ * file, or of an edit_file stale refusal (it carries the current text).
649
+ * Never the disk's current revision — a stale write stays refused.
650
+ * `kind: "write"` (a whole-file replace) also needs visible whole-file
651
+ * knowledge: an unranged, uncapped read or an own write, followed only
652
+ * by own mutations. The log is per session, so a child session (a
653
+ * subagent) inherits no observation. */
654
+ function realOrResolved(p) {
655
+ try {
656
+ return realpathSync(p);
657
+ }
658
+ catch {
659
+ return p;
660
+ }
661
+ }
662
+ export function observedRevisionFromLog(sessionId, full, kind = "edit") {
663
+ if (sessionId === undefined || activeStoreDir === "")
664
+ return undefined;
665
+ let text;
666
+ try {
667
+ text = readFileSync(join(activeStoreDir, `${sessionId}.jsonl`), "utf8");
668
+ }
669
+ catch {
670
+ return undefined;
671
+ }
672
+ const events = [];
673
+ for (const line of text.split("\n")) {
674
+ if (line === "")
675
+ continue;
676
+ try {
677
+ const e = JSON.parse(line).event;
678
+ if (e !== undefined)
679
+ events.push(e);
680
+ }
681
+ catch { /* a torn tail line */ }
682
+ }
683
+ let messages;
684
+ try {
685
+ messages = projectMessages(events);
686
+ }
687
+ catch {
688
+ return undefined;
689
+ }
690
+ const target = realOrResolved(full);
691
+ const calls = new Map();
692
+ let rev;
693
+ let wholeKnown = false;
694
+ for (const m of messages) {
695
+ if (m.role === "assistant") {
696
+ for (const b of m.blocks) {
697
+ if (b.type === "tool_use" && typeof b.input.path === "string") {
698
+ calls.set(b.callId, { name: b.name, full: realOrResolved(resolve(process.cwd(), b.input.path)), ranged: b.input.offset !== undefined || b.input.limit !== undefined });
699
+ }
700
+ }
701
+ }
702
+ else if (m.role === "tool") {
703
+ const c = calls.get(m.callId);
704
+ if (c === undefined || c.full !== target)
705
+ continue;
706
+ const body = typeof m.content === "string" ? m.content : m.content.map((x) => (x.type === "text" ? x.text : "")).join("");
707
+ const hit = [...body.matchAll(/\[(rev:[0-9a-f]{16})\]/g)].pop();
708
+ if (hit === undefined)
709
+ continue;
710
+ if (!m.isError && (c.name === "read_file" || c.name === "edit_file" || c.name === "write_file")) {
711
+ rev = hit[1];
712
+ if (c.name === "read_file")
713
+ wholeKnown = !c.ranged && !body.includes("(call again with offset=") && !body.includes("[output capped at");
714
+ else if (c.name === "write_file")
715
+ wholeKnown = true;
716
+ }
717
+ else if (m.isError && c.name === "edit_file" && body.includes(" changed since ")) {
718
+ rev = hit[1];
719
+ wholeKnown = false;
720
+ }
721
+ }
722
+ }
723
+ if (kind === "write" && !wholeKnown)
724
+ return undefined;
725
+ return rev;
726
+ }
@@ -7,6 +7,11 @@ import type { NoticeMark } from "@vincemakes/kiso-tui";
7
7
  * (<what>) — ` — the parenthesis only when the command is known — and the
8
8
  * LAST ` — ` part opening with "it may still be running". One line each. */
9
9
  export declare function taskNoticeLines(items: readonly TaskDeliveryItem[], what?: (taskId: string) => string | undefined): string[];
10
+ /** Finding 0480-F9: the chain budget is spent — said to the person once,
11
+ * so a chain that went quiet on its budget does not look idle. */
12
+ export declare function budgetSpentLine(info: {
13
+ readonly wakes: number;
14
+ }): string;
10
15
  /** A task's state in the person's words. A stop that was asked for and is
11
16
  * not yet confirmed reads `stopping` — never `stopped` before the journal
12
17
  * says so; a task kiso lost track of says so (Amendment 8). */
@@ -36,6 +36,11 @@ export function taskNoticeLines(items, what) {
36
36
  }
37
37
  return rows;
38
38
  }
39
+ /** Finding 0480-F9: the chain budget is spent — said to the person once,
40
+ * so a chain that went quiet on its budget does not look idle. */
41
+ export function budgetSpentLine(info) {
42
+ return `✦ chain budget spent — ${info.wakes} autonomous wake${info.wakes === 1 ? "" : "s"} since your last message · finished tasks wait for you`;
43
+ }
39
44
  /** A task's state in the person's words. A stop that was asked for and is
40
45
  * not yet confirmed reads `stopping` — never `stopped` before the journal
41
46
  * says so; a task kiso lost track of says so (Amendment 8). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "kiso-code \u2014 the coding agent on kiso, the durable runtime for AI agents: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,19 +18,19 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.48.0",
22
- "@vincemakes/kiso-core": "0.48.0",
23
- "@vincemakes/kiso-evals": "0.48.0",
24
- "@vincemakes/kiso-mcp-ext": "0.48.0",
25
- "@vincemakes/kiso-provider-anthropic": "0.48.0",
26
- "@vincemakes/kiso-provider-openai": "0.48.0",
27
- "@vincemakes/kiso-provider-openai-responses": "0.48.0",
28
- "@vincemakes/kiso-runtime": "0.48.0",
29
- "@vincemakes/kiso-skills-ext": "0.48.0",
30
- "@vincemakes/kiso-subagent-ext": "0.48.0",
31
- "@vincemakes/kiso-tools-node": "0.48.0",
32
- "@vincemakes/kiso-tui": "0.48.0",
33
- "@vincemakes/kiso-tui-cells": "0.48.0"
21
+ "@vincemakes/kiso-ask-ext": "0.50.0",
22
+ "@vincemakes/kiso-core": "0.50.0",
23
+ "@vincemakes/kiso-evals": "0.50.0",
24
+ "@vincemakes/kiso-mcp-ext": "0.50.0",
25
+ "@vincemakes/kiso-provider-anthropic": "0.50.0",
26
+ "@vincemakes/kiso-provider-openai": "0.50.0",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.50.0",
28
+ "@vincemakes/kiso-runtime": "0.50.0",
29
+ "@vincemakes/kiso-skills-ext": "0.50.0",
30
+ "@vincemakes/kiso-subagent-ext": "0.50.0",
31
+ "@vincemakes/kiso-tools-node": "0.50.0",
32
+ "@vincemakes/kiso-tui": "0.50.0",
33
+ "@vincemakes/kiso-tui-cells": "0.50.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^26.1.2",