@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/README.md +2 -1
- package/dist/builtin.d.ts +5 -4
- package/dist/builtin.js +12 -7
- package/dist/chat.js +121 -19
- package/dist/child-result.d.ts +23 -0
- package/dist/child-result.js +59 -0
- package/dist/config.d.ts +13 -6
- package/dist/config.js +30 -16
- package/dist/create-coding-agent.js +3 -1
- package/dist/dispatch.d.ts +2 -0
- package/dist/dispatch.js +163 -31
- package/dist/empty-sessions.d.ts +4 -2
- package/dist/empty-sessions.js +6 -2
- package/dist/external-editor.js +4 -1
- package/dist/floor.js +71 -26
- package/dist/index.js +89 -29
- package/dist/launch.d.ts +29 -0
- package/dist/launch.js +43 -0
- package/dist/mode.d.ts +90 -17
- package/dist/mode.js +135 -32
- package/dist/protected-shell.js +60 -19
- package/dist/protected-writes.d.ts +2 -2
- package/dist/protected-writes.js +2 -2
- package/dist/readonly-shell.js +16 -14
- package/dist/replay.js +3 -0
- package/dist/resume.js +2 -2
- package/dist/settings.d.ts +3 -0
- package/dist/settings.js +41 -11
- package/dist/shell-words.d.ts +34 -1
- package/dist/shell-words.js +84 -4
- package/dist/state.d.ts +31 -0
- package/dist/state.js +88 -2
- package/dist/task-notice.d.ts +22 -0
- package/dist/task-notice.js +86 -0
- package/dist/trust-ui.js +6 -6
- package/package.json +14 -14
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 {
|
|
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
|
|
567
|
-
|
|
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
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
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
|
|
576
|
-
//
|
|
577
|
-
//
|
|
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
|
|
584
|
-
|
|
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" : `▸ ${
|
|
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] :
|
|
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
|
-
|
|
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
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
618
|
-
body.notice(`
|
|
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" : `▸ ${
|
|
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(
|
|
1041
|
-
ctx.input.close();
|
|
1042
|
-
});
|
|
1090
|
+
ctx.chainRef.current = ctx.chainRef.current.then(exitAfterAsking);
|
|
1043
1091
|
return;
|
|
1044
1092
|
}
|
|
1045
|
-
|
|
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
|
+
}
|
package/dist/empty-sessions.d.ts
CHANGED
|
@@ -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,
|
|
18
|
-
|
|
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. */
|
package/dist/empty-sessions.js
CHANGED
|
@@ -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,
|
|
76
|
-
|
|
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
|
package/dist/external-editor.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
46
|
-
import {
|
|
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
|
|
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
|
|
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(
|
|
80
|
-
mounts: named(
|
|
81
|
-
temps: [...new Set(
|
|
82
|
-
prefixes: named(
|
|
83
|
-
subtrees:
|
|
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 ===
|
|
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
|
|
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
|
|
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
|
|
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
|
|
166
|
+
if (same(p, m.real))
|
|
141
167
|
return what(`a system root (${m.name})`);
|
|
142
|
-
if (dirname(p)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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
|