@vincemakes/kiso-code 0.45.3 → 0.46.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/floor.js CHANGED
@@ -42,8 +42,8 @@
42
42
  */
43
43
  import { existsSync, statSync } from "node:fs";
44
44
  import { homedir, tmpdir } from "node:os";
45
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
46
- import { HOME_SUBTREES, looseCommands, parseShellLooseChecked, resolveShellPath } from "./shell-words.js";
45
+ import { basename, resolve, win32 } from "node:path";
46
+ import { homeSubtrees, hostDialect, looseCommands, msysPath, parseShellLooseChecked, resolveShellPath } from "./shell-words.js";
47
47
  /** R8 (the lead's ruling): a system root AND everything inside it. */
48
48
  const SYSTEM_ROOTS = [
49
49
  "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/usr", "/var",
@@ -59,28 +59,46 @@ const TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp", "/va
59
59
  * only the prefix itself; what is inside runs (`brew` put it there and can
60
60
  * again). */
61
61
  const SOFTWARE_PREFIXES = ["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"];
62
+ /** Windows P2: the same tables from the environment, never a hard-coded
63
+ * user name. System roots: SystemRoot and the program and data roots;
64
+ * the users root works as a mount root (it and each home in it refused);
65
+ * the temp family is TEMP/TMP. Every drive root is refused in
66
+ * `unrecoverable`. */
67
+ function windowsRoots(home) {
68
+ const env = process.env;
69
+ const set = (values) => values.filter((v) => v !== undefined && v !== "");
70
+ return {
71
+ sys: set([env.SystemRoot, env.ProgramFiles, env["ProgramFiles(x86)"], env.ProgramData]),
72
+ mounts: [win32.dirname(home)],
73
+ temps: set([env.TEMP, env.TMP]),
74
+ prefixes: [],
75
+ };
76
+ }
62
77
  /** Memoized per (workspace, home): the roots do not move within a session,
63
78
  * and resolving forty of them cost every shell call ~4 ms. */
64
79
  const WHERE = new Map();
65
80
  function where(root, home) {
66
- const key = `${root}\0${home}`;
81
+ const dialect = hostDialect();
82
+ const key = `${dialect.msys ? "msys" : "posix"}\0${root}\0${home}`;
67
83
  const hit = WHERE.get(key);
68
84
  if (hit !== undefined)
69
85
  return hit;
70
- const real = (p) => resolveShellPath(root, "/", p).canonical;
86
+ const { join } = dialect.path;
87
+ const real = (p) => resolveShellPath(root, dialect.msys ? root : "/", p, dialect).canonical;
71
88
  const named = (names) => names.map((name) => ({ name, real: real(name) }));
72
89
  const rootReal = real(root);
90
+ const roots = dialect.msys ? windowsRoots(home) : { sys: SYSTEM_ROOTS, mounts: MOUNT_ROOTS, temps: [...TEMP_ROOTS, tmpdir()], prefixes: SOFTWARE_PREFIXES };
73
91
  const w = {
74
92
  root,
75
93
  home,
76
94
  rootReal,
77
95
  homeReal: real(home),
78
96
  gitReal: join(rootReal, ".git"),
79
- sys: named(SYSTEM_ROOTS),
80
- mounts: named(MOUNT_ROOTS),
81
- temps: [...new Set([...TEMP_ROOTS, tmpdir()].map(real))],
82
- prefixes: named(SOFTWARE_PREFIXES),
83
- subtrees: HOME_SUBTREES.map((name) => ({ name, real: real(join(home, name)) })),
97
+ sys: named(roots.sys),
98
+ mounts: named(roots.mounts),
99
+ temps: [...new Set(roots.temps.map(real))],
100
+ prefixes: named(roots.prefixes),
101
+ subtrees: homeSubtrees().map((name) => ({ name, real: real(join(home, name)) })),
84
102
  };
85
103
  WHERE.set(key, w);
86
104
  return w;
@@ -97,27 +115,35 @@ function capCwds(w, list) {
97
115
  }
98
116
  const canon = (w, cwd, p) => resolveShellPath(w.root, cwd, p).canonical;
99
117
  const within = (parent, p) => {
118
+ const { relative, isAbsolute } = hostDialect().path;
100
119
  const rel = relative(parent, p);
101
120
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
102
121
  };
122
+ /** The same path: case-insensitive on Windows (Windows P2). */
123
+ const same = (a, b) => (hostDialect().msys ? a.toLowerCase() === b.toLowerCase() : a === b);
103
124
  /** Why `p` (canonical) is unrecoverable, or null. `over` is true for a
104
125
  * wildcard over `p` rather than `p` itself — the same set, since
105
126
  * emptying a directory is removing everything it held. */
106
127
  function unrecoverable(w, p, over) {
107
128
  const what = (s) => (over ? `a wildcard over ${s}` : s);
129
+ const msys = hostDialect().msys;
108
130
  if (p === "/")
109
131
  return what("/");
110
- if (p === w.homeReal)
132
+ if (msys && win32.parse(p).root === p)
133
+ return what(`a drive root (${p})`);
134
+ if (same(p, w.homeReal))
111
135
  return what("the home directory");
112
- if (p === w.rootReal)
136
+ if (same(p, w.rootReal))
113
137
  return what("the workspace root");
114
138
  if (within(p, w.rootReal))
115
139
  return what(`a directory above the workspace (${p})`);
116
140
  // R7: the workspace's history — without a remote, gone
117
141
  if (within(w.gitReal, p))
118
142
  return what("the workspace's .git");
143
+ // on Windows the temp root sits inside AppData: what is inside a temp
144
+ // root that lies inside the subtree runs
119
145
  for (const sub of w.subtrees)
120
- if (within(sub.real, p))
146
+ if (within(sub.real, p) && !(msys && w.temps.some((t) => within(sub.real, t) && !same(p, t) && within(t, p))))
121
147
  return what(`~/${sub.name}`);
122
148
  // Inside the workspace or the home directory is the project's and the
123
149
  // person's, never a system root's — or a workspace in /opt, or a root
@@ -125,21 +151,21 @@ function unrecoverable(w, p, over) {
125
151
  if ((w.rootReal !== "/" && within(w.rootReal, p)) || within(w.homeReal, p))
126
152
  return null;
127
153
  for (const t of w.temps) {
128
- if (p === t)
154
+ if (same(p, t))
129
155
  return what(`a temp root (${t})`);
130
156
  if (within(t, p))
131
157
  return null;
132
158
  }
133
159
  for (const x of w.prefixes) {
134
- if (p === x.real)
160
+ if (same(p, x.real))
135
161
  return what(`a software prefix (${x.name})`);
136
162
  if (within(x.real, p))
137
163
  return null;
138
164
  }
139
165
  for (const m of w.mounts) {
140
- if (p === m.real)
166
+ if (same(p, m.real))
141
167
  return what(`a system root (${m.name})`);
142
- if (dirname(p) === m.real)
168
+ if (same(hostDialect().path.dirname(p), m.real))
143
169
  return what(`a mount or home root (${p})`);
144
170
  // deeper runs — by RESOLVED path: macOS's /home is
145
171
  // /System/Volumes/Data/home, which the /System rule would refuse
@@ -147,7 +173,7 @@ function unrecoverable(w, p, over) {
147
173
  return null;
148
174
  }
149
175
  for (const s of w.sys) {
150
- if (p === s.real)
176
+ if (same(p, s.real))
151
177
  return what(`a system root (${s.name})`);
152
178
  if (within(s.real, p))
153
179
  return what(`a path inside a system root (${s.name})`);
@@ -169,7 +195,9 @@ function reUnknown(text, from) {
169
195
  /** One target word, from each directory the command might run in. */
170
196
  function targetWhy(w, cwds, word) {
171
197
  for (const cwd of cwds) {
172
- let text = word.text;
198
+ // Windows P2: either separator separates (the same length, so the
199
+ // unknown positions hold)
200
+ let text = hostDialect().msys ? word.text.replace(/\\/g, "/") : word.text;
173
201
  let at = word.unknownAt;
174
202
  let glob = word.unknownIsGlob;
175
203
  let variableOnly = word.variableOnly;
@@ -189,7 +217,10 @@ function targetWhy(w, cwds, word) {
189
217
  if (variableOnly)
190
218
  return `a target that is only a variable (${word.text})`;
191
219
  if (at < 0) {
192
- const why = unrecoverable(w, canon(w, cwd, text), false);
220
+ const target = resolveShellPath(w.root, cwd, text);
221
+ if (target.opaque !== undefined)
222
+ return `${word.text}, ${target.opaque}`;
223
+ const why = unrecoverable(w, target.canonical, false);
193
224
  if (why !== null)
194
225
  return why;
195
226
  continue;
@@ -200,7 +231,10 @@ function targetWhy(w, cwds, word) {
200
231
  continue;
201
232
  const prefix = text.slice(0, at);
202
233
  const lastSlash = prefix.lastIndexOf("/");
203
- const dir = canon(w, cwd, lastSlash < 0 ? "." : prefix.slice(0, lastSlash + 1) || "/");
234
+ const over = resolveShellPath(w.root, cwd, lastSlash < 0 ? "." : prefix.slice(0, lastSlash + 1) || "/");
235
+ if (over.opaque !== undefined)
236
+ return `a wildcard in ${word.text}, ${over.opaque}`;
237
+ const dir = over.canonical;
204
238
  const head = prefix.slice(lastSlash + 1); // the literal start of the unknown component
205
239
  const nextSlash = text.indexOf("/", at);
206
240
  const component = text.slice(lastSlash + 1, nextSlash < 0 ? text.length : nextSlash);
@@ -210,7 +244,7 @@ function targetWhy(w, cwds, word) {
210
244
  for (const sub of w.subtrees) {
211
245
  if (within(sub.real, dir))
212
246
  return `a wildcard inside ~/${sub.name}`;
213
- if (glob && dir === w.homeReal && head !== "" && sub.name.toLowerCase().startsWith(head.toLowerCase()))
247
+ if (glob && same(dir, w.homeReal) && head !== "" && sub.name.toLowerCase().startsWith(head.toLowerCase()))
214
248
  return `a wildcard that reaches ~/${sub.name}`;
215
249
  }
216
250
  // B9: over the directory ONLY when the unknown component is nothing
@@ -318,7 +352,7 @@ const SELECTING = new Set([
318
352
  ]);
319
353
  /** R5: a checkout/restore operand is a path only when path-shaped — a
320
354
  * branch name is never refused. */
321
- const pathShaped = (t) => t === "." || t === "./" || t.startsWith("/") || t.startsWith("~") || t.includes("/") || t.startsWith(":");
355
+ const pathShaped = (t) => t === "." || t === "./" || t.startsWith("/") || t.startsWith("~") || t.includes("/") || t.startsWith(":") || (hostDialect().msys && (t.includes("\\") || /^[A-Za-z]:/.test(t)));
322
356
  /** B7: pathspec magic from the top of the repository */
323
357
  const topMagic = (t) => t.startsWith(":/") || t.startsWith(":(top");
324
358
  /** The destructive command's targets, or null when it is not destructive. */
@@ -447,7 +481,7 @@ function destructiveTargets(argv) {
447
481
  function isGitDir(p) {
448
482
  try {
449
483
  const st = statSync(p);
450
- return st.isFile() || (st.isDirectory() && existsSync(join(p, "HEAD")));
484
+ return st.isFile() || (st.isDirectory() && existsSync(hostDialect().path.join(p, "HEAD")));
451
485
  }
452
486
  catch {
453
487
  return false;
@@ -458,6 +492,7 @@ function isGitDir(p) {
458
492
  * inside the workspace, the workspace root; outside it, the directory
459
493
  * itself — git would refuse to run there, and the floor does not bet on it. */
460
494
  function repoRoot(w, cwd) {
495
+ const { dirname, join } = hostDialect().path;
461
496
  const start = canon(w, cwd, ".");
462
497
  for (let d = start;; d = dirname(d)) {
463
498
  if (isGitDir(join(d, ".git")))
@@ -467,6 +502,16 @@ function repoRoot(w, cwd) {
467
502
  }
468
503
  return within(w.rootReal, start) ? w.rootReal : start;
469
504
  }
505
+ /** Windows P2: where a cd to an unreadable path lands — every relative
506
+ * path from it is unreadable too. */
507
+ const UNREADABLE_DIR = "//?/unreadable";
508
+ /** Where `cd text` from `c` lands, as the shell spells it (lexically). */
509
+ function cdTo(c, text) {
510
+ if (!hostDialect().msys)
511
+ return resolve(c, text);
512
+ const read = msysPath(text);
513
+ return "opaque" in read || c === UNREADABLE_DIR ? UNREADABLE_DIR : win32.resolve(c, read.native);
514
+ }
470
515
  /** A child shell starts where this node runs — the node's joiner has
471
516
  * already been applied — and nothing it does comes back. */
472
517
  const child = (s) => ({ cwds: [...s.cwds], lastCd: null, prev: s.prev });
@@ -544,7 +589,7 @@ export function floorCheck(commandLine, workspaceRoot, home = homedir()) {
544
589
  else if (to === undefined || to.unknownAt >= 0 || to.variableOnly)
545
590
  dest = capCwds(w, [w.home, ...state.cwds]);
546
591
  else
547
- dest = capCwds(w, state.cwds.map((c) => resolve(c, tilde(to))));
592
+ dest = capCwds(w, state.cwds.map((c) => cdTo(c, tilde(to))));
548
593
  state.prev = state.cwds;
549
594
  state.lastCd = { to: dest, from };
550
595
  continue;
@@ -554,12 +599,12 @@ export function floorCheck(commandLine, workspaceRoot, home = homedir()) {
554
599
  continue;
555
600
  let here = state.cwds;
556
601
  for (const cd of d.cds ?? [])
557
- here = cd.unknownAt >= 0 ? capCwds(w, [...here, w.home]) : here.map((c) => resolve(c, tilde(cd)));
602
+ here = cd.unknownAt >= 0 ? capCwds(w, [...here, w.home]) : here.map((c) => cdTo(c, tilde(cd)));
558
603
  for (const t of d.targets) {
559
604
  let why = null;
560
605
  if (t === "repo") {
561
606
  for (const c of here)
562
- if ((why = unrecoverable(w, repoRoot(w, c), false)) !== null)
607
+ if ((why = c === UNREADABLE_DIR ? "a repository in a directory that cannot be read" : unrecoverable(w, repoRoot(w, c), false)) !== null)
563
608
  break;
564
609
  }
565
610
  else
package/dist/index.js CHANGED
@@ -37,14 +37,16 @@ import { skillMenuItems } from "./skill-invoke.js";
37
37
  import { canonicalPath, hasSession, locateSession, projectLayoutActive, sessionFolders } from "./projects.js";
38
38
  import { reverseMigration } from "./session-migration.js";
39
39
  import { defaultTrashRoot, findEmptySessions, moveToTrash } from "./empty-sessions.js";
40
+ import { browserCommand, launchCommand } from "./launch.js";
40
41
  import { createFauxProvider } from "@vincemakes/kiso-evals";
41
42
  import { isProtectedPath, protectedIdentity, PROTECTED_REFUSAL } from "@vincemakes/kiso-tools-node";
42
- import { MODES, OFFERED_MODES, getMode, modeFromEnv, setMode } from "./mode.js";
43
+ import { OFFERED_MODES, applyModeState, envModeLayer, getDontAsk, modeDisplay, parseMode, resolveModeLayers } from "./mode.js";
43
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
45
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
45
46
  import { chat, contextWindowTokens, displayCtxRatio, microcompactThresholdFor, statusModelLabel } from "./chat.js";
46
47
  import { preferences, usePreferences } from "./preferences.js";
47
- import { settingsLayers } from "./state.js";
48
+ import { settingsLayers, setChildTurnBudget, stopAllTasks } from "./state.js";
49
+ import { finishChild } from "./child-result.js";
48
50
  import { loadUserConfig, resolveAutoCompact } from "./config.js";
49
51
  import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
50
52
  import { tmuxMouseHint } from "./tmux-hint.js";
@@ -74,14 +76,14 @@ export { applyProjectMerges } from "./trust-ui.js";
74
76
  * path's exact semantics (auto-denied asks, no chips, no pops). */
75
77
  /** OR-4: hand a URL to the platform's opener, detached, and never let a
76
78
  * missing or failing opener touch the sign-in — the printed URL is the
77
- * path that always works. macOS `open`, Linux `xdg-open`; elsewhere the
78
- * URL stays printed (Windows is unsupported by the CLI as a whole). */
79
+ * path that always works. macOS `open`, Linux `xdg-open`, Windows
80
+ * rundll32's URL handler (launch.ts); elsewhere the URL stays printed. */
79
81
  function openInBrowser(url) {
80
- const opener = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
82
+ const opener = browserCommand(url);
81
83
  if (opener === null)
82
84
  return;
83
85
  try {
84
- spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
86
+ spawn(opener[0], [...opener[1]], { detached: true, stdio: "ignore" }).unref();
85
87
  }
86
88
  catch {
87
89
  // the URL is on screen; opening it is the person's fallback
@@ -180,6 +182,9 @@ function readlineInput(rl) {
180
182
  /* §2.3: same as ctrl+o above — readline has no binding, and a
181
183
  * dock-less session has no committed rows to reprint. */
182
184
  },
185
+ onBackground() {
186
+ /* 3e: readline has no ctrl+b — /tasks and a steer still work. */
187
+ },
183
188
  onEditor() {
184
189
  /* §2.4: readline has no ctrl+g, and a piped session has no
185
190
  * composer to hand over. */
@@ -274,6 +279,9 @@ function editorInput(editor) {
274
279
  onThink(cb) {
275
280
  editor.onThink(cb);
276
281
  },
282
+ onBackground(cb) {
283
+ editor.onBackground(cb);
284
+ },
277
285
  onEditor(cb) {
278
286
  editor.onEditor(cb);
279
287
  },
@@ -488,10 +496,10 @@ function bannerExtensionText() {
488
496
  // terminal layer (extensionsBannerText — a pure function of the three
489
497
  // name lists, including the "(connecting…)" in-flight label). Which
490
498
  // lists exist is the CLI's fact and stays here.
491
- // 0.40.0: in dontAsk the ask extension is loaded but offers no tool
492
- // (builtin.ts offInDontAsk) — the banner says so beside the tier that
493
- // turns it off, rather than listing it as if it could ask.
494
- const builtIn = builtInExtensions.map((e) => (e.name === "ask" && getMode() === "dontAsk" ? { name: e.name, note: "off in dontAsk" } : e));
499
+ // 0.40.0: with the don't-ask switch on the ask extension is loaded but
500
+ // offers no tool (builtin.ts offInDontAsk) — the banner says so, rather
501
+ // than listing it as if it could ask.
502
+ const builtIn = builtInExtensions.map((e) => (e.name === "ask" && getDontAsk() ? { name: e.name, note: "off in dontAsk" } : e));
495
503
  return extensionsBannerText(builtIn, userExtensions, projectExtensions);
496
504
  }
497
505
  /** E1: the startup banner line(s) — TTY: logo + merged extensions + the
@@ -569,7 +577,7 @@ function extensionsBanner(resume = []) {
569
577
  };
570
578
  body.banner(VERSION, text.replace(/^ · /, ""), resume, {
571
579
  model: agentModel,
572
- mode: getMode() === "plan" ? "plan (read-only)" : getMode(),
580
+ mode: modeDisplay(),
573
581
  cwd: cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd,
574
582
  // DC-49 — REALPATH on both sides. A symlinked HOME (or a symlinked
575
583
  // cwd) compares unequal as raw strings while being the same
@@ -808,7 +816,7 @@ function paintBootStatus(session) {
808
816
  return;
809
817
  // DF-0330-F1: the BOOT row gets the budget too — it is the same row, and
810
818
  // the opening screen is where a long model id is first seen.
811
- dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session), undefined, process.stdout.columns > 0 ? process.stdout.columns : 80));
819
+ dock.setStatus(idleStatus(modeDisplay(), statusModelLabel(session), displayCtxRatio(session), undefined, process.stdout.columns > 0 ? process.stdout.columns : 80));
812
820
  }
813
821
  /** PH-1a (finding PH-F12): a usage error raised from inside the TUI —
814
822
  * main's finally still runs (dock teardown, agent close, temp cleanup)
@@ -816,6 +824,9 @@ function paintBootStatus(session) {
816
824
  class CliUsageError extends Error {
817
825
  exitCode = 2;
818
826
  }
827
+ /** ADR-0058 3d: set when this process is a background child (its budget
828
+ * and where its answer goes) — finished once its turn is done. */
829
+ let childEnd = null;
819
830
  /** The /resume+/clear mini-spec — the chat LOOP: chat() ends with a
820
831
  * directive; a switch re-enters it on another session with the SAME
821
832
  * editor. First entry paints the banner; a switch paints one notice
@@ -1103,8 +1114,11 @@ async function chatLoop(first, firstId, input, autoCompact) {
1103
1114
  ...(process.stdin.isTTY ? { pick: () => pickSession(agent, input) } : {}),
1104
1115
  };
1105
1116
  const end = await chat(session, currentFaux, input, autoCompact, nav, seed);
1106
- if (end.next === "exit")
1117
+ if (end.next === "exit") {
1118
+ if (childEnd !== null)
1119
+ await finishChild(session, childEnd);
1107
1120
  return;
1121
+ }
1108
1122
  // DC-57: the lines that arrived with the switch command ride INTO the
1109
1123
  // next entry — they were aimed at the session being asked for.
1110
1124
  seed = end.lines ?? [];
@@ -1250,23 +1264,30 @@ async function main() {
1250
1264
  }
1251
1265
  args.splice(modelArgIdx, 2);
1252
1266
  }
1253
- // Modes: --mode wins over KISO_MODE, which wins over the USER config's
1254
- // mode (the project config's mode applies later — after the trust gate,
1255
- // inside createCodingAgent — unless a higher layer already decided).
1267
+ // Modes: flag > env > project config > user config > default, for the
1268
+ // tier and the don't-ask switch each (mode.ts resolveModeLayers). The
1269
+ // project config exists only once the trust gate has passed, so it joins
1270
+ // later (applyConfigMode); until then the other three decide.
1271
+ const flagLayer = {};
1256
1272
  const modeFlag = args.indexOf("--mode");
1257
1273
  if (modeFlag !== -1) {
1258
- const m = MODES.find((x) => x === args[modeFlag + 1]);
1259
- if (m === undefined) {
1260
- console.error(`unknown mode: ${args[modeFlag + 1]} (tiers: ${OFFERED_MODES.join(", ")})`);
1274
+ const raw = args[modeFlag + 1];
1275
+ if (raw === undefined || parseMode(raw) === undefined) {
1276
+ console.error(`unknown mode: ${raw} (tiers: ${OFFERED_MODES.join(", ")})`);
1261
1277
  process.exit(2);
1262
1278
  }
1263
- setMode(m);
1264
- settingsLayers.modeFlag = m;
1279
+ flagLayer.mode = raw;
1280
+ settingsLayers.modeFlag = raw;
1265
1281
  args.splice(modeFlag, 2);
1266
1282
  }
1267
- else {
1268
- setMode(modeFromEnv() ?? loadUserConfig()?.mode ?? "default");
1283
+ const dontAskFlag = args.indexOf("--dont-ask");
1284
+ if (dontAskFlag !== -1) {
1285
+ flagLayer.dontAsk = true;
1286
+ settingsLayers.dontAskFlag = true;
1287
+ args.splice(dontAskFlag, 1);
1269
1288
  }
1289
+ const modeLayers = (project) => ({ flag: flagLayer, env: envModeLayer(), project, user: loadUserConfig() });
1290
+ applyModeState(resolveModeLayers(modeLayers(null)));
1270
1291
  // CX-1 F5: --task-file <path> — the file is the ONE user turn (the
1271
1292
  // structured child entry). Read BEFORE any session exists: an
1272
1293
  // unreadable file is a usage error with nothing executed.
@@ -1291,6 +1312,35 @@ async function main() {
1291
1312
  args.splice(i, 2);
1292
1313
  }
1293
1314
  }
1315
+ // ADR-0058 3d: a background child's --max-turns N (its budget, D6) and
1316
+ // --result-file <path> (where its answer goes). Child-only: without
1317
+ // --task-file they are refused — the interactive door has no turn
1318
+ // limit (R3e).
1319
+ {
1320
+ const end = {};
1321
+ for (const flag of ["--max-turns", "--result-file"]) {
1322
+ const i = args.indexOf(flag);
1323
+ if (i === -1)
1324
+ continue;
1325
+ const value = args[i + 1];
1326
+ if (taskFile === undefined)
1327
+ throw new CliUsageError(`${flag} needs --task-file — it is for a delegated child only`);
1328
+ if (value === undefined || value.startsWith("--"))
1329
+ throw new CliUsageError(`${flag} needs a value`);
1330
+ if (flag === "--max-turns") {
1331
+ const n = Number(value);
1332
+ if (!Number.isInteger(n) || n < 1)
1333
+ throw new CliUsageError(`--max-turns: expected a whole number of at least 1, got ${value}`);
1334
+ end.maxTurns = n;
1335
+ }
1336
+ else
1337
+ end.resultFile = value;
1338
+ args.splice(i, 2);
1339
+ }
1340
+ if (end.maxTurns !== undefined || end.resultFile !== undefined)
1341
+ childEnd = end;
1342
+ setChildTurnBudget(end.maxTurns);
1343
+ }
1294
1344
  // 0.40.0: `kiso sessions --all | --current` — the one command that takes
1295
1345
  // them. Parsed only there, so neither ever becomes a session id. Absent
1296
1346
  // = each form's default (TTY: current; pipe: all, today's bytes).
@@ -1433,10 +1483,9 @@ async function main() {
1433
1483
  try {
1434
1484
  // merge round B: the project config's mode applies AFTER the trust gate
1435
1485
  // (its verdict decides whether the project config exists at all) —
1436
- // unless a higher layer (--mode flag / KISO_MODE) already decided.
1486
+ // under whatever a higher layer (the flags, the env) already decided.
1437
1487
  const applyConfigMode = () => {
1438
- if (modeFlag === -1 && process.env.KISO_MODE === undefined && mergedConfig.mode !== undefined)
1439
- setMode(mergedConfig.mode);
1488
+ applyModeState(resolveModeLayers(modeLayers(settingsLayers.project)));
1440
1489
  };
1441
1490
  if (printPrompt !== undefined) {
1442
1491
  // the -p flow: recovery-first one-shot, the resume() machinery
@@ -1553,7 +1602,7 @@ async function main() {
1553
1602
  console.log("kiso sessions --prune-empty --yes moves them to the Trash");
1554
1603
  }
1555
1604
  else
1556
- console.log(`moved ${n(empty.length, "empty session")} to ${moveToTrash(empty, defaultTrashRoot())}`);
1605
+ console.log(`moved ${n(empty.length, "empty session")} to ${moveToTrash(empty, defaultTrashRoot(kisoHome()))}`);
1557
1606
  if (inUse > 0)
1558
1607
  console.log(`skipped ${n(inUse, "empty session")} in use — a live process holds the lock`);
1559
1608
  break;
@@ -1719,7 +1768,9 @@ async function main() {
1719
1768
  // npm's to explain; the message names the manual command so
1720
1769
  // the person can run it with whatever their setup needs.
1721
1770
  const [bin, ...installArgs] = INSTALL_COMMAND;
1722
- const r = spawnSync(bin, installArgs, { stdio: "inherit" });
1771
+ // Windows: npm is npm.cmd — through cmd.exe (launch.ts)
1772
+ const l = launchCommand(bin, installArgs);
1773
+ const r = spawnSync(l.file, [...l.args], { stdio: "inherit", ...(l.windowsVerbatimArguments === true ? { windowsVerbatimArguments: true } : {}) });
1723
1774
  if (r.error !== undefined || r.status !== 0) {
1724
1775
  const why = r.error !== undefined ? `could not run npm (${r.error.message})` : `npm exited ${r.status ?? "on a signal"}`;
1725
1776
  process.stderr.write(`kiso update: ${why} — run it yourself: ${INSTALL_COMMAND.join(" ")}\n`);
@@ -1751,7 +1802,8 @@ async function main() {
1751
1802
  " kiso help this help\n\n" +
1752
1803
  "flags (any position):\n" +
1753
1804
  " --model <profile|provider/model> pick the model (also /model in-session)\n" +
1754
- " --mode <tier> approval tier: default|accept-edits|plan|dontAsk|bypass\n" +
1805
+ " --mode <tier> approval tier: default|accept-edits|plan|full-access\n" +
1806
+ " --dont-ask never stop for a person: what would ask is refused\n" +
1755
1807
  " --version print the version\n\n" +
1756
1808
  "configuration:\n" +
1757
1809
  " no key keyless faux demo (a scripted four-round session)\n" +
@@ -1795,11 +1847,19 @@ async function main() {
1795
1847
  // variable now). A signal death skips this and leaves the dead-pid
1796
1848
  // residue — the dead-holder takeover recovers it by design
1797
1849
  // (ADR-0050); the lock never outlives a live writer either way.
1850
+ // ADR-0058: a clean exit stops the session's tasks first — or, when
1851
+ // the person chose to leave them (3e), only the moved commands. What
1852
+ // could not be confirmed is said, never called stopped.
1853
+ const tasksAtExit = await stopAllTasks();
1798
1854
  agent?.close();
1799
1855
  // v2b: the dock tears down on EVERY exit path — CSI r resets the
1800
1856
  // scroll region, the cursor lands at the input line, no broken
1801
1857
  // terminal (kill -9 excepted; `reset` saves it).
1802
1858
  dock.exit();
1859
+ for (const id of tasksAtExit.unconfirmed)
1860
+ console.error(`${id}: stop unconfirmed — outcome unknown`);
1861
+ if (tasksAtExit.left.length > 0)
1862
+ console.error(`left running: ${tasksAtExit.left.join(", ")} — reopen this session to hear how ${tasksAtExit.left.length === 1 ? "it ends" : "they end"}`);
1803
1863
  // finding #8 (P1): extension dispose runs on the same exit path — a
1804
1864
  // dispose failure prints one line and NEVER changes the exit code.
1805
1865
  await disposeExtensions(loadedExtensions);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Windows P3 — how the CLI's small spawns start: `kiso update` (npm), the
3
+ * external editor, and the sign-in page.
4
+ *
5
+ * npm is npm.cmd and an editor is often code.cmd: a .cmd/.bat shim needs
6
+ * cmd.exe to start, and Node will not start one without a shell. Rather
7
+ * than `shell: true`, the call goes to cmd.exe itself — `/d /s /c "<line>"`
8
+ * — with every word quoted by `viaCmd`, the one function here (the
9
+ * cross-spawn rules: CommandLineToArgvW quoting, then a caret before each
10
+ * cmd.exe metacharacter). The shell tool never comes this way: it only
11
+ * ever starts bash (P1).
12
+ *
13
+ * Pure — no process is started here, so importing it moves no test into
14
+ * the pty pool.
15
+ */
16
+ export interface Launch {
17
+ readonly file: string;
18
+ readonly args: readonly string[];
19
+ /** Windows: the line is cmd.exe's to parse, not Node's to requote */
20
+ readonly windowsVerbatimArguments?: true;
21
+ }
22
+ /** `bin args` as cmd.exe starts it. */
23
+ export declare function viaCmd(bin: string, args: readonly string[], env?: NodeJS.ProcessEnv): Launch;
24
+ /** `bin args` as this platform starts it: directly, or through cmd.exe on Windows. */
25
+ export declare function launchCommand(bin: string, args: readonly string[]): Launch;
26
+ /** The platform's URL opener — `rundll32`'s URL handler on Windows (cmd's
27
+ * `start` would cut a sign-in URL at its first `&`) — or null, and the
28
+ * URL stays printed. */
29
+ export declare function browserCommand(url: string): readonly [string, readonly string[]] | null;
package/dist/launch.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Windows P3 — how the CLI's small spawns start: `kiso update` (npm), the
3
+ * external editor, and the sign-in page.
4
+ *
5
+ * npm is npm.cmd and an editor is often code.cmd: a .cmd/.bat shim needs
6
+ * cmd.exe to start, and Node will not start one without a shell. Rather
7
+ * than `shell: true`, the call goes to cmd.exe itself — `/d /s /c "<line>"`
8
+ * — with every word quoted by `viaCmd`, the one function here (the
9
+ * cross-spawn rules: CommandLineToArgvW quoting, then a caret before each
10
+ * cmd.exe metacharacter). The shell tool never comes this way: it only
11
+ * ever starts bash (P1).
12
+ *
13
+ * Pure — no process is started here, so importing it moves no test into
14
+ * the pty pool.
15
+ */
16
+ /** cmd.exe's metacharacters: each is escaped with a caret. */
17
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
18
+ /** One argument, quoted as CommandLineToArgvW reads it, then escaped for cmd.exe. */
19
+ function cmdArgument(arg) {
20
+ const quoted = `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`;
21
+ return quoted.replace(CMD_META, "^$1");
22
+ }
23
+ /** `bin args` as cmd.exe starts it. */
24
+ export function viaCmd(bin, args, env = process.env) {
25
+ const line = [bin.replace(CMD_META, "^$1"), ...args.map(cmdArgument)].join(" ");
26
+ return { file: env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true };
27
+ }
28
+ /** `bin args` as this platform starts it: directly, or through cmd.exe on Windows. */
29
+ export function launchCommand(bin, args) {
30
+ return process.platform === "win32" ? viaCmd(bin, args) : { file: bin, args };
31
+ }
32
+ /** The platform's URL opener — `rundll32`'s URL handler on Windows (cmd's
33
+ * `start` would cut a sign-in URL at its first `&`) — or null, and the
34
+ * URL stays printed. */
35
+ export function browserCommand(url) {
36
+ if (process.platform === "darwin")
37
+ return ["open", [url]];
38
+ if (process.platform === "linux")
39
+ return ["xdg-open", [url]];
40
+ if (process.platform === "win32")
41
+ return ["rundll32", ["url.dll,FileProtocolHandler", url]];
42
+ return null;
43
+ }