@vincemakes/kiso-code 0.46.1 → 0.46.2

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/dist/chat.js CHANGED
@@ -8,8 +8,8 @@ import { readFileSync, statSync } from "node:fs";
8
8
  import { escapeTerminal, cacheHitPct, decodeRate, idleStatus, palette, renderEvent, renderRecap, runningStatus, toolTarget, STATUS_GLYPHS, kUnit, namedPickView, } from "@vincemakes/kiso-tui";
9
9
  import { askView, coldResumeLine, coldResumeView, deletionRiskHint, editFileDiff, writeFileDiff } from "@vincemakes/kiso-tui";
10
10
  import { canonicalTargetPath, isProtectedPath, protectedIdentity, shellProgressPath } from "@vincemakes/kiso-tools-node";
11
- import { liveTasks, mergedConfig, queuedSwitchLines, seenUnknownTasks, setExitTasks, tasksFor } from "./state.js";
12
- import { taskCounts, taskNoticeRow } from "./task-notice.js";
11
+ import { deliveryLines, liveTasks, lostToldOf, mergedConfig, queuedSwitchLines, setExitTasks, tasksFor, taskWhatOf } from "./state.js";
12
+ import { taskCounts, taskNoticeLines } from "./task-notice.js";
13
13
  import { echoText } from "@vincemakes/kiso-tui-cells/render";
14
14
  import { canonicalizeUsage, RunClosedError } from "@vincemakes/kiso-runtime";
15
15
  import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
@@ -953,7 +953,8 @@ onInputLanded) {
953
953
  // any run that carries one, as replay.ts does for old logs.
954
954
  // ADR-0058: a task notice is a row, never the person's chip
955
955
  if (ev.via?.kind === "tasks") {
956
- body.notice(taskNoticeRow(ev.via.items));
956
+ for (const row of deliveryLines(session.id, ev.via.items))
957
+ body.notice(row);
957
958
  break;
958
959
  }
959
960
  if (ev.source === "system") {
@@ -1536,7 +1537,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1536
1537
  const manager = tasksFor(session.id);
1537
1538
  if (manager === undefined)
1538
1539
  return undefined;
1539
- if (fresh || taskRow === null || Date.now() - taskRow.at > 1_000 || taskRow.seen !== seenUnknownTasks.size) {
1540
+ if (fresh || taskRow === null || Date.now() - taskRow.at > 1_000) {
1540
1541
  let list = [];
1541
1542
  try {
1542
1543
  list = manager.list();
@@ -1544,7 +1545,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1544
1545
  catch {
1545
1546
  // a corrupt journal is reported where it is read on purpose
1546
1547
  }
1547
- taskRow = { at: Date.now(), seen: seenUnknownTasks.size, counts: taskCounts(list, seenUnknownTasks) };
1548
+ taskRow = { at: Date.now(), counts: taskCounts(list) };
1548
1549
  }
1549
1550
  return taskRow.counts;
1550
1551
  };
@@ -1621,7 +1622,7 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1621
1622
  spentUsd = (spentUsd ?? 0) + usd;
1622
1623
  };
1623
1624
  const queueTurn = (line, via) => {
1624
- const slot = { line: via?.kind === "skill" ? via.line : via?.kind === "tasks" ? taskNoticeRow(via.items) : line, content: line, ...(via !== undefined ? { via } : {}), cancelled: false };
1625
+ const slot = { line: via?.kind === "skill" ? via.line : via?.kind === "tasks" ? (deliveryLines(session.id, via.items).join(" · ") || taskNoticeLines(via.items, taskWhatOf(session.id)).join(" · ")) : line, content: line, ...(via !== undefined ? { via } : {}), cancelled: false };
1625
1626
  pendingTurns.push(slot);
1626
1627
  queued += 1;
1627
1628
  chainRef.current = chainRef.current.then(async () => {
@@ -1706,6 +1707,15 @@ export async function chat(session, faux, input, autoCompact, nav, seed) {
1706
1707
  : session.useTasks(taskManager, {
1707
1708
  wake: mergedConfig.taskWake !== false,
1708
1709
  onWake: (w) => queueTurn(w.content, w.via),
1710
+ // Amendment 8: a loss is said when kiso concludes it — the
1711
+ // model's notice still rides the next run, and is not said again
1712
+ onLost: (ids) => {
1713
+ const told = lostToldOf(session.id);
1714
+ for (const id of ids)
1715
+ told.add(id);
1716
+ for (const line of taskNoticeLines(ids.map((taskId) => ({ taskId, transition: "unknown" })), taskWhatOf(session.id)))
1717
+ body.notice(line);
1718
+ },
1709
1719
  ...(Number.isFinite(autoDetachFromEnv) && autoDetachFromEnv >= 0 ? { autoDetachMinAgeMs: autoDetachFromEnv } : {}),
1710
1720
  });
1711
1721
  // 3e: a task's change repaints the row it is counted on
package/dist/config.d.ts CHANGED
@@ -151,7 +151,8 @@ export declare function loadUserConfig(): KisoConfig | null;
151
151
  /** The project config — read ONLY when the project's .kiso passed the E3
152
152
  * trust gate (an untrusted project's config is never even read). */
153
153
  export declare function loadProjectConfig(cwd: string, trusted: boolean): KisoConfig | null;
154
- /** Merge: project wins over user (each layer's own keys only). */
154
+ /** Merge: a project's keys win over the user's (each layer's own keys
155
+ * only) — except that a project may only tighten (assertProjectTightens). */
155
156
  export declare function mergeConfigs(user: KisoConfig | null, project: KisoConfig | null): KisoConfig;
156
157
  /** What a profile's sign-in actually IS. The two shapes are not
157
158
  * interchangeable: an API key is a string an adapter holds, an OAuth
@@ -210,6 +211,9 @@ export declare function resolveContextWindow(merged: KisoConfig, profile?: Model
210
211
  /** Auto-compact: env (KISO_AUTO_COMPACT) > config.autoCompact > off. An
211
212
  * invalid env value is OFF (it set the env → it wins, and it is invalid). */
212
213
  export declare function resolveAutoCompact(merged: KisoConfig): AutoCompactConfig | undefined;
213
- /** Project-trust policy: config.projectTrust (project wins over user) —
214
- * "ask" (the E3 gate, default) or "never" (the gate auto-refuses). */
214
+ /** Project-trust policy: the USER config's projectTrust — "ask" (the E3
215
+ * gate, default) or "never" (the gate auto-refuses). The gate passes
216
+ * loadUserConfig() alone (trust-ui.ts): a project never relaxes its own
217
+ * gate, and it could not anyway, since an untrusted project's config is
218
+ * never read. */
215
219
  export declare function resolveProjectTrustPolicy(merged: KisoConfig): "ask" | "never";
package/dist/config.js CHANGED
@@ -153,6 +153,10 @@ export function parseConfig(text, source) {
153
153
  out.model = obj.model;
154
154
  }
155
155
  if (obj.models !== undefined) {
156
+ // 0.46.2: a profile names where requests go and which of the
157
+ // person's env vars is the key — a repository must never choose that
158
+ if (source.startsWith("<cwd>"))
159
+ fail("models", 'belongs in the USER config — a project may pick one of your profiles with "model", never define one');
156
160
  if (obj.models === null || typeof obj.models !== "object" || Array.isArray(obj.models))
157
161
  fail("models", "expected an object of profiles");
158
162
  const models = {};
@@ -261,15 +265,43 @@ export function loadProjectConfig(cwd, trusted) {
261
265
  return null;
262
266
  return readConfigFile(join(cwd, ".kiso", "config.json"), "<cwd>/.kiso/config.json");
263
267
  }
264
- /** Merge: project wins over user (each layer's own keys only). */
268
+ /** 0.46.2 — how much a tier lets run without a person, strictest first;
269
+ * `manual` sits with `default` (both ask). */
270
+ const MODE_LOOSENESS = { plan: 0, manual: 1, default: 1, "accept-edits": 2, "full-access": 3 };
271
+ /**
272
+ * 0.46.2 — a repository's config may only make kiso STRICTER. A project
273
+ * mode looser than the user level (the user config's mode, else
274
+ * `default`) is refused, and a project may turn don't-ask on but never off
275
+ * — including the switch the old `dontAsk` mode spelling carries. Flags
276
+ * and env are the person's own choice and are not judged here.
277
+ */
278
+ function assertProjectTightens(u, p) {
279
+ const where = "config <cwd>/.kiso/config.json";
280
+ const base = parseMode(u.mode ?? "default");
281
+ const proj = p.mode !== undefined ? parseMode(p.mode) : undefined;
282
+ if (proj !== undefined && base !== undefined && MODE_LOOSENESS[proj.mode] > MODE_LOOSENESS[base.mode]) {
283
+ const than = u.mode !== undefined ? `your config's "${u.mode}"` : "the default";
284
+ throw new ConfigError(`${where}: mode — "${p.mode}" is looser than ${than}: a project can make the mode stricter, never looser`);
285
+ }
286
+ if (p.dontAsk === false) {
287
+ throw new ConfigError(`${where}: dontAsk — a project can turn don't-ask on, never off`);
288
+ }
289
+ if (base?.dontAsk === true && proj !== undefined && proj.dontAsk !== true && p.dontAsk !== true) {
290
+ throw new ConfigError(`${where}: mode — "${p.mode}" would turn off the don't-ask switch your config's "${u.mode}" carries: a project can turn don't-ask on, never off`);
291
+ }
292
+ }
293
+ /** Merge: a project's keys win over the user's (each layer's own keys
294
+ * only) — except that a project may only tighten (assertProjectTightens). */
265
295
  export function mergeConfigs(user, project) {
266
296
  const u = user ?? {};
267
297
  const p = project ?? {};
298
+ assertProjectTightens(u, p);
268
299
  return {
269
300
  ...(u.model !== undefined ? { model: u.model } : {}),
270
301
  ...(p.model !== undefined ? { model: p.model } : {}),
302
+ // 0.46.2: profiles come from the user config only — parseConfig
303
+ // refuses a project's `models`, so there is nothing here to merge
271
304
  ...(u.models !== undefined ? { models: u.models } : {}),
272
- ...(p.models !== undefined ? { models: { ...u.models, ...p.models } } : u.models !== undefined ? { models: u.models } : {}),
273
305
  ...(u.mode !== undefined ? { mode: u.mode } : {}),
274
306
  ...(p.mode !== undefined ? { mode: p.mode } : {}),
275
307
  ...(u.dontAsk !== undefined ? { dontAsk: u.dontAsk } : {}),
@@ -521,8 +553,11 @@ export function resolveAutoCompact(merged) {
521
553
  }
522
554
  return merged.autoCompact;
523
555
  }
524
- /** Project-trust policy: config.projectTrust (project wins over user) —
525
- * "ask" (the E3 gate, default) or "never" (the gate auto-refuses). */
556
+ /** Project-trust policy: the USER config's projectTrust — "ask" (the E3
557
+ * gate, default) or "never" (the gate auto-refuses). The gate passes
558
+ * loadUserConfig() alone (trust-ui.ts): a project never relaxes its own
559
+ * gate, and it could not anyway, since an untrusted project's config is
560
+ * never read. */
526
561
  export function resolveProjectTrustPolicy(merged) {
527
562
  return merged.projectTrust ?? "ask";
528
563
  }
@@ -18,8 +18,8 @@ import { builtInLayer } from "./builtin.js";
18
18
  import { recordLearnedWindow, useLearnedWindows } from "./learned-windows.js";
19
19
  import { compactionDiscardedNotice, contextWindowTokens, knownContextWindow, unknownWindowNotice, windowLearnedNotice } from "./chat.js";
20
20
  import {} from "@vincemakes/kiso-core";
21
- import { guardSavedAllow, isProtectedWrite } from "./protected-writes.js";
22
- import { floorExtension, isDestructiveCall } from "./floor.js";
21
+ import { guardSavedAllow, neverInheritedCall } from "./protected-writes.js";
22
+ import { floorExtension } from "./floor.js";
23
23
  import { protectedShellExtension } from "./protected-shell.js";
24
24
  import { homedir } from "node:os";
25
25
  import { breakerExtension } from "./breaker.js";
@@ -230,9 +230,10 @@ export async function createCodingAgent(sessionId, input, modelFlag) {
230
230
  // 0.40.0: the read-only shell allow sits after the tiers — an allow from
231
231
  // it outranks a tier's ask and names itself in decidedBy.
232
232
  // 0.40.0: a saved allow never carries a write into .git/ or .kiso/, nor
233
- // a destructive shell command.
233
+ // a destructive shell command; 0.46.2: nor a command across the remote
234
+ // boundary (remote-boundary.ts).
234
235
  const workspaceRoot = () => codingToolOptions().workspaceRoot;
235
- const neverInherited = (call) => isProtectedWrite(call, workspaceRoot()) || isDestructiveCall(call);
236
+ const neverInherited = (call) => neverInheritedCall(call, workspaceRoot());
236
237
  setNeverInherited(neverInherited);
237
238
  // 0.40.0: the catastrophe floor, at the chain's HEAD — a deny there
238
239
  // names itself in decidedBy and outranks every tier, bypass included.
package/dist/dispatch.js CHANGED
@@ -17,8 +17,8 @@ import { preferences, rememberEffort, setPreference } from "./preferences.js";
17
17
  import { settingsRows } from "./settings.js";
18
18
  import { floorOn, settingsLayers } from "./state.js";
19
19
  import { currentGround } from "@vincemakes/kiso-tui-cells/render";
20
- import { queuedSwitchLines, seenUnknownTasks, tasksFor } from "./state.js";
21
- import { taskOutput, taskRow, taskStateLabel } from "./task-notice.js";
20
+ import { queuedSwitchLines, tasksFor } from "./state.js";
21
+ import { lostReason, taskOutput, taskRow, taskStateLabel } from "./task-notice.js";
22
22
  import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner, statedContextWindow, windowSourceNote } from "./chat.js";
23
23
  import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
24
24
  import { shellTool } from "@vincemakes/kiso-tools-node";
@@ -1255,8 +1255,8 @@ export function dispatch(line, ctx) {
1255
1255
  /** ADR-0058 (3e) — `/tasks`, `/tasks show <id>`, `/tasks stop <id>`. On a
1256
1256
  * dock the bare command is a pick: a task, then what to do with it. A
1257
1257
  * stop is REQUESTED here; the row says `stopping` until the journal says
1258
- * how it ended. Looking at the list counts as having seen its unknown
1259
- * tasks (the status row stops counting them). */
1258
+ * how it ended. A task kiso lost track of says why in `show`
1259
+ * (Amendment 8). */
1260
1260
  async function tasksCommand(arg, ctx) {
1261
1261
  const manager = tasksFor(ctx.session.id);
1262
1262
  if (manager === undefined) {
@@ -1273,6 +1273,10 @@ async function tasksCommand(arg, ctx) {
1273
1273
  }
1274
1274
  const show = (t) => {
1275
1275
  const lines = taskOutput(t);
1276
+ // Amendment 8: a lost task says why, before its output
1277
+ const why = lostReason(t);
1278
+ if (why !== undefined)
1279
+ bodyLog(`${t.id} — lost track: ${why}; it may still be running`);
1276
1280
  bodyLog(`${t.id} — ${t.agent !== undefined ? "its answer" : "its last output"}${lines.length === 0 ? ": nothing yet" : ""}`);
1277
1281
  for (const line of lines)
1278
1282
  bodyLog(` ${line}`);
@@ -1299,9 +1303,6 @@ async function tasksCommand(arg, ctx) {
1299
1303
  bodyLog("no tasks in this session");
1300
1304
  return;
1301
1305
  }
1302
- for (const t of list)
1303
- if (t.state.kind === "unknown")
1304
- seenUnknownTasks.add(t.id);
1305
1306
  if (!dock.active) {
1306
1307
  for (const t of list) {
1307
1308
  const r = taskRow(t);
package/dist/floor.d.ts CHANGED
@@ -63,6 +63,27 @@ export declare function innerLines(argv: readonly LooseWord[], home: string): {
63
63
  readonly lines: readonly string[];
64
64
  readonly subshell: boolean;
65
65
  } | null;
66
+ /** How deep command lines inside command lines are followed — `sh -c`,
67
+ * `eval` — and how many are read in all: eval's two readings would
68
+ * otherwise double at every level. */
69
+ export declare const MAX_INNER_DEPTH = 16;
70
+ export declare const MAX_INNER_LINES = 256;
71
+ /** git's global options, read to the subcommand. Shared by the floor and
72
+ * the remote boundary (remote-boundary.ts), which must find the same
73
+ * subcommand the floor does: an option without a value is one word
74
+ * skipped, whatever it is, so `git --no-pager push` reads as push. */
75
+ export interface GitInvocation {
76
+ readonly sub: string | undefined;
77
+ readonly rest: readonly LooseWord[];
78
+ /** B7: several -C compose, each relative to the one before */
79
+ readonly cds: readonly LooseWord[];
80
+ /** B7: --work-tree X IS the tree a whole-tree command works on */
81
+ readonly workTree: LooseWord | undefined;
82
+ /** B7: --git-dir X is a target in itself (space and = forms both) */
83
+ readonly gitDirs: readonly LooseWord[];
84
+ }
85
+ /** `args` is git's argv without `git`. */
86
+ export declare function gitInvocation(args: readonly LooseWord[]): GitInvocation;
66
87
  /**
67
88
  * Would the floor refuse this command line? `workspaceRoot` is where the
68
89
  * shell runs it.
package/dist/floor.js CHANGED
@@ -344,8 +344,8 @@ export function innerLines(argv, home) {
344
344
  /** How deep command lines inside command lines are followed — `sh -c`,
345
345
  * `eval` — and how many are read in all: eval's two readings would
346
346
  * otherwise double at every level. */
347
- const MAX_INNER_DEPTH = 16;
348
- const MAX_INNER_LINES = 256;
347
+ export const MAX_INNER_DEPTH = 16;
348
+ export const MAX_INNER_LINES = 256;
349
349
  const SELECTING = new Set([
350
350
  "-name", "-iname", "-path", "-ipath", "-wholename", "-iwholename", "-regex", "-iregex", "-newer", "-mtime", "-mmin", "-atime",
351
351
  "-amin", "-ctime", "-cmin", "-size", "-empty", "-user", "-group", "-perm", "-samefile", "-links",
@@ -355,6 +355,38 @@ const SELECTING = new Set([
355
355
  const pathShaped = (t) => t === "." || t === "./" || t.startsWith("/") || t.startsWith("~") || t.includes("/") || t.startsWith(":") || (hostDialect().msys && (t.includes("\\") || /^[A-Za-z]:/.test(t)));
356
356
  /** B7: pathspec magic from the top of the repository */
357
357
  const topMagic = (t) => t.startsWith(":/") || t.startsWith(":(top");
358
+ /** `args` is git's argv without `git`. */
359
+ export function gitInvocation(args) {
360
+ let i = 0;
361
+ const cds = [];
362
+ let workTree;
363
+ const gitDirs = [];
364
+ while (i < args.length && args[i].text.startsWith("-")) {
365
+ const t = args[i].text;
366
+ const eq = /^--(work-tree|git-dir)=(.*)$/.exec(t);
367
+ if (eq !== null) {
368
+ const value = { ...args[i], text: eq[2], unknownAt: -1, tilde: eq[2].startsWith("~") };
369
+ if (eq[1] === "work-tree")
370
+ workTree = value;
371
+ else
372
+ gitDirs.push(value);
373
+ }
374
+ if ((t === "-C" || t === "--work-tree" || t === "--git-dir") && i + 1 < args.length) {
375
+ if (t === "-C")
376
+ cds.push(args[i + 1]);
377
+ else if (t === "--work-tree")
378
+ workTree = args[i + 1];
379
+ else
380
+ gitDirs.push(args[i + 1]);
381
+ i += 2;
382
+ }
383
+ else if (t === "-c" || t === "--namespace" || t === "--exec-path")
384
+ i += 2;
385
+ else
386
+ i += 1;
387
+ }
388
+ return { sub: args[i]?.text, rest: args.slice(i + 1), cds, workTree, gitDirs };
389
+ }
358
390
  /** The destructive command's targets, or null when it is not destructive. */
359
391
  function destructiveTargets(argv) {
360
392
  const name = basename(argv[0]?.text ?? "");
@@ -398,39 +430,7 @@ function destructiveTargets(argv) {
398
430
  }
399
431
  if (name !== "git")
400
432
  return null;
401
- let i = 0;
402
- // B7: several -C compose, each relative to the one before
403
- const cds = [];
404
- // B7: --work-tree X IS the tree a whole-tree command works on; --git-dir
405
- // X is a target in itself (space and = forms both)
406
- let workTree;
407
- const gitDirs = [];
408
- while (i < args.length && args[i].text.startsWith("-")) {
409
- const t = args[i].text;
410
- const eq = /^--(work-tree|git-dir)=(.*)$/.exec(t);
411
- if (eq !== null) {
412
- const value = { ...args[i], text: eq[2], unknownAt: -1, tilde: eq[2].startsWith("~") };
413
- if (eq[1] === "work-tree")
414
- workTree = value;
415
- else
416
- gitDirs.push(value);
417
- }
418
- if ((t === "-C" || t === "--work-tree" || t === "--git-dir") && i + 1 < args.length) {
419
- if (t === "-C")
420
- cds.push(args[i + 1]);
421
- else if (t === "--work-tree")
422
- workTree = args[i + 1];
423
- else
424
- gitDirs.push(args[i + 1]);
425
- i += 2;
426
- }
427
- else if (t === "-c" || t === "--namespace" || t === "--exec-path")
428
- i += 2;
429
- else
430
- i += 1;
431
- }
432
- const sub = args[i]?.text;
433
- const rest = args.slice(i + 1);
433
+ const { sub, rest, cds, workTree, gitDirs } = gitInvocation(args);
434
434
  const flags = new Set(rest.filter((a) => a.text.startsWith("-")).map((a) => a.text));
435
435
  const as = (targets) => ({
436
436
  targets: [...targets.map((t) => (t === "repo" && workTree !== undefined ? workTree : t)), ...gitDirs],
package/dist/index.js CHANGED
@@ -45,7 +45,7 @@ import { activeStoreDir, setActiveStoreDir, agentModel, atFiles, body, bodyLog,
45
45
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
46
46
  import { chat, contextWindowTokens, displayCtxRatio, microcompactThresholdFor, statusModelLabel } from "./chat.js";
47
47
  import { preferences, usePreferences } from "./preferences.js";
48
- import { settingsLayers, setChildTurnBudget, stopAllTasks } from "./state.js";
48
+ import { settingsLayers, setChildTurnBudget, stopAllTasks, taskWhatOf } from "./state.js";
49
49
  import { finishChild } from "./child-result.js";
50
50
  import { loadUserConfig, resolveAutoCompact } from "./config.js";
51
51
  import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
@@ -1044,7 +1044,7 @@ async function chatLoop(first, firstId, input, autoCompact) {
1044
1044
  // prompt inside a conversation with thousands of events — the
1045
1045
  // durable log was right there and none of it was shown. Empty for
1046
1046
  // a fresh session, so `kiso chat` is byte-identical.
1047
- showResumeTail(session.log.all);
1047
+ showResumeTail(session.log.all, session.id);
1048
1048
  // R2 (owner, 2026-08-27): the resume list is NOT on the opening
1049
1049
  // screen. `/resume` is where you go looking for a session; the
1050
1050
  // opening's job is to say what THIS one is.
@@ -1076,7 +1076,7 @@ async function chatLoop(first, firstId, input, autoCompact) {
1076
1076
  }
1077
1077
  else {
1078
1078
  bodyLog(`session ${id} (switched — previous: ${prev}, /resume ${prev} returns)\n`);
1079
- showResumeTail(session.log.all);
1079
+ showResumeTail(session.log.all, session.id);
1080
1080
  if (currentFaux)
1081
1081
  session.setAdapter(createFauxProvider(readFauxScript().slice(fauxSkip(id))));
1082
1082
  }
@@ -1188,10 +1188,11 @@ async function readSecret(prompt) {
1188
1188
  * there is no viewer and no cell renderer, so the plain text tail stays
1189
1189
  * (DC-51: one call, one cell).
1190
1190
  */
1191
- function showResumeTail(events) {
1191
+ function showResumeTail(events, sessionId) {
1192
1192
  const W = process.stdout.columns ?? 80;
1193
+ // Amendment 8: a lost task's replayed row names what it ran
1193
1194
  if (dock.active)
1194
- replayInto(body, events, W);
1195
+ replayInto(body, events, W, taskWhatOf(sessionId));
1195
1196
  else
1196
1197
  bodyLog(resumeTail(events, W).join("\n"));
1197
1198
  }
@@ -1575,7 +1576,7 @@ async function main() {
1575
1576
  session.setAdapter(createFauxProvider(readFauxScript().slice(fauxSkip(id))));
1576
1577
  // REL-0152-D5 — the same tail on the explicit-id form. NOT on
1577
1578
  // the -p path above: that one's stdout is a machine's input.
1578
- showResumeTail(session.log.all);
1579
+ showResumeTail(session.log.all, session.id);
1579
1580
  await resume(session, prompt, faux, input);
1580
1581
  break;
1581
1582
  }
@@ -1857,7 +1858,7 @@ async function main() {
1857
1858
  // terminal (kill -9 excepted; `reset` saves it).
1858
1859
  dock.exit();
1859
1860
  for (const id of tasksAtExit.unconfirmed)
1860
- console.error(`${id}: stop unconfirmed — outcome unknown`);
1861
+ console.error(`${id}: stop unconfirmed — it may still be running`);
1861
1862
  if (tasksAtExit.left.length > 0)
1862
1863
  console.error(`left running: ${tasksAtExit.left.join(", ")} — reopen this session to hear how ${tasksAtExit.left.length === 1 ? "it ends" : "they end"}`);
1863
1864
  // finding #8 (P1): extension dispose runs on the same exit path — a
@@ -18,6 +18,14 @@
18
18
  import type { PolicyCall } from "@vincemakes/kiso-core";
19
19
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
20
20
  export declare function isProtectedWrite(call: PolicyCall, workspaceRoot: string): boolean;
21
+ /**
22
+ * The calls a saved allow never carries — they reach a person every time
23
+ * in the asking tiers: a write into .git/ or .kiso/, a destructive
24
+ * command (floor.ts, which also answers yes for a line it cannot read),
25
+ * and a command across the remote boundary (remote-boundary.ts, 0.46.2).
26
+ * create-coding-agent.ts wires it into every guarded saved allow.
27
+ */
28
+ export declare function neverInheritedCall(call: PolicyCall, workspaceRoot: string): boolean;
21
29
  /**
22
30
  * A saved allow ("yes, don't ask again for <tool>") allows by TOOL, so on
23
31
  * its own it would carry every later call of that tool — including the
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { relative, sep } from "node:path";
19
19
  import { resolveShellPath } from "./shell-words.js";
20
+ import { isDestructiveCall } from "./floor.js";
21
+ import { isRemoteBoundaryCall } from "./remote-boundary.js";
20
22
  const PROTECTED = new Set([".git", ".kiso"]);
21
23
  const WRITERS = new Set(["write_file", "edit_file"]);
22
24
  export function isProtectedWrite(call, workspaceRoot) {
@@ -34,6 +36,16 @@ export function isProtectedWrite(call, workspaceRoot) {
34
36
  .some((seg) => PROTECTED.has(seg.toLowerCase()));
35
37
  }
36
38
  const ABSTAIN = { action: "abstain" };
39
+ /**
40
+ * The calls a saved allow never carries — they reach a person every time
41
+ * in the asking tiers: a write into .git/ or .kiso/, a destructive
42
+ * command (floor.ts, which also answers yes for a line it cannot read),
43
+ * and a command across the remote boundary (remote-boundary.ts, 0.46.2).
44
+ * create-coding-agent.ts wires it into every guarded saved allow.
45
+ */
46
+ export function neverInheritedCall(call, workspaceRoot) {
47
+ return isProtectedWrite(call, workspaceRoot) || isDestructiveCall(call) || isRemoteBoundaryCall(call);
48
+ }
37
49
  /**
38
50
  * A saved allow ("yes, don't ask again for <tool>") allows by TOOL, so on
39
51
  * its own it would carry every later call of that tool — including the
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 0.46.2 — the remote boundary (owner-ratified 2026-10-06, rev 2 with the
3
+ * review's amendments).
4
+ *
5
+ * Two families of shell command change state that other people share:
6
+ *
7
+ * A. a destructive git push — history rewritten or refs removed:
8
+ * --force / -f, --force-with-lease, --force-if-includes, a refspec
9
+ * with a leading `+`, --mirror, --delete / -d, a deleting refspec
10
+ * (`:branch`), --prune. A dry run is exempt, read as git reads it: the
11
+ * last of -n / --dry-run / --no-dry-run wins, and git takes any
12
+ * unambiguous prefix of a long option, so `--mirr` is --mirror and
13
+ * `--no-dry` turns the dry run off. A prefix is read generously — a
14
+ * prefix git would reject as ambiguous costs a question, not a pass.
15
+ * B. a registry write — a package published, unpublished or deprecated,
16
+ * or a dist-tag moved: npm or pnpm with `publish`, `unpublish`,
17
+ * `deprecate`, or `dist-tag` / `dist-tags` with add (a, set, s) or rm
18
+ * (r, del, d, remove) — pnpm hands the last three to npm; yarn with
19
+ * `publish`, `npm publish`, or `tag` / `npm tag` with add, rm or
20
+ * remove. Not as the script `npm run publish` names. A dry run is
21
+ * exempt only where the command honours one, read as its parser reads
22
+ * it (`--dry-run false` is a real publish): npm and pnpm publish and
23
+ * unpublish take --dry-run, `yarn npm publish` takes -n / --dry-run;
24
+ * deprecate, dist-tag and yarn classic's publish have none, so the
25
+ * flag exempts nothing there. npm's abbreviations of a command
26
+ * (`npm pub`) are read too, in the command's place — not after an
27
+ * option that takes a value (`npm --registry x pub`). Listing tags
28
+ * and `npm view` stay out.
29
+ *
30
+ * A saved "don't ask again for shell" never carries either
31
+ * (protected-writes.ts neverInheritedCall): permission remembered by TOOL
32
+ * NAME from some earlier call does not cross into a change other people
33
+ * share. Authority given now still does — every tier decides these as it
34
+ * decides any shell call, so full-access runs them, and an explicit user
35
+ * extension that allows them still allows them. That is the extension of
36
+ * launch-weekend plan §4 ("destructive commands never inherit a saved
37
+ * allow in the asking modes") to the remote; "bypass stays bypass" is
38
+ * untouched.
39
+ *
40
+ * The list is closed: a new family needs the owner's word. It reads
41
+ * direct invocations only, words as written — `npm run release`, a script
42
+ * file, a git alias, a `+` refspec in git config and a variable's value
43
+ * are not seen. A guardrail, not a sandbox. A line it cannot read is not
44
+ * its question: isDestructiveCall already answers yes for one, so a saved
45
+ * allow abstains either way, and the two readings never disagree on what
46
+ * "unreadable" means.
47
+ */
48
+ import type { PolicyCall } from "@vincemakes/kiso-core";
49
+ /** Whether a command line crosses the remote boundary — read as the floor
50
+ * reads one: past wrappers (sudo, env, timeout), into `sh -c` and eval,
51
+ * along every command of a chain. */
52
+ export declare function isRemoteBoundary(commandLine: string): boolean;
53
+ /** The policy-call form: a shell call whose command crosses. A throw is
54
+ * not this reading's to answer — see the head comment. */
55
+ export declare function isRemoteBoundaryCall(call: PolicyCall): boolean;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 0.46.2 — the remote boundary (owner-ratified 2026-10-06, rev 2 with the
3
+ * review's amendments).
4
+ *
5
+ * Two families of shell command change state that other people share:
6
+ *
7
+ * A. a destructive git push — history rewritten or refs removed:
8
+ * --force / -f, --force-with-lease, --force-if-includes, a refspec
9
+ * with a leading `+`, --mirror, --delete / -d, a deleting refspec
10
+ * (`:branch`), --prune. A dry run is exempt, read as git reads it: the
11
+ * last of -n / --dry-run / --no-dry-run wins, and git takes any
12
+ * unambiguous prefix of a long option, so `--mirr` is --mirror and
13
+ * `--no-dry` turns the dry run off. A prefix is read generously — a
14
+ * prefix git would reject as ambiguous costs a question, not a pass.
15
+ * B. a registry write — a package published, unpublished or deprecated,
16
+ * or a dist-tag moved: npm or pnpm with `publish`, `unpublish`,
17
+ * `deprecate`, or `dist-tag` / `dist-tags` with add (a, set, s) or rm
18
+ * (r, del, d, remove) — pnpm hands the last three to npm; yarn with
19
+ * `publish`, `npm publish`, or `tag` / `npm tag` with add, rm or
20
+ * remove. Not as the script `npm run publish` names. A dry run is
21
+ * exempt only where the command honours one, read as its parser reads
22
+ * it (`--dry-run false` is a real publish): npm and pnpm publish and
23
+ * unpublish take --dry-run, `yarn npm publish` takes -n / --dry-run;
24
+ * deprecate, dist-tag and yarn classic's publish have none, so the
25
+ * flag exempts nothing there. npm's abbreviations of a command
26
+ * (`npm pub`) are read too, in the command's place — not after an
27
+ * option that takes a value (`npm --registry x pub`). Listing tags
28
+ * and `npm view` stay out.
29
+ *
30
+ * A saved "don't ask again for shell" never carries either
31
+ * (protected-writes.ts neverInheritedCall): permission remembered by TOOL
32
+ * NAME from some earlier call does not cross into a change other people
33
+ * share. Authority given now still does — every tier decides these as it
34
+ * decides any shell call, so full-access runs them, and an explicit user
35
+ * extension that allows them still allows them. That is the extension of
36
+ * launch-weekend plan §4 ("destructive commands never inherit a saved
37
+ * allow in the asking modes") to the remote; "bypass stays bypass" is
38
+ * untouched.
39
+ *
40
+ * The list is closed: a new family needs the owner's word. It reads
41
+ * direct invocations only, words as written — `npm run release`, a script
42
+ * file, a git alias, a `+` refspec in git config and a variable's value
43
+ * are not seen. A guardrail, not a sandbox. A line it cannot read is not
44
+ * its question: isDestructiveCall already answers yes for one, so a saved
45
+ * allow abstains either way, and the two readings never disagree on what
46
+ * "unreadable" means.
47
+ */
48
+ import { homedir } from "node:os";
49
+ import { basename } from "node:path";
50
+ import { MAX_INNER_DEPTH, MAX_INNER_LINES, gitInvocation, innerLines, unwrap } from "./floor.js";
51
+ import { looseCommands, parseShellLooseChecked } from "./shell-words.js";
52
+ /** git push options that take the next word as their value. */
53
+ const PUSH_VALUED = new Set(["-o", "--push-option", "--repo", "--receive-pack", "--exec"]);
54
+ /** git push options that rewrite or remove remote refs. */
55
+ const PUSH_REWRITES = new Set(["--force", "--force-with-lease", "--force-if-includes", "--mirror", "--delete", "--prune"]);
56
+ function destructivePush(args) {
57
+ let crosses = false;
58
+ let dryRun = false;
59
+ let options = true;
60
+ const positionals = [];
61
+ for (let i = 0; i < args.length; i += 1) {
62
+ const t = args[i].text;
63
+ if (!options || !t.startsWith("-") || t === "-") {
64
+ positionals.push(t);
65
+ continue;
66
+ }
67
+ if (t === "--") {
68
+ options = false;
69
+ continue;
70
+ }
71
+ if (PUSH_VALUED.has(t)) {
72
+ i += 1;
73
+ continue;
74
+ }
75
+ if (t.startsWith("--")) {
76
+ const flag = t.split("=")[0];
77
+ if (flag === "--dry-run")
78
+ dryRun = true;
79
+ else if (flag.length >= "--no-d".length && "--no-dry-run".startsWith(flag))
80
+ dryRun = false;
81
+ else if (flag.length > 2 && [...PUSH_REWRITES].some((r) => r.startsWith(flag)))
82
+ crosses = true;
83
+ continue;
84
+ }
85
+ // a short cluster, `-uf`; `-o` takes the rest of the word, or the next
86
+ for (let k = 1; k < t.length; k += 1) {
87
+ const c = t[k];
88
+ if (c === "o") {
89
+ if (k === t.length - 1)
90
+ i += 1;
91
+ break;
92
+ }
93
+ if (c === "n")
94
+ dryRun = true;
95
+ if (c === "f" || c === "d")
96
+ crosses = true;
97
+ }
98
+ }
99
+ // the first positional is the repository; the rest are refspecs
100
+ if (positionals.slice(1).some((r) => r.startsWith("+") || r.startsWith(":")))
101
+ crosses = true;
102
+ return crosses && !dryRun;
103
+ }
104
+ /** Whether a dry run is on, read as npm's option parser reads it: the last
105
+ * setting wins, `--dry-run false` and `--dry-run=false` turn it off, and
106
+ * only an explicit true counts. `-n` is a dry run where yarn says so. */
107
+ function dryRun(words, short) {
108
+ let on = false;
109
+ for (let i = 0; i < words.length; i += 1) {
110
+ const w = words[i];
111
+ if (w === "--dry-run" || (short && w === "-n")) {
112
+ const next = words[i + 1];
113
+ on = next !== "false";
114
+ if (next === "true" || next === "false")
115
+ i += 1;
116
+ }
117
+ else if (w.startsWith("--dry-run="))
118
+ on = w === "--dry-run=true";
119
+ else if (w === "--no-dry-run")
120
+ on = false;
121
+ }
122
+ return on;
123
+ }
124
+ /** dist-tag (npm) and tag (yarn) write with these subcommands, and list
125
+ * with the rest — a bare `npm dist-tag <pkg>` lists too. */
126
+ const NPM_TAG_WRITES = new Set(["add", "a", "set", "s", "rm", "r", "del", "d", "remove"]);
127
+ const NPM_TAG_READS = new Set(["ls", "l", "sl", "list"]);
128
+ const YARN_TAG_WRITES = new Set(["add", "rm", "remove"]);
129
+ const YARN_TAG_READS = new Set(["ls", "list"]);
130
+ /** A tag command writes when a write subcommand follows it — unless the
131
+ * first word after it names a listing (`npm dist-tag ls d` lists "d"). */
132
+ function tagWrite(words, at, writes, reads) {
133
+ const after = words.slice(at + 1).filter((w) => !w.startsWith("-"));
134
+ if (after[0] !== undefined && reads.has(after[0]))
135
+ return false;
136
+ return after.some((w) => writes.has(w));
137
+ }
138
+ /** npm runs a unique prefix of a command name: `npm pub` is publish,
139
+ * `npm unp` unpublish, `npm dep` deprecate (npm 10's own resolution).
140
+ * Read generously, as git's options are — a prefix npm would reject as
141
+ * ambiguous (`npm de`) costs a question. `un` is npm's alias for
142
+ * uninstall; dist-tag has no abbreviation, every prefix of it being
143
+ * ambiguous with dist-tags. */
144
+ function npmCommand(first) {
145
+ if (first === undefined || first.length < 2 || first === "un")
146
+ return first;
147
+ return ["publish", "unpublish", "deprecate"].find((w) => w.startsWith(first)) ?? first;
148
+ }
149
+ function registryWrite(pm, args) {
150
+ const words = args.map((a) => a.text);
151
+ const first = words.find((w) => !w.startsWith("-"));
152
+ if (first === "run" || first === "run-script")
153
+ return false;
154
+ if (pm === "yarn") {
155
+ // berry spells the registry commands `yarn npm …`; classic has no
156
+ // unpublish or deprecate, and its publish takes no dry run
157
+ const berry = words.includes("npm");
158
+ if (words.includes("publish") && !(berry && dryRun(words, true)))
159
+ return true;
160
+ const tag = words.indexOf("tag");
161
+ return tag !== -1 && tagWrite(words, tag, YARN_TAG_WRITES, YARN_TAG_READS);
162
+ }
163
+ const command = pm === "npm" ? npmCommand(first) : first;
164
+ const publishes = words.includes("publish") || words.includes("unpublish") || command === "publish" || command === "unpublish";
165
+ if (publishes && !dryRun(words, false))
166
+ return true;
167
+ if (words.includes("deprecate") || command === "deprecate")
168
+ return true;
169
+ const tag = words.findIndex((w) => w === "dist-tag" || w === "dist-tags");
170
+ return tag !== -1 && tagWrite(words, tag, NPM_TAG_WRITES, NPM_TAG_READS);
171
+ }
172
+ function crossing(argv) {
173
+ const name = basename(argv[0]?.text ?? "");
174
+ if (name === "git") {
175
+ const inv = gitInvocation(argv.slice(1));
176
+ return inv.sub === "push" && destructivePush(inv.rest);
177
+ }
178
+ if (name === "npm" || name === "pnpm" || name === "yarn")
179
+ return registryWrite(name, argv.slice(1));
180
+ return false;
181
+ }
182
+ /** Whether a command line crosses the remote boundary — read as the floor
183
+ * reads one: past wrappers (sudo, env, timeout), into `sh -c` and eval,
184
+ * along every command of a chain. */
185
+ export function isRemoteBoundary(commandLine) {
186
+ const seen = new Set();
187
+ const visit = (line, depth) => {
188
+ const read = parseShellLooseChecked(line);
189
+ return looseCommands(read.nodes).some(({ argv: raw }) => {
190
+ const argv = unwrap(raw);
191
+ const inner = innerLines(argv, homedir());
192
+ if (inner !== null) {
193
+ return (depth < MAX_INNER_DEPTH &&
194
+ inner.lines.some((l) => {
195
+ if (seen.has(l) || seen.size >= MAX_INNER_LINES)
196
+ return false;
197
+ seen.add(l);
198
+ return visit(l, depth + 1);
199
+ }));
200
+ }
201
+ return crossing(argv);
202
+ });
203
+ };
204
+ return visit(commandLine, 0);
205
+ }
206
+ /** The policy-call form: a shell call whose command crosses. A throw is
207
+ * not this reading's to answer — see the head comment. */
208
+ export function isRemoteBoundaryCall(call) {
209
+ if (call.name !== "shell" || typeof call.input.command !== "string")
210
+ return false;
211
+ try {
212
+ return isRemoteBoundary(call.input.command);
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ }
package/dist/replay.d.ts CHANGED
@@ -49,5 +49,5 @@ export declare function turnsOf(events: readonly Ev[]): Ev[][];
49
49
  * Replay a session's durable events into the body. Returns the number of
50
50
  * turns it found (0: a fresh session — nothing is drawn).
51
51
  */
52
- export declare function replayInto(body: ReplayBody, events: readonly Ev[], W?: number): number;
52
+ export declare function replayInto(body: ReplayBody, events: readonly Ev[], W?: number, what?: (taskId: string) => string | undefined): number;
53
53
  export {};
package/dist/replay.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * the viewer — then any uncovered earlier turns folded as above.
21
21
  */
22
22
  import { echoText } from "@vincemakes/kiso-tui-cells/render";
23
- import { taskNoticeRow } from "./task-notice.js";
23
+ import { taskNoticeLines } from "./task-notice.js";
24
24
  /** How many turns stay on screen in full. */
25
25
  const FULL_TURNS = 2;
26
26
  /** A user turn's opening line, or null when this input does not open one
@@ -48,7 +48,7 @@ export function turnsOf(events) {
48
48
  }
49
49
  /** One turn through the Body — consumeRun's mapping, minus everything that
50
50
  * needs the live process (clocks, tailers, usage, the recap). */
51
- function replayTurn(body, turn) {
51
+ function replayTurn(body, turn, what) {
52
52
  let thinking = false;
53
53
  let said = false;
54
54
  const results = new Set();
@@ -63,7 +63,8 @@ function replayTurn(body, turn) {
63
63
  if (ask !== null)
64
64
  body.userLine(ask);
65
65
  else if (e.via?.kind === "tasks")
66
- body.notice(taskNoticeRow(e.via.items));
66
+ for (const row of taskNoticeLines(e.via.items, what))
67
+ body.notice(row);
67
68
  else if (e.source === "system") {
68
69
  body.notice("verification pass");
69
70
  body.notice(` ${typeof e.content === "string" ? e.content : ""}`);
@@ -124,7 +125,7 @@ const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
124
125
  * Replay a session's durable events into the body. Returns the number of
125
126
  * turns it found (0: a fresh session — nothing is drawn).
126
127
  */
127
- export function replayInto(body, events, W = 80) {
128
+ export function replayInto(body, events, W = 80, what) {
128
129
  // The latest checkpoint: the turns it covers are what the model will
129
130
  // read as its summary, not as turns.
130
131
  let checkpoint = null;
@@ -146,16 +147,16 @@ export function replayInto(body, events, W = 80) {
146
147
  const summary = checkpoint.summary;
147
148
  body.fold(`checkpoint · summarizes ${plural(covered.length, "earlier turn")} · ctrl+r to read`, () => {
148
149
  for (const t of covered)
149
- replayTurn(body, t);
150
+ replayTurn(body, t, what);
150
151
  }, summary);
151
152
  }
152
153
  if (earlier.length > 0) {
153
154
  body.fold(`${plural(earlier.length, "earlier turn")} · ctrl+r to read`, () => {
154
155
  for (const t of earlier)
155
- replayTurn(body, t);
156
+ replayTurn(body, t, what);
156
157
  });
157
158
  }
158
159
  for (const t of shown)
159
- replayTurn(body, t);
160
+ replayTurn(body, t, what);
160
161
  return total;
161
162
  }
package/dist/state.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * the moved modules read and mutate at call time.
7
7
  */
8
8
  import { Dock, type AtItem, type Body, type PanelVerdict, type PanelView, type SaferAnswer, type SessionCardView } from "@vincemakes/kiso-tui";
9
+ import type { TaskDeliveryItem } from "@vincemakes/kiso-core";
9
10
  import type { KisoExtension, StoreRecord } from "@vincemakes/kiso-runtime";
10
11
  import { TaskManager } from "@vincemakes/kiso-runtime/internal";
11
12
  import { type ShellTasks } from "@vincemakes/kiso-tools-node";
@@ -34,11 +35,14 @@ export declare function codingToolOptions(): {
34
35
  readonly protectedFiles: readonly string[];
35
36
  readonly tasks: (sessionId: string | undefined) => ShellTasks | undefined;
36
37
  };
38
+ /** Amendment 8: what a session's task ran — a lost task's transcript row
39
+ * names it. Undefined when the task or its journal cannot be read. */
40
+ export declare function taskWhatOf(sessionId: string | undefined): (taskId: string) => string | undefined;
41
+ export declare function lostToldOf(sessionId: string): Set<string>;
42
+ /** The transcript lines a delivery shows: a loss already told is left out. */
43
+ export declare function deliveryLines(sessionId: string, items: readonly TaskDeliveryItem[]): string[];
37
44
  export declare function tasksFor(sessionId: string | undefined): TaskManager | undefined;
38
45
  export declare function setExitTasks(choice: "stop" | "leave"): void;
39
- /** 3e: the unknown tasks the person has looked at in `/tasks` in this
40
- * process — the status row stops counting them. */
41
- export declare const seenUnknownTasks: Set<string>;
42
46
  /** 3e: the live tasks of every session this process opened — a runner's
43
47
  * (`durable`, it survives this process) and moved commands (`moved`). */
44
48
  export declare function liveTasks(): {
package/dist/state.js CHANGED
@@ -14,6 +14,7 @@ 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
16
  import { canonicalPath, LEGACY_SESSIONS_DIR, projectDirFor, projectLayoutActive } from "./projects.js";
17
+ import { taskNoticeLines, taskWhat } from "./task-notice.js";
17
18
  /** finding #11: KISO_HOME is the ONE root — every default path derives from
18
19
  * it (sessions, trust, extensions, mcp config, skills). The dedicated
19
20
  * env vars (KISO_EXTENSIONS_DIR / KISO_MCP_CONFIG / KISO_SKILLS_DIR)
@@ -48,6 +49,34 @@ export function codingToolOptions() {
48
49
  * — the `!` gesture — has none, and its shell is today's. */
49
50
  const taskManagers = new Map();
50
51
  let taskBackend;
52
+ /** Amendment 8: what a session's task ran — a lost task's transcript row
53
+ * names it. Undefined when the task or its journal cannot be read. */
54
+ export function taskWhatOf(sessionId) {
55
+ return (taskId) => {
56
+ try {
57
+ const t = tasksFor(sessionId)?.get(taskId);
58
+ return t === undefined ? undefined : taskWhat(t);
59
+ }
60
+ catch {
61
+ return undefined; // a corrupt journal is reported where it is read on purpose
62
+ }
63
+ };
64
+ }
65
+ /** Amendment 8: the lost tasks this process has told the person of, per
66
+ * session — when the model's notice of the same loss arrives later, its
67
+ * row is not said again. */
68
+ const toldLost = new Map();
69
+ export function lostToldOf(sessionId) {
70
+ let told = toldLost.get(sessionId);
71
+ if (told === undefined)
72
+ toldLost.set(sessionId, (told = new Set()));
73
+ return told;
74
+ }
75
+ /** The transcript lines a delivery shows: a loss already told is left out. */
76
+ export function deliveryLines(sessionId, items) {
77
+ const told = lostToldOf(sessionId);
78
+ return taskNoticeLines(items.filter((i) => !(i.transition === "unknown" && told.has(i.taskId))), taskWhatOf(sessionId));
79
+ }
51
80
  export function tasksFor(sessionId) {
52
81
  if (sessionId === undefined || activeStoreDir === "")
53
82
  return undefined;
@@ -67,9 +96,6 @@ let exitTasks = "stop";
67
96
  export function setExitTasks(choice) {
68
97
  exitTasks = choice;
69
98
  }
70
- /** 3e: the unknown tasks the person has looked at in `/tasks` in this
71
- * process — the status row stops counting them. */
72
- export const seenUnknownTasks = new Set();
73
99
  /** 3e: the live tasks of every session this process opened — a runner's
74
100
  * (`durable`, it survives this process) and moved commands (`moved`). */
75
101
  export function liveTasks() {
@@ -1,10 +1,19 @@
1
1
  import type { TaskDeliveryItem } from "@vincemakes/kiso-core";
2
- import type { TaskInfo } from "@vincemakes/kiso-runtime/internal";
3
- export declare function taskNoticeRow(items: readonly TaskDeliveryItem[]): string;
2
+ import { type TaskInfo } from "@vincemakes/kiso-runtime/internal";
3
+ /** The lines a delivery shows: today's `✦ task t1 exited · t2 failed` for
4
+ * the ordinary items, and one `✦ lost track of …` row per lost task. The
5
+ * shape is fixed (a design keys on it): the opening `✦ lost track of <id>
6
+ * (<what>) — ` — the parenthesis only when the command is known — and the
7
+ * LAST ` — ` part opening with "it may still be running". One line each. */
8
+ export declare function taskNoticeLines(items: readonly TaskDeliveryItem[], what?: (taskId: string) => string | undefined): string[];
4
9
  /** A task's state in the person's words. A stop that was asked for and is
5
10
  * not yet confirmed reads `stopping` — never `stopped` before the journal
6
- * says so; an outcome nobody can know reads `◌ outcome unknown`. */
11
+ * says so; a task kiso lost track of says so (Amendment 8). */
7
12
  export declare function taskStateLabel(task: TaskInfo): string;
13
+ /** Why kiso lost track of a task, from its journal — or undefined when it
14
+ * has not: a stop it could not confirm, a moved command whose kiso ended,
15
+ * or a runner that is gone. */
16
+ export declare function lostReason(task: TaskInfo): string | undefined;
8
17
  /** What the task is: the command, or the child's role and task. */
9
18
  export declare function taskWhat(task: TaskInfo): string;
10
19
  /** One `/tasks` row: `t3 running 1m 20s`, with the command beside it. */
@@ -14,9 +23,8 @@ export declare function taskRow(task: TaskInfo, now?: number): {
14
23
  };
15
24
  /** A task's last `lines` lines of output — or, for a child, its answer. */
16
25
  export declare function taskOutput(task: TaskInfo, lines?: number): string[];
17
- /** The status row's counts: live tasks, and the unknown ones the person
18
- * has not looked at in `/tasks` in this process. */
19
- export declare function taskCounts(tasks: readonly TaskInfo[], seen: ReadonlySet<string>): {
26
+ /** The status row's count: the tasks kiso manages (running or starting).
27
+ * A lost task is not counted here (Amendment 8). */
28
+ export declare function taskCounts(tasks: readonly TaskInfo[]): {
20
29
  readonly running: number;
21
- readonly unknown: number;
22
30
  };
@@ -3,19 +3,40 @@
3
3
  * on resume. The model reads the notice's own lines; a person reads this.
4
4
  *
5
5
  * 3e: and what the person sees of the tasks themselves — the `/tasks` rows,
6
- * a task's output on request, and the counts on the status row. All of it
6
+ * a task's output on request, and the count on the status row. All of it
7
7
  * is drawn on the screen only: no `user_input`, nothing in the session log,
8
8
  * nothing the model sees (the model has its notices and `read_file`).
9
+ *
10
+ * Amendment 8: a task kiso lost track of (`unknown`) is said in the
11
+ * person's words — "lost track", and why — as its own transcript row, once;
12
+ * never a second count on the status row. The model's notice keeps the
13
+ * protocol's `unknown`.
9
14
  */
10
15
  import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
11
16
  import { dirname, join } from "node:path";
17
+ import { readRecords } from "@vincemakes/kiso-runtime/internal";
12
18
  import { elapsedLabel } from "@vincemakes/kiso-tui-cells";
13
- export function taskNoticeRow(items) {
14
- return `✦ task ${items.map((i) => `${i.taskId} ${i.transition}`).join(" · ")}`;
19
+ /** The lines a delivery shows: today's `✦ task t1 exited · t2 failed` for
20
+ * the ordinary items, and one `✦ lost track of …` row per lost task. The
21
+ * shape is fixed (a design keys on it): the opening `✦ lost track of <id>
22
+ * (<what>) — ` — the parenthesis only when the command is known — and the
23
+ * LAST ` — ` part opening with "it may still be running". One line each. */
24
+ export function taskNoticeLines(items, what) {
25
+ const rows = [];
26
+ const ordinary = items.filter((i) => i.transition !== "unknown");
27
+ if (ordinary.length > 0)
28
+ rows.push(`✦ task ${ordinary.map((i) => `${i.taskId} ${i.transition}`).join(" · ")}`);
29
+ for (const i of items) {
30
+ if (i.transition !== "unknown")
31
+ continue;
32
+ const w = what?.(i.taskId)?.split("\n")[0]?.trim();
33
+ rows.push(`✦ lost track of ${i.taskId}${w ? ` (${w})` : ""} — it may still be running · /tasks shows it`);
34
+ }
35
+ return rows;
15
36
  }
16
37
  /** A task's state in the person's words. A stop that was asked for and is
17
38
  * not yet confirmed reads `stopping` — never `stopped` before the journal
18
- * says so; an outcome nobody can know reads `◌ outcome unknown`. */
39
+ * says so; a task kiso lost track of says so (Amendment 8). */
19
40
  export function taskStateLabel(task) {
20
41
  const s = task.state;
21
42
  switch (s.kind) {
@@ -26,7 +47,7 @@ export function taskStateLabel(task) {
26
47
  case "running":
27
48
  return task.stoppedBy !== undefined ? "stopping" : s.ready ? "ready" : "running";
28
49
  case "unknown":
29
- return "◌ outcome unknown";
50
+ return "lost track — may still be running";
30
51
  case "ended":
31
52
  if (s.stopped)
32
53
  return "stopped";
@@ -35,6 +56,26 @@ export function taskStateLabel(task) {
35
56
  return s.exitCode !== null ? (s.exitCode === 0 ? "exited 0" : `failed ${s.exitCode}`) : `ended ${s.signal ?? ""}`.trim();
36
57
  }
37
58
  }
59
+ /** Why kiso lost track of a task, from its journal — or undefined when it
60
+ * has not: a stop it could not confirm, a moved command whose kiso ended,
61
+ * or a runner that is gone. */
62
+ export function lostReason(task) {
63
+ if (task.state.kind !== "unknown")
64
+ return undefined;
65
+ let records = [];
66
+ try {
67
+ records = readRecords(join(dirname(task.outputPath), "journal.jsonl"));
68
+ }
69
+ catch {
70
+ // an unreadable journal: the general reason below
71
+ }
72
+ const unconfirmed = records.find((r) => r.type === "stop_unconfirmed");
73
+ if (unconfirmed !== undefined)
74
+ return `its stop could not be confirmed (pids ${unconfirmed.pids.join(", ")})`;
75
+ if (task.backend === "foreground")
76
+ return "the kiso that held it ended without recording its end";
77
+ return "its runner is gone without recording its end";
78
+ }
38
79
  /** What the task is: the command, or the child's role and task. */
39
80
  export function taskWhat(task) {
40
81
  return task.agent !== undefined ? task.command : task.command.split("\n")[0];
@@ -71,16 +112,12 @@ export function taskOutput(task, lines = 20) {
71
112
  return [];
72
113
  }
73
114
  }
74
- /** The status row's counts: live tasks, and the unknown ones the person
75
- * has not looked at in `/tasks` in this process. */
76
- export function taskCounts(tasks, seen) {
115
+ /** The status row's count: the tasks kiso manages (running or starting).
116
+ * A lost task is not counted here (Amendment 8). */
117
+ export function taskCounts(tasks) {
77
118
  let running = 0;
78
- let unknown = 0;
79
- for (const t of tasks) {
119
+ for (const t of tasks)
80
120
  if (t.state.kind === "running" || t.state.kind === "starting")
81
121
  running += 1;
82
- else if (t.state.kind === "unknown" && !seen.has(t.id))
83
- unknown += 1;
84
- }
85
- return { running, unknown };
122
+ return { running };
86
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.46.1",
3
+ "version": "0.46.2",
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.46.1",
22
- "@vincemakes/kiso-core": "0.46.1",
23
- "@vincemakes/kiso-evals": "0.46.1",
24
- "@vincemakes/kiso-mcp-ext": "0.46.1",
25
- "@vincemakes/kiso-provider-anthropic": "0.46.1",
26
- "@vincemakes/kiso-provider-openai": "0.46.1",
27
- "@vincemakes/kiso-provider-openai-responses": "0.46.1",
28
- "@vincemakes/kiso-runtime": "0.46.1",
29
- "@vincemakes/kiso-skills-ext": "0.46.1",
30
- "@vincemakes/kiso-subagent-ext": "0.46.1",
31
- "@vincemakes/kiso-tools-node": "0.46.1",
32
- "@vincemakes/kiso-tui": "0.46.1",
33
- "@vincemakes/kiso-tui-cells": "0.46.1"
21
+ "@vincemakes/kiso-ask-ext": "0.46.2",
22
+ "@vincemakes/kiso-core": "0.46.2",
23
+ "@vincemakes/kiso-evals": "0.46.2",
24
+ "@vincemakes/kiso-mcp-ext": "0.46.2",
25
+ "@vincemakes/kiso-provider-anthropic": "0.46.2",
26
+ "@vincemakes/kiso-provider-openai": "0.46.2",
27
+ "@vincemakes/kiso-provider-openai-responses": "0.46.2",
28
+ "@vincemakes/kiso-runtime": "0.46.2",
29
+ "@vincemakes/kiso-skills-ext": "0.46.2",
30
+ "@vincemakes/kiso-subagent-ext": "0.46.2",
31
+ "@vincemakes/kiso-tools-node": "0.46.2",
32
+ "@vincemakes/kiso-tui": "0.46.2",
33
+ "@vincemakes/kiso-tui-cells": "0.46.2"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^26.1.2",