@vincemakes/kiso-code 0.48.0 → 0.49.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.
@@ -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
@@ -60,8 +60,9 @@ ask,
60
60
  secretEnvNames = []) {
61
61
  // ADR-0058 3d: the session's task manager makes `delegate` offer
62
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)])]);
63
+ // are the extension's defaults). 0.49.0 C1: the session's live binding
64
+ // is the model a child runs on when its task names none.
65
+ const all = await Promise.all([createMcp({ secretEnvNames }), createSkills(), createSubagent({ tasks: tasksFor, currentBinding: bindingFor, ...joinOption() }), ...(ask === undefined ? [] : [createAsk(ask).then(offInDontAsk)])]);
65
66
  const shadowed = all.filter((b) => user.some((u) => u.name === b.name));
66
67
  for (const s of shadowed) {
67
68
  console.error(`[extensions] user extension "${s.name}" shadows the built-in — the built-in is not loaded`);
@@ -73,3 +74,9 @@ secretEnvNames = []) {
73
74
  }
74
75
  return all.filter((b) => !shadowed.includes(b));
75
76
  }
77
+ /** 0.49.0 A: a reader delegation waits as long as a shell command does —
78
+ * one knob for "the foreground wait" (KISO_MIN_FOREGROUND_MS reaches both). */
79
+ function joinOption() {
80
+ const floor = codingToolOptions().limits?.minForegroundMs;
81
+ return floor !== undefined ? { joinMs: floor } : {};
82
+ }
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`;
@@ -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 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 new files or\n full rewrites.\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.";
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
@@ -28,8 +28,8 @@ What you can reach:
28
28
  Tool discipline:
29
29
  - READ BEFORE YOU EDIT. For any file you are about to change, read it
30
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.
31
+ - Use edit_file for targeted changes and write_file for new files or
32
+ full rewrites.
33
33
  - Be careful — shell has side effects and may take time.
34
34
  - search_text and list_dir are cheap — locate first, then read ranges
35
35
  with read_file offset/limit; never read a whole large file in one call.
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/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 } : {}),
@@ -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/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
@@ -44,6 +45,30 @@ export declare function taskWhatOf(sessionId: string | undefined): (taskId: stri
44
45
  export declare function lostToldOf(sessionId: string): Set<string>;
45
46
  /** The transcript lines a delivery shows: a loss already told is left out. */
46
47
  export declare function deliveryLines(sessionId: string, items: readonly TaskDeliveryItem[]): string[];
48
+ /** 0.49.0 C1: the live sessions by id — what a delegation reads its
49
+ * conversation's binding from. Every entry registers its session
50
+ * (index.ts bindRestoredSession: fresh, resumed, switched, -p). */
51
+ interface LiveSession {
52
+ readonly id: string;
53
+ readonly model: string;
54
+ readonly baseUrl: string | undefined;
55
+ readonly profileName: string | null;
56
+ readonly reasoning: ReasoningSetting;
57
+ }
58
+ export declare function setLiveSession(session: LiveSession): void;
59
+ /** 0.49.0 C1: the conversation's live binding, for a child that names no
60
+ * model — the profile its session is bound to (the session's own record,
61
+ * never the display mark, which /reload and /resume clear), else the one
62
+ * configured profile with the session's (model, baseUrl), else none — and
63
+ * the effort the session runs at. */
64
+ export declare function bindingFor(sessionId: string | undefined): {
65
+ readonly profile: string | null;
66
+ readonly model: string;
67
+ readonly reasoning: ReasoningSetting;
68
+ } | null;
69
+ /** The one configured profile whose model and endpoint are these — null
70
+ * when none matches, or several do (a guess is never a binding). */
71
+ export declare function profileByIdentity(model: string, baseUrl: string | undefined): string | null;
47
72
  export declare function tasksFor(sessionId: string | undefined): TaskManager | undefined;
48
73
  export declare function setExitTasks(choice: "stop" | "leave"): void;
49
74
  /** 3e: the live tasks of every session this process opened — a runner's
@@ -416,3 +441,4 @@ export declare function setLastBinding(b: LastBinding): void;
416
441
  export declare function lastBinding(): LastBinding | null;
417
442
  export declare function setAcceptDrift(v: boolean): void;
418
443
  export declare function acceptDrift(): boolean;
444
+ export {};
package/dist/state.js CHANGED
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import { AT_CAP, AT_SKIP, Dock } from "@vincemakes/kiso-tui";
14
14
  import { TaskManager } from "@vincemakes/kiso-runtime/internal";
15
15
  import { processTaskBackend } from "@vincemakes/kiso-tools-node";
16
+ import { collectWriter } from "@vincemakes/kiso-subagent-ext";
16
17
  import { canonicalPath, LEGACY_SESSIONS_DIR, projectDirFor, projectLayoutActive } from "./projects.js";
17
18
  import { taskNoticeLines, taskWhat } from "./task-notice.js";
18
19
  /** finding #11: KISO_HOME is the ONE root — every default path derives from
@@ -88,13 +89,38 @@ export function deliveryLines(sessionId, items) {
88
89
  const told = lostToldOf(sessionId);
89
90
  return taskNoticeLines(items.filter((i) => !(i.transition === "unknown" && told.has(i.taskId))), taskWhatOf(sessionId));
90
91
  }
92
+ const liveSessions = new Map();
93
+ export function setLiveSession(session) {
94
+ liveSessions.set(session.id, session);
95
+ }
96
+ /** 0.49.0 C1: the conversation's live binding, for a child that names no
97
+ * model — the profile its session is bound to (the session's own record,
98
+ * never the display mark, which /reload and /resume clear), else the one
99
+ * configured profile with the session's (model, baseUrl), else none — and
100
+ * the effort the session runs at. */
101
+ export function bindingFor(sessionId) {
102
+ const s = sessionId === undefined ? undefined : liveSessions.get(sessionId);
103
+ if (s === undefined)
104
+ return null;
105
+ const profile = s.profileName !== null && configModels[s.profileName] !== undefined ? s.profileName : profileByIdentity(s.model, s.baseUrl);
106
+ return { profile, model: s.model, reasoning: s.reasoning };
107
+ }
108
+ /** The one configured profile whose model and endpoint are these — null
109
+ * when none matches, or several do (a guess is never a binding). */
110
+ export function profileByIdentity(model, baseUrl) {
111
+ const hits = Object.entries(configModels).filter(([, p]) => p.model === model && p.baseUrl === baseUrl);
112
+ return hits.length === 1 ? hits[0][0] : null;
113
+ }
91
114
  export function tasksFor(sessionId) {
92
115
  if (sessionId === undefined || activeStoreDir === "")
93
116
  return undefined;
94
117
  let manager = taskManagers.get(sessionId);
95
118
  if (manager === undefined) {
96
119
  taskBackend ??= processTaskBackend();
97
- manager = new TaskManager({ root: join(activeStoreDir, `${sessionId}.tasks`), backend: taskBackend });
120
+ // 0.49.0 B: a writer's end is its collection — the subagent
121
+ // extension's collector, called once its process has ended
122
+ const created = new TaskManager({ root: join(activeStoreDir, `${sessionId}.tasks`), backend: taskBackend, collect: (info) => collectWriter(info, created) });
123
+ manager = created;
98
124
  manager.observe();
99
125
  taskManagers.set(sessionId, manager);
100
126
  }
@@ -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.49.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.49.0",
22
+ "@vincemakes/kiso-core": "0.49.0",
23
+ "@vincemakes/kiso-evals": "0.49.0",
24
+ "@vincemakes/kiso-mcp-ext": "0.49.0",
25
+ "@vincemakes/kiso-provider-anthropic": "0.49.0",
26
+ "@vincemakes/kiso-provider-openai": "0.49.0",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.49.0",
28
+ "@vincemakes/kiso-runtime": "0.49.0",
29
+ "@vincemakes/kiso-skills-ext": "0.49.0",
30
+ "@vincemakes/kiso-subagent-ext": "0.49.0",
31
+ "@vincemakes/kiso-tools-node": "0.49.0",
32
+ "@vincemakes/kiso-tui": "0.49.0",
33
+ "@vincemakes/kiso-tui-cells": "0.49.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^26.1.2",