@vincemakes/kiso-code 0.45.2 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dispatch.js CHANGED
@@ -3,10 +3,10 @@
3
3
  * turns. The bodies moved verbatim from chat()'s closure; chat provides
4
4
  * the context (the chain, the run state, the prompt arming).
5
5
  */
6
- import { STATUS_GLYPHS, contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, compactingStatus, palette, renderEvent, settledLabel, slashCommandNames } from "@vincemakes/kiso-tui";
6
+ import { STATUS_GLYPHS, contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, namedPickView, compactingStatus, palette, renderEvent, settledLabel, slashCommandNames } from "@vincemakes/kiso-tui";
7
7
  import { newSessionId } from "./session-id.js";
8
8
  import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
9
- import { MODES, MODE_NOTE, OFFERED_MODES, getMode, setMode } from "./mode.js";
9
+ import { MODE_LABEL, MODE_NOTE, OFFERED_MODES, applyModeSetting, getDontAsk, getMode, modeDisplay, parseMode, setDontAsk } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
11
  import { protectedBangReason, protectedShellVerdict } from "./protected-shell.js";
12
12
  import { agentBaseUrl, currentProfileName, setCurrentProfileName, currentModelName, agentModel, body, bodyLog, codingToolOptions, protectedFiles, kisoHome, configModels, dock, lastBinding, loadedSkillsCatalog, mergedConfig, readContextLedger, retryOnRow, sessionsDir, setAgentModel, setConfiguredWindow, setCurrentModelName, setModelChoice, setRetryShown, upstreamOf, VERSION, setLastBinding } from "./state.js";
@@ -17,7 +17,8 @@ import { preferences, 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 } from "./state.js";
20
+ import { queuedSwitchLines, seenUnknownTasks, tasksFor } from "./state.js";
21
+ import { taskOutput, taskRow, taskStateLabel } from "./task-notice.js";
21
22
  import { contextWindowTokens, microcompactThresholdFor, startStatusSpinner, statedContextWindow, windowSourceNote } from "./chat.js";
22
23
  import { authForProfile, directWriteProfile, profileAvailable, resolveContextWindow, unavailableReason } from "./config.js";
23
24
  import { shellTool } from "@vincemakes/kiso-tools-node";
@@ -396,6 +397,16 @@ export function dispatch(line, ctx) {
396
397
  });
397
398
  return;
398
399
  }
400
+ if (trimmed === "\x02background") {
401
+ // ADR-0058 (3e) — ctrl+b: the running foreground command(s) to the
402
+ // background, through the session's TaskManager (§6), at once — a
403
+ // steer waits for a command to be 2 s old (D1), the key never does.
404
+ // Off the chain on purpose: it is pressed DURING a run.
405
+ const moved = tasksFor(ctx.session.id)?.detachAll("person") ?? [];
406
+ if (moved.length === 0)
407
+ body.notice("nothing to move to the background");
408
+ return;
409
+ }
399
410
  if (trimmed === "\x14think") {
400
411
  // §2.3 / 0.40.6 — ctrl+t hides thinking (one italic line per block,
401
412
  // the open one included) and shows it again. DC-50's mechanism: one
@@ -510,7 +521,9 @@ export function dispatch(line, ctx) {
510
521
  env: process.env,
511
522
  ...(settingsLayers.modeFlag !== undefined ? { modeFlag: settingsLayers.modeFlag } : {}),
512
523
  ...(settingsLayers.modelFlag !== undefined ? { modelFlag: settingsLayers.modelFlag } : {}),
524
+ dontAskFlag: settingsLayers.dontAskFlag,
513
525
  mode: getMode(),
526
+ dontAsk: getDontAsk(),
514
527
  model: { label: `${agentModel}${providerLabel(agentBaseUrl, upstreamOf(agentBaseUrl))}${profile === null ? "" : ` · profile ${profile}`}`, profile, switched: settingsLayers.modelSwitched },
515
528
  ground: currentGround(),
516
529
  floorOn,
@@ -563,59 +576,89 @@ export function dispatch(line, ctx) {
563
576
  // /mode <name> switches — the notice cell leaves the audit
564
577
  // line in the body, the status bar repaints at once.
565
578
  ctx.chainRef.current = ctx.chainRef.current.then(async () => {
566
- const m = MODES.find((x) => x === trimmed.slice(5).trim());
567
- if (trimmed.slice(5).trim() === "") {
579
+ const arg = trimmed.slice(5).trim();
580
+ // a tier switch, old names included — and when the switch came
581
+ // with the old dontAsk tier and leaves with it, a line says so
582
+ const switchTier = (s) => {
583
+ const wasOn = getDontAsk();
584
+ applyModeSetting(s);
585
+ body.notice(`mode → ${MODE_LABEL[getMode()]}`);
586
+ if (getDontAsk() !== wasOn)
587
+ body.notice(`don't ask → ${getDontAsk() ? "on" : "off"}`);
588
+ ctx.paintIdle();
589
+ };
590
+ if (arg === "") {
568
591
  // DC-36 — BARE /mode PICKS, the way bare /model has since
569
- // TUI2-R2 ④. The five tiers are a CLOSED set, which makes
570
- // this the least defensible place in the product to have
571
- // asked a human to type the answer: everything needed to
572
- // make it a choice was on screen and only the choosing was
573
- // missing.
592
+ // TUI2-R2 ④. The tiers are a CLOSED set, which makes this the
593
+ // least defensible place in the product to have asked a human
594
+ // to type the answer: everything needed to make it a choice
595
+ // was on screen and only the choosing was missing.
574
596
  //
575
- // The printed form survives VERBATIM wherever there is no
576
- // panel to draw — a pipe, a dock-less TTY — because that is
577
- // a machine-readable surface and this round moves no bytes
578
- // on one. `/mode <name>` is untouched.
597
+ // The printed form survives wherever there is no panel to
598
+ // draw — a pipe, a dock-less TTY — because that is a
599
+ // machine-readable surface. `/mode <name>` is untouched.
579
600
  if (dock.active && ctx.input.panelAsk !== undefined) {
580
601
  const current = getMode();
581
602
  const picked = await new Promise((resolve) => {
582
603
  ctx.input.panelAsk(modePickView({
583
- header: `mode — current: ${current}`,
584
- options: OFFERED_MODES.map((name) => ({ label: name, note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
604
+ // the header names the switch when it is on; the
605
+ // rows are the tiers alone — don't ask is not a tier
606
+ // and is not offered beside them (owner, 2026-09-30)
607
+ header: `mode — current: ${modeDisplay()}`,
608
+ options: OFFERED_MODES.map((name) => ({ label: MODE_LABEL[name], note: [MODE_NOTE[name], ...(name === current ? ["current"] : [])].join(" · ") })),
585
609
  // MP-1 (0.40.7): the cursor opens on the tier in force
586
610
  ...(OFFERED_MODES.indexOf(current) >= 0 ? { initial: OFFERED_MODES.indexOf(current) } : {}),
587
611
  },
588
612
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯.
589
- ctx.isRunning() ? "❯ run paused" : `▸ ${current}`), (v) => resolve(v.action === "picked" ? v.result : null));
613
+ ctx.isRunning() ? "❯ run paused" : `▸ ${modeDisplay()}`), (v) => resolve(v.action === "picked" ? v.result : null));
590
614
  });
591
615
  ctx.paintIdle();
592
616
  if (picked === null) {
593
617
  ctx.input.prompt();
594
618
  return; // esc — nothing switched, nothing said
595
619
  }
596
- const chosen = "index" in picked ? OFFERED_MODES[picked.index] : MODES.find((x) => x === picked.custom.trim());
620
+ const chosen = "index" in picked ? (OFFERED_MODES[picked.index] === undefined ? undefined : { mode: OFFERED_MODES[picked.index] }) : parseMode(picked.custom);
597
621
  if (chosen === undefined) {
598
622
  bodyLog(`no such mode: ${"index" in picked ? String(picked.index) : picked.custom.trim()}`);
599
623
  bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
600
624
  }
601
625
  else {
602
- setMode(chosen);
603
- body.notice(`mode → ${chosen}`);
604
- ctx.paintIdle();
626
+ switchTier(chosen);
605
627
  }
606
628
  ctx.input.prompt();
607
629
  return;
608
630
  }
609
631
  bodyLog(`mode ${getMode()}`);
632
+ if (getDontAsk())
633
+ bodyLog("don't ask on");
610
634
  bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
611
635
  }
612
- else if (m === undefined) {
613
- bodyLog(`no such mode: ${trimmed.slice(5).trim()}`);
614
- bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
636
+ else {
637
+ const typed = parseMode(arg);
638
+ if (typed === undefined) {
639
+ bodyLog(`no such mode: ${arg}`);
640
+ bodyLog(`tiers: ${OFFERED_MODES.join(" ")}`);
641
+ }
642
+ else {
643
+ switchTier(typed);
644
+ }
645
+ }
646
+ ctx.input.prompt();
647
+ });
648
+ return;
649
+ }
650
+ if (trimmed === "/dont-ask" || trimmed.startsWith("/dont-ask ")) {
651
+ // The don't-ask switch on its own — the tier stays. Bare flips it;
652
+ // `on` / `off` set it. A switch set here outlives tier changes.
653
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
654
+ const arg = trimmed.slice(9).trim();
655
+ const want = arg === "" ? !getDontAsk() : arg === "on" ? true : arg === "off" ? false : undefined;
656
+ if (want === undefined) {
657
+ bodyLog("usage: /dont-ask [on|off]");
615
658
  }
616
659
  else {
617
- setMode(m);
618
- body.notice(`mode → ${m}`);
660
+ setDontAsk(want);
661
+ body.notice(`don't ask → ${want ? "on" : "off"}`);
619
662
  ctx.paintIdle();
620
663
  }
621
664
  ctx.input.prompt();
@@ -684,7 +727,7 @@ export function dispatch(line, ctx) {
684
727
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯. The
685
728
  // else-branch keeps ▸ — it names the current tier, which is
686
729
  // what ▸ means everywhere else.
687
- ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) => {
730
+ ctx.isRunning() ? "❯ run paused" : `▸ ${modeDisplay()}`), (v) => {
688
731
  // OR-7: the level rides beside the result, so a pick
689
732
  // with no second axis is byte-for-byte the old one.
690
733
  if (v.action === "picked")
@@ -1034,15 +1077,31 @@ export function dispatch(line, ctx) {
1034
1077
  // streams), a pipe's "exit" always arrives mid-run, and the
1035
1078
  // approval panel the fix protects only exists on the dock. An idle
1036
1079
  // exit is immediate, byte-for-byte as before, on every surface.
1080
+ // 3e: with live tasks the dock asks first; staying re-prompts
1081
+ const exitAfterAsking = async () => {
1082
+ if (await ctx.confirmExit())
1083
+ ctx.input.close();
1084
+ else
1085
+ ctx.input.prompt();
1086
+ };
1037
1087
  if (ctx.isRunning() && dock.active) {
1038
1088
  if (trimmed === "exit")
1039
1089
  body.notice("[exit queued — closing after the current run completes]");
1040
- ctx.chainRef.current = ctx.chainRef.current.then(async () => {
1041
- ctx.input.close();
1042
- });
1090
+ ctx.chainRef.current = ctx.chainRef.current.then(exitAfterAsking);
1043
1091
  return;
1044
1092
  }
1045
- ctx.input.close();
1093
+ void exitAfterAsking();
1094
+ return;
1095
+ }
1096
+ if (trimmed === "/tasks" || trimmed.startsWith("/tasks ")) {
1097
+ // ADR-0058 (3e): the session's tasks, for the PERSON. Everything here
1098
+ // is drawn on the screen and nowhere else — no user_input, no event
1099
+ // in the session log, nothing the model sees, no wake.
1100
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
1101
+ await tasksCommand(trimmed.slice("/tasks".length).trim(), ctx);
1102
+ ctx.paintIdle();
1103
+ ctx.input.prompt();
1104
+ });
1046
1105
  return;
1047
1106
  }
1048
1107
  if (trimmed === "/clear") {
@@ -1188,3 +1247,76 @@ export function dispatch(line, ctx) {
1188
1247
  // live count rides the status bar (+N queued).
1189
1248
  ctx.submitTurn(line);
1190
1249
  }
1250
+ /** ADR-0058 (3e) — `/tasks`, `/tasks show <id>`, `/tasks stop <id>`. On a
1251
+ * dock the bare command is a pick: a task, then what to do with it. A
1252
+ * stop is REQUESTED here; the row says `stopping` until the journal says
1253
+ * how it ended. Looking at the list counts as having seen its unknown
1254
+ * tasks (the status row stops counting them). */
1255
+ async function tasksCommand(arg, ctx) {
1256
+ const manager = tasksFor(ctx.session.id);
1257
+ if (manager === undefined) {
1258
+ bodyLog("no tasks here — this session has no task manager");
1259
+ return;
1260
+ }
1261
+ let list;
1262
+ try {
1263
+ list = manager.list();
1264
+ }
1265
+ catch (err) {
1266
+ bodyLog(`tasks: ${err.message}`);
1267
+ return;
1268
+ }
1269
+ const show = (t) => {
1270
+ const lines = taskOutput(t);
1271
+ bodyLog(`${t.id} — ${t.agent !== undefined ? "its answer" : "its last output"}${lines.length === 0 ? ": nothing yet" : ""}`);
1272
+ for (const line of lines)
1273
+ bodyLog(` ${line}`);
1274
+ };
1275
+ const stop = (t) => {
1276
+ bodyLog(manager.stop(t.id, "person") ? `${t.id}: stop requested` : `${t.id}: nothing to stop (${taskStateLabel(t)})`);
1277
+ };
1278
+ const [verb, id] = arg.split(/\s+/);
1279
+ if (verb === "show" || verb === "stop") {
1280
+ const t = list.find((x) => x.id === id);
1281
+ if (t === undefined)
1282
+ bodyLog(`no task ${id ?? ""} in this session`);
1283
+ else if (verb === "show")
1284
+ show(t);
1285
+ else
1286
+ stop(t);
1287
+ return;
1288
+ }
1289
+ if (arg !== "") {
1290
+ bodyLog("usage: /tasks · /tasks show <id> · /tasks stop <id>");
1291
+ return;
1292
+ }
1293
+ if (list.length === 0) {
1294
+ bodyLog("no tasks in this session");
1295
+ return;
1296
+ }
1297
+ for (const t of list)
1298
+ if (t.state.kind === "unknown")
1299
+ seenUnknownTasks.add(t.id);
1300
+ if (!dock.active) {
1301
+ for (const t of list) {
1302
+ const r = taskRow(t);
1303
+ bodyLog(`${r.label} ${r.note}`);
1304
+ }
1305
+ return;
1306
+ }
1307
+ const ask = (header, options) => new Promise((resolve) => {
1308
+ ctx.input.panelAsk(namedPickView("tasks", { header, options }, ctx.isRunning() ? "❯ run paused" : "▸ tasks", "which task? (id) "), (v) => resolve(v.action === "picked" && "index" in v.result ? v.result.index : null));
1309
+ });
1310
+ const shown = list.slice(-20); // the newest twenty; /tasks show <id> reaches any
1311
+ const which = await ask(`tasks — ${list.length} in this session`, shown.map((t) => taskRow(t)));
1312
+ const task = which === null ? undefined : shown[which];
1313
+ if (task === undefined)
1314
+ return;
1315
+ const live = task.state.kind === "running" || task.state.kind === "starting";
1316
+ const actions = [task.agent !== undefined ? "show its answer" : "show its output", ...(live ? ["stop it"] : [])];
1317
+ const action = await ask(`${task.id} · ${taskStateLabel(task)} · ${taskRow(task).note}`, actions.map((label) => ({ label })));
1318
+ if (action === 0)
1319
+ show(task);
1320
+ else if (action === 1)
1321
+ stop(task);
1322
+ }
@@ -14,8 +14,10 @@ export interface EmptyScan {
14
14
  export declare function pidAlive(pid: number): boolean;
15
15
  /** Every session in `dirs` with a sidecar and no log. Reads only. */
16
16
  export declare function findEmptySessions(dirs: readonly string[], alive?: (pid: number) => boolean): EmptyScan;
17
- /** The platform's Trash: ~/.Trash on macOS, the freedesktop one elsewhere. */
18
- export declare function defaultTrashRoot(): string;
17
+ /** The platform's Trash: ~/.Trash on macOS, kiso's own folder on Windows
18
+ * (its Recycle Bin is not a folder a program moves files into), the
19
+ * freedesktop one elsewhere. */
20
+ export declare function defaultTrashRoot(kisoHome: string): string;
19
21
  /** Move the sessions into one new folder under `trashRoot`, keeping each
20
22
  * session folder's name; returns that folder. Never deletes: a file that
21
23
  * cannot be renamed across devices is copied, then unlinked. */
@@ -72,10 +72,14 @@ export function findEmptySessions(dirs, alive = pidAlive) {
72
72
  }
73
73
  return { empty, inUse };
74
74
  }
75
- /** The platform's Trash: ~/.Trash on macOS, the freedesktop one elsewhere. */
76
- export function defaultTrashRoot() {
75
+ /** The platform's Trash: ~/.Trash on macOS, kiso's own folder on Windows
76
+ * (its Recycle Bin is not a folder a program moves files into), the
77
+ * freedesktop one elsewhere. */
78
+ export function defaultTrashRoot(kisoHome) {
77
79
  if (process.platform === "darwin")
78
80
  return join(homedir(), ".Trash");
81
+ if (process.platform === "win32")
82
+ return join(kisoHome, "trash");
79
83
  return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "Trash", "files");
80
84
  }
81
85
  /** Move the sessions into one new folder under `trashRoot`, keeping each
@@ -23,6 +23,7 @@
23
23
  import { spawnSync } from "node:child_process";
24
24
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
25
25
  import { join } from "node:path";
26
+ import { launchCommand } from "./launch.js";
26
27
  /**
27
28
  * Run `bin` on `text` and return what came back, or `null` when the edit
28
29
  * failed — in which case `onFailure` has already been told, in one line,
@@ -43,7 +44,9 @@ export function runExternalEditor(bin, root, text, onFailure) {
43
44
  const file = join(dir, "message.md"); // .md so the editor lights it up
44
45
  try {
45
46
  writeFileSync(file, text, { encoding: "utf8", mode: 0o600 });
46
- const r = spawnSync(bin, [file], { stdio: "inherit", shell: false });
47
+ // Windows: an editor is often a .cmd shim (code.cmd) — through cmd.exe
48
+ const l = launchCommand(bin, [file]);
49
+ const r = spawnSync(l.file, [...l.args], { stdio: "inherit", shell: false, ...(l.windowsVerbatimArguments === true ? { windowsVerbatimArguments: true } : {}) });
47
50
  if (r.error !== undefined || (r.status !== null && r.status !== 0)) {
48
51
  onFailure(`[ctrl+g] ${bin} exited ${r.status ?? "abnormally"} — the composer is unchanged`);
49
52
  return null;
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