@vincemakes/kiso-code 0.25.0 → 0.26.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/chat.js CHANGED
@@ -1182,17 +1182,28 @@ export async function chat(session, faux, input, autoCompact, nav) {
1182
1182
  return;
1183
1183
  dispatch("/compact", dispatchCtx);
1184
1184
  };
1185
+ // CX-1 F5: a literal input (task-file mode) submits the turn directly —
1186
+ // its bytes never meet the slash dispatcher.
1187
+ const route = (line) => {
1188
+ if (input.literal === true)
1189
+ dispatchCtx.submitTurn(line);
1190
+ else
1191
+ dispatch(line, dispatchCtx);
1192
+ };
1185
1193
  input.onLine((line) => {
1186
1194
  if (!replReady) {
1187
1195
  queuedLines.push(line);
1188
1196
  return;
1189
1197
  }
1190
- dispatch(line, dispatchCtx);
1198
+ route(line);
1191
1199
  });
1192
1200
  // W15: the expand key — the editor forwards ctrl+o; dispatch runs the
1193
1201
  // chain action (the sentinel's control char marks the key, so a typed
1194
1202
  // "expand" turn is never intercepted).
1195
1203
  input.onExpand(() => dispatch("\x12expand", dispatchCtx));
1204
+ // E1 §3 — ctrl+x and `/copy` are the same action reached two ways, so
1205
+ // they are the same sentinel: one implementation, one behaviour.
1206
+ input.onCopy(() => dispatch("\x18copy", dispatchCtx));
1196
1207
  // R3a — Shift+Tab: the approval-tier cycle (the /mode ring, in the
1197
1208
  // MODES order). The switch is the SAME live-extension flip /mode
1198
1209
  // performs; the status row repaints at once with a one-line notice.
@@ -1243,7 +1254,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1243
1254
  // chain BEFORE the appends and the replayed turns would never be
1244
1255
  // awaited (the F-group regression).
1245
1256
  for (const line of queuedLines) {
1246
- dispatch(line, dispatchCtx);
1257
+ route(line);
1247
1258
  }
1248
1259
  queuedLines.length = 0;
1249
1260
  input.prompt();
@@ -35,3 +35,55 @@
35
35
  export declare function clipboardImage(dir: string, run?: (cmd: string, args: readonly string[]) => {
36
36
  status: number | null;
37
37
  }): string | null;
38
+ /**
39
+ * E1 §3 — THE LAST ANSWER, from the record rather than from the screen.
40
+ *
41
+ * The source is the session's PROJECTION (`session.projected()`), which
42
+ * is derived from committed events: a message a retry or an abort voided
43
+ * is already absent from it, and so is anything still streaming. Two
44
+ * things this deliberately is NOT:
45
+ *
46
+ * - the rendered rows, which are folded, washed and cut — a paste of
47
+ * those is a paste of the terminal's layout, not of the answer;
48
+ * - a join of the trailing `text_delta` frames, which are a live
49
+ * projection and not a record.
50
+ *
51
+ * Null when there is no complete answer yet, so the caller can say so
52
+ * rather than copying an empty string over whatever the user had.
53
+ */
54
+ export declare function lastAnswer(messages: readonly import("@vincemakes/kiso-core").Message[]): string | null;
55
+ /** What the clipboard route did, and the one sentence the status row may
56
+ * say about it. The message is the product of this function because the
57
+ * claim and the act must not be able to drift apart. */
58
+ export interface ClipboardWrite {
59
+ readonly ok: boolean;
60
+ readonly message: string;
61
+ }
62
+ interface WriteEnv {
63
+ readonly platform: string;
64
+ readonly isTTY: boolean;
65
+ readonly run: (cmd: string, args: readonly string[], stdin?: string) => {
66
+ status: number | null;
67
+ };
68
+ readonly write: (s: string) => void;
69
+ }
70
+ /**
71
+ * E1 §3 — PUT TEXT ON THE CLIPBOARD, and say only what happened.
72
+ *
73
+ * Two routes, and they differ in what they can KNOW:
74
+ *
75
+ * - **darwin, `pbcopy`**: a real process with a real exit status. When
76
+ * it succeeds we can say `copied`. When it fails we say so.
77
+ * - **everywhere else, OSC 52**: a REQUEST to the terminal. Most
78
+ * terminals do not answer it and Apple Terminal does not implement
79
+ * it at all, so there is no success to report — only that we asked.
80
+ * The row says `asked the terminal to copy N chars`. An earlier
81
+ * draft said `copied N chars (via the terminal)`, which still leads
82
+ * with "copied"; a reader takes the first word.
83
+ *
84
+ * And a route that does not exist: **stdout that is not a TTY emits
85
+ * nothing**. An OSC written into a pipe is escape bytes in someone's
86
+ * data.
87
+ */
88
+ export declare function clipboardWrite(text: string, env?: Partial<WriteEnv>): ClipboardWrite;
89
+ export {};
package/dist/clipboard.js CHANGED
@@ -68,3 +68,81 @@ export function clipboardImage(dir, run = defaultRun) {
68
68
  function defaultRun(cmd, args) {
69
69
  return spawnSync(cmd, [...args], { stdio: "ignore", timeout: 5000 });
70
70
  }
71
+ /**
72
+ * E1 §3 — THE LAST ANSWER, from the record rather than from the screen.
73
+ *
74
+ * The source is the session's PROJECTION (`session.projected()`), which
75
+ * is derived from committed events: a message a retry or an abort voided
76
+ * is already absent from it, and so is anything still streaming. Two
77
+ * things this deliberately is NOT:
78
+ *
79
+ * - the rendered rows, which are folded, washed and cut — a paste of
80
+ * those is a paste of the terminal's layout, not of the answer;
81
+ * - a join of the trailing `text_delta` frames, which are a live
82
+ * projection and not a record.
83
+ *
84
+ * Null when there is no complete answer yet, so the caller can say so
85
+ * rather than copying an empty string over whatever the user had.
86
+ */
87
+ export function lastAnswer(messages) {
88
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
89
+ const m = messages[i];
90
+ if (m.role !== "assistant")
91
+ continue;
92
+ // A turn that only called tools said nothing to copy. Its blocks
93
+ // are real, so `blocks.length > 0` is not the test — the test is
94
+ // whether any of them is TEXT.
95
+ const text = m.blocks
96
+ .filter((b) => b.type === "text")
97
+ .map((b) => b.text)
98
+ .join("\n\n");
99
+ if (text !== "")
100
+ return text;
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * E1 §3 — PUT TEXT ON THE CLIPBOARD, and say only what happened.
106
+ *
107
+ * Two routes, and they differ in what they can KNOW:
108
+ *
109
+ * - **darwin, `pbcopy`**: a real process with a real exit status. When
110
+ * it succeeds we can say `copied`. When it fails we say so.
111
+ * - **everywhere else, OSC 52**: a REQUEST to the terminal. Most
112
+ * terminals do not answer it and Apple Terminal does not implement
113
+ * it at all, so there is no success to report — only that we asked.
114
+ * The row says `asked the terminal to copy N chars`. An earlier
115
+ * draft said `copied N chars (via the terminal)`, which still leads
116
+ * with "copied"; a reader takes the first word.
117
+ *
118
+ * And a route that does not exist: **stdout that is not a TTY emits
119
+ * nothing**. An OSC written into a pipe is escape bytes in someone's
120
+ * data.
121
+ */
122
+ export function clipboardWrite(text, env = {}) {
123
+ const platform = env.platform ?? process.platform;
124
+ const isTTY = env.isTTY ?? process.stdout.isTTY === true;
125
+ const run = env.run ?? ((cmd, args, stdin) => spawnSync(cmd, [...args], { input: stdin, stdio: ["pipe", "ignore", "ignore"], timeout: 5000 }));
126
+ const write = env.write ?? ((s) => process.stdout.write(s));
127
+ const n = [...text].length;
128
+ // NO TTY, NO COPY — and this is checked BEFORE the platform, which is
129
+ // the correction the pipe gate forced.
130
+ //
131
+ // The first build let darwin's `pbcopy` run regardless, on the
132
+ // reasoning that pbcopy is a process and not a terminal route so it
133
+ // works with stdout redirected. It does work — and that is the
134
+ // problem: a script running `kiso chat < input.txt` would silently
135
+ // overwrite whatever the human had on their clipboard. A side effect
136
+ // on the user's desktop is not something a pipe should be able to
137
+ // cause by accident.
138
+ if (!isTTY)
139
+ return { ok: false, message: "no terminal to copy to" };
140
+ if (platform === "darwin") {
141
+ const r = run("pbcopy", [], text);
142
+ return r.status === 0
143
+ ? { ok: true, message: `copied ${n} chars` }
144
+ : { ok: false, message: `could not copy — pbcopy exited ${r.status ?? "abnormally"}` };
145
+ }
146
+ write(`\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`);
147
+ return { ok: true, message: `asked the terminal to copy ${n} chars` };
148
+ }
package/dist/dispatch.js CHANGED
@@ -7,6 +7,7 @@ import { contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpR
7
7
  import { newSessionId } from "./session-id.js";
8
8
  import { buildAdapter, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
9
9
  import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
10
+ import { clipboardWrite, lastAnswer } from "./clipboard.js";
10
11
  import { agentModel, body, bodyLog, configModels, dock, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
11
12
  import { directWriteProfile, profileAvailable } from "./config.js";
12
13
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
@@ -106,6 +107,48 @@ export function dispatch(line, ctx) {
106
107
  ctx.input.prompt();
107
108
  return;
108
109
  }
110
+ if (trimmed === "\x18copy" || trimmed === "/copy") {
111
+ // E1 §3 — the last answer onto the clipboard, as RAW MARKDOWN.
112
+ //
113
+ // The source is the session's PROJECTION, which is derived from
114
+ // committed events: a message a retry or an abort voided is already
115
+ // absent from it, and anything still streaming has not landed in it
116
+ // yet. Deliberately NOT the rendered rows (folded, washed, cut —
117
+ // that is the terminal's layout, not the answer) and NOT a join of
118
+ // the trailing text_delta frames (a live projection, not a record).
119
+ //
120
+ // One implementation for the key and the command: they are the same
121
+ // action reached two ways, and two implementations would be two
122
+ // behaviours waiting to drift.
123
+ // IT CHAINS, like `/last` and unlike `ctrl+o`. The distinction is
124
+ // what the action DEPENDS ON: ctrl+o repaints what is already on
125
+ // screen and can answer at once, while a copy reads the last
126
+ // ANSWER — which may be the turn currently in flight.
127
+ //
128
+ // Found by the pipe gate: a pipe hands the CLI every line at once,
129
+ // so an unchained `/copy` typed after `go` ran BEFORE the turn and
130
+ // said "nothing to copy — no answer yet" above the answer it was
131
+ // asked to copy.
132
+ ctx.chainRef.current = ctx.chainRef.current.then(async () => {
133
+ const answer = lastAnswer(ctx.session.projected());
134
+ if (answer === null) {
135
+ bodyLog("[nothing to copy — no answer yet]");
136
+ }
137
+ else {
138
+ const r = clipboardWrite(answer);
139
+ // The MESSAGE comes from clipboardWrite, never from here —
140
+ // the claim and the act must not be able to drift apart. On
141
+ // the dock it rides the W18 hint slot (gone at the next
142
+ // keypress); in a pipe it is a line like any other.
143
+ if (dock.active)
144
+ dock.setStatus(r.message);
145
+ else
146
+ bodyLog(`[${r.message}]`);
147
+ }
148
+ ctx.input.prompt();
149
+ });
150
+ return;
151
+ }
109
152
  if (trimmed === "/rewrap") {
110
153
  // R4 (C4d): re-print the recent PROSE at the CURRENT width, at the
111
154
  // bottom. kiso hard-folds every row before committing it (invariant
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@
26
26
  import { appendFileSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
27
27
  import { newSessionId } from "./session-id.js";
28
28
  import { createInterface } from "node:readline";
29
+ import { Readable } from "node:stream";
29
30
  import { fileURLToPath } from "node:url";
30
31
  import { join } from "node:path";
31
32
  import { Body, Editor, bannerLines, currentGround, resolveGround, setGround, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListRow } from "@vincemakes/kiso-tui";
@@ -38,7 +39,7 @@ import { agentModel, atFiles, body, bodyLog, kisoHome, builtInExtensions, curren
38
39
  import { askUi, resolveProjectTrust } from "./trust-ui.js";
39
40
  import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
40
41
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
41
- import { autoCompactFromEnv, chat, contextWindowTokens, estimateCtxRatio } from "./chat.js";
42
+ import { chat, contextWindowTokens, estimateCtxRatio } from "./chat.js";
42
43
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
43
44
  import { checkForUpdate } from "./update-check.js";
44
45
  import { resume } from "./resume.js";
@@ -54,6 +55,22 @@ export { applyProjectMerges } from "./trust-ui.js";
54
55
  * prompt. Only ever constructed when stdin is NOT a TTY. The rl starts
55
56
  * consuming stdin at construction (main), so 'line' events are buffered
56
57
  * until chat() wires the handler — pipe input must never be dropped. */
58
+ /** CX-1 F5 (audit F5): the task-file input — the file's whole content is
59
+ * ONE turn, delivered the moment the loop listens, and the input is
60
+ * closed right after: chat still awaits the in-flight chain before it
61
+ * exits, so the turn runs to its terminal and nothing else is read.
62
+ * Built on an EMPTY readline so every other member keeps the pipe
63
+ * path's exact semantics (auto-denied asks, no chips, no pops). */
64
+ function taskFileInput(content) {
65
+ const base = readlineInput(createInterface({ input: Readable.from([]), output: process.stdout }));
66
+ return {
67
+ ...base,
68
+ literal: true,
69
+ onLine(cb) {
70
+ cb(content);
71
+ },
72
+ };
73
+ }
57
74
  function readlineInput(rl) {
58
75
  let lineCb = null;
59
76
  const pending = [];
@@ -84,6 +101,10 @@ function readlineInput(rl) {
84
101
  /* readline has no ctrl+o binding — ignored (W15 rides the
85
102
  * editor path only). */
86
103
  },
104
+ onCopy() {
105
+ /* readline has no ctrl+x binding — ignored. `/copy` still
106
+ * works here: it is a typed command, not a key. */
107
+ },
87
108
  question(query, cb) {
88
109
  rl.question(query, cb);
89
110
  },
@@ -164,6 +185,9 @@ function editorInput(editor) {
164
185
  onExpand(cb) {
165
186
  editor.onExpand(cb);
166
187
  },
188
+ onCopy(cb) {
189
+ editor.onCopy(cb);
190
+ },
167
191
  // KC2 §2: the redirect gesture — the editor decides WHEN (the
168
192
  // same-chunk pair, the precedence gate); chat decides what it MEANS.
169
193
  onRedirect(cb) {
@@ -402,10 +426,26 @@ function extensionsBanner(resume = []) {
402
426
  // filesystem calls it.
403
427
  const home = homedir();
404
428
  const cwd = process.cwd();
429
+ /** DC-49: realpath, falling back to the raw path when it cannot be
430
+ * resolved — an unresolvable path is not a match, and throwing here
431
+ * would take the banner down over a cosmetic row. */
432
+ const realOf = (dir) => {
433
+ try {
434
+ return realpathSync(dir);
435
+ }
436
+ catch {
437
+ return dir;
438
+ }
439
+ };
405
440
  body.banner(VERSION, text.replace(/^ · /, ""), resume, {
406
441
  model: agentModel,
407
442
  mode: getMode() === "plan" ? "plan (read-only)" : getMode(),
408
443
  cwd: cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd,
444
+ // DC-49 — REALPATH on both sides. A symlinked HOME (or a symlinked
445
+ // cwd) compares unequal as raw strings while being the same
446
+ // directory, and the row would then be absent exactly where it is
447
+ // most needed.
448
+ homeWorkspace: realOf(cwd) === realOf(home),
409
449
  });
410
450
  }
411
451
  /**
@@ -578,7 +618,7 @@ async function makeAgent(sessionId, input, modelFlag) {
578
618
  store,
579
619
  // Area 5: the coding tools are bound to the workspace — every path
580
620
  // they touch is canonicalized inside cwd, escapes are refused.
581
- tools: [...createCodingTools({ workspaceRoot: process.cwd() })],
621
+ tools: [...createCodingTools({ workspaceRoot: process.cwd(), excludeRoots: [kisoHome()] })], // DC-49
582
622
  // Modes: the five tiers ride the E1 policy chain (mode:<tier>
583
623
  // extensions, current tier first) — the old static PERMISSION_POLICY
584
624
  // is gone, its semantics live in the "default" tier. The banner
@@ -844,6 +884,25 @@ async function main() {
844
884
  else {
845
885
  setMode(modeFromEnv() ?? loadUserConfig()?.mode ?? "default");
846
886
  }
887
+ // CX-1 F5: --task-file <path> — the file is the ONE user turn (the
888
+ // structured child entry). Read BEFORE any session exists: an
889
+ // unreadable file is a usage error with nothing executed.
890
+ let taskFile;
891
+ {
892
+ const i = args.indexOf("--task-file");
893
+ if (i !== -1) {
894
+ const path = args[i + 1];
895
+ if (path === undefined)
896
+ throw new CliUsageError("--task-file needs a path");
897
+ try {
898
+ taskFile = readFileSync(path, "utf8");
899
+ }
900
+ catch (err) {
901
+ throw new CliUsageError(`--task-file: cannot read ${path}: ${err.message}`);
902
+ }
903
+ args.splice(i, 2);
904
+ }
905
+ }
847
906
  const [command, arg] = args;
848
907
  // round 8: faux mode is the keyless demo script — an exhausted script must
849
908
  // exit non-zero, never masquerade as a successful provider run. The
@@ -855,7 +914,7 @@ async function main() {
855
914
  // (entered here, dock-bound, trusted before any extension loads),
856
915
  // readline elsewhere. The trust question, chat, and resume all read
857
916
  // through it; main's finally closes it on every exit path.
858
- const input = makeLineInput();
917
+ const input = taskFile !== undefined ? taskFileInput(taskFile) : makeLineInput();
859
918
  // R3a — cross-session input history: ~/.kiso/history, one line per
860
919
  // entry, appended on submit, tail-500 at load (truncated by REWRITE
861
920
  // at startup so the file never grows unbounded). Unreadable file =
@@ -1073,7 +1132,12 @@ async function main() {
1073
1132
  agent = await makeAgent(id, input, modelFlag);
1074
1133
  // finding E4-1's faux resolution rides chatLoop (currentFaux).
1075
1134
  faux = currentFaux;
1076
- await chatLoop(agent, id, input, autoCompactFromEnv());
1135
+ // CX-1 F9 (audit F9): the bare entry resolves autoCompact from
1136
+ // the SAME merged config chat/resume use — the env override
1137
+ // lives inside the resolver. It used to pass the env-only
1138
+ // reader, so a user's config.json autoCompact worked on
1139
+ // `kiso chat` and silently vanished on the default entry.
1140
+ await chatLoop(agent, id, input, resolveAutoCompact(mergedConfig));
1077
1141
  break;
1078
1142
  }
1079
1143
  }
package/dist/state.d.ts CHANGED
@@ -55,6 +55,10 @@ export declare function readContextLedger(sessionId: string, window: number): im
55
55
  * the fuzzy filter sees one alphabet whichever branch ran.
56
56
  */
57
57
  export declare function atFiles(): readonly AtItem[];
58
+ /** DC-49 — the walk, addressable for the gate. `atFiles` is driven by
59
+ * `process.cwd()` and `kisoHome()`, neither of which a unit case can
60
+ * set honestly; this is the same function with both supplied. */
61
+ export declare function atWalkFor(root: string, excludeRoots: readonly string[]): readonly string[];
58
62
  /**
59
63
  * v2c — the interactive input source. TTYs use the raw-mode Editor (the
60
64
  * self-drawn input row — width-aware, the CJK-drift root cause retired,
@@ -63,6 +67,9 @@ export declare function atFiles(): readonly AtItem[];
63
67
  * source.
64
68
  */
65
69
  export interface LineInput {
70
+ /** CX-1 F5: task-file mode — every line delivered is a TURN, never a
71
+ * command; the dispatcher is bypassed (a leading `/` is content). */
72
+ readonly literal?: boolean;
66
73
  onLine(cb: (line: string) => void): void;
67
74
  onSigint(cb: () => void): void;
68
75
  onEot(cb: () => void): void;
@@ -70,6 +77,10 @@ export interface LineInput {
70
77
  /** W15: the expand key (ctrl+o) — the chain-level action, never the
71
78
  * editor's own interpretation. */
72
79
  onExpand(cb: () => void): void;
80
+ /** E1 §3: the copy key (ctrl+x) — the chain-level action, never the
81
+ * composer's own. The readline input ignores it; `/copy` is the
82
+ * route that works everywhere. */
83
+ onCopy(cb: () => void): void;
73
84
  /** KC2 §2: the redirect gesture (Alt+Enter / Ctrl+Enter) — the
74
85
  * buffer's text arrives as a line at the same instant the run is told
75
86
  * to stop. OPTIONAL: the pipe path has no raw keys and never wires
package/dist/state.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * the moved modules read and mutate at call time.
7
7
  */
8
8
  import { execFileSync } from "node:child_process";
9
- import { readdirSync, readFileSync } from "node:fs";
9
+ import { readdirSync, readFileSync, realpathSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
@@ -119,7 +119,8 @@ export function atFiles() {
119
119
  paths = execFileSync("git", ["ls-files", "-c", "-o", "--exclude-standard"], { cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 24 }).split("\n").filter((p) => p !== "");
120
120
  }
121
121
  catch {
122
- paths = atWalk(process.cwd(), "", []);
122
+ // DC-49: the fallback walk excludes kiso's own state directory.
123
+ paths = atWalk(process.cwd(), "", [], [kisoHome()]);
123
124
  }
124
125
  return paths.slice(0, AT_CAP + 1).map((path) => ({ path }));
125
126
  }
@@ -128,15 +129,26 @@ export function atFiles() {
128
129
  * directory contributes nothing and ends nothing — the recursion's own
129
130
  * try catches it at that level, so one locked subtree cannot stop a
130
131
  * file picker from opening. */
131
- function atWalk(dir, prefix, out) {
132
+ function atWalk(dir, prefix, out, excluded = []) {
132
133
  try {
133
134
  for (const e of readdirSync(dir, { withFileTypes: true })) {
134
135
  if (out.length > AT_CAP)
135
136
  break;
136
137
  if (AT_SKIP.has(e.name))
137
138
  continue;
138
- if (e.isDirectory())
139
- atWalk(`${dir}/${e.name}`, `${prefix}${e.name}/`, out);
139
+ const full = `${dir}/${e.name}`;
140
+ if (e.isDirectory()) {
141
+ // DC-49 — the picker does not DESCEND into an excluded root.
142
+ //
143
+ // `AT_SKIP` is a list of NAMES with no dot rule, so unlike
144
+ // tools-node's walk this one has never skipped `~/.kiso` at
145
+ // all: with the workspace at `~` and no git repo there, `@`
146
+ // lists the user's own session logs among their files. That
147
+ // is current behaviour, not a future risk.
148
+ if (excluded.length > 0 && isUnder(full, excluded))
149
+ continue;
150
+ atWalk(full, `${prefix}${e.name}/`, out, excluded);
151
+ }
140
152
  else if (e.isFile())
141
153
  out.push(`${prefix}${e.name}`);
142
154
  }
@@ -146,6 +158,34 @@ function atWalk(dir, prefix, out) {
146
158
  }
147
159
  return out;
148
160
  }
161
+ /** DC-49 — realpath on BOTH sides. darwin's `/tmp` is a symlink to
162
+ * `/private/tmp`, so a raw string compare is a check that passes on one
163
+ * machine and not another. */
164
+ function isUnder(dir, roots) {
165
+ let real;
166
+ try {
167
+ real = realpathSync(dir);
168
+ }
169
+ catch {
170
+ real = dir;
171
+ }
172
+ return roots.some((r) => {
173
+ let rr;
174
+ try {
175
+ rr = realpathSync(r);
176
+ }
177
+ catch {
178
+ rr = r;
179
+ }
180
+ return real === rr || real.startsWith(`${rr}/`);
181
+ });
182
+ }
183
+ /** DC-49 — the walk, addressable for the gate. `atFiles` is driven by
184
+ * `process.cwd()` and `kisoHome()`, neither of which a unit case can
185
+ * set honestly; this is the same function with both supplied. */
186
+ export function atWalkFor(root, excludeRoots) {
187
+ return atWalk(root, "", [], excludeRoots);
188
+ }
149
189
  /** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
150
190
  * NO_COLOR stay the v2a line mode byte-for-byte. Created at load, like
151
191
  * the pre-split module-scope const. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
4
4
  "description": "kiso CLI — the durable coding agent that survives kill -9: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,19 +18,19 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-ask-ext": "0.25.0",
22
- "@vincemakes/kiso-core": "0.25.0",
23
- "@vincemakes/kiso-evals": "0.25.0",
24
- "@vincemakes/kiso-mcp-ext": "0.25.0",
25
- "@vincemakes/kiso-provider-anthropic": "0.25.0",
26
- "@vincemakes/kiso-provider-openai": "0.25.0",
27
- "@vincemakes/kiso-runtime": "0.25.0",
28
- "@vincemakes/kiso-skills-ext": "0.25.0",
29
- "@vincemakes/kiso-subagent-ext": "0.25.0",
30
- "@vincemakes/kiso-task-ext": "0.25.0",
31
- "@vincemakes/kiso-tools-node": "0.25.0",
32
- "@vincemakes/kiso-tui": "0.25.0",
33
- "@vincemakes/kiso-tui-cells": "0.25.0"
21
+ "@vincemakes/kiso-ask-ext": "0.26.1",
22
+ "@vincemakes/kiso-core": "0.26.1",
23
+ "@vincemakes/kiso-evals": "0.26.1",
24
+ "@vincemakes/kiso-mcp-ext": "0.26.1",
25
+ "@vincemakes/kiso-provider-anthropic": "0.26.1",
26
+ "@vincemakes/kiso-provider-openai": "0.26.1",
27
+ "@vincemakes/kiso-runtime": "0.26.1",
28
+ "@vincemakes/kiso-skills-ext": "0.26.1",
29
+ "@vincemakes/kiso-subagent-ext": "0.26.1",
30
+ "@vincemakes/kiso-task-ext": "0.26.1",
31
+ "@vincemakes/kiso-tools-node": "0.26.1",
32
+ "@vincemakes/kiso-tui": "0.26.1",
33
+ "@vincemakes/kiso-tui-cells": "0.26.1"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^26.1.2",