@vincemakes/kiso-code 0.39.1 → 0.40.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/index.js CHANGED
@@ -30,18 +30,27 @@ import { createInterface } from "node:readline";
30
30
  import { Readable } from "node:stream";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import { join } from "node:path";
33
- import { Body, Editor, PROMPT, bannerLines, currentGround, resolveGround, setGround, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListRow } from "@vincemakes/kiso-tui";
33
+ import { Body, Editor, PROMPT, bannerLines, currentGround, resolveGround, setGround, escapeTerminal, extensionsBannerText, idColumn, idleStatus, interactivePrompt, palette, renderSessionLine, sessionListFooter, sessionListHeader, sessionListRow, sessionListUnknownLine, slashCommandNames } from "@vincemakes/kiso-tui";
34
34
  import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, SessionStore, } from "@vincemakes/kiso-runtime";
35
+ import { listSessionSidecars, migrateSummaries, readProfile, runsACheck, summaryMigrationPending } from "@vincemakes/kiso-runtime/internal";
36
+ import { skillMenuItems } from "./skill-invoke.js";
37
+ import { canonicalPath, claimProjectDir, hasSession, locateSession, projectLayoutActive, sessionFolders } from "./projects.js";
38
+ import { migrationNotice, pendingLegacyIds, planMigration, reverseMigration, runMigration } from "./session-migration.js";
35
39
  import { createFauxProvider } from "@vincemakes/kiso-evals";
36
- import { createCodingTools } from "@vincemakes/kiso-tools-node";
37
- import { MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
40
+ import { createCodingTools, isProtectedPath, protectedIdentity, PROTECTED_REFUSAL } from "@vincemakes/kiso-tools-node";
41
+ import { MODES, OFFERED_MODES, getMode, modeExtensions, modeFromEnv, modeSystemPrompt, setMode } from "./mode.js";
42
+ import { readOnlyShellExtension } from "./readonly-shell.js";
43
+ import { guardSavedAllow, isProtectedWrite } from "./protected-writes.js";
44
+ import { floorExtension, isDestructiveCall } from "./floor.js";
45
+ import { protectedShellExtension } from "./protected-shell.js";
38
46
  import { breakerExtension } from "./breaker.js";
39
47
  import { builtInLayer } from "./builtin.js";
40
- import { agentModel, atFiles, body, bodyLog, codingToolOptions, kisoHome, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, modelChoice, projectExtensions, configModels, configuredWindow, agentBaseUrl, currentModelName, currentAgentExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setMergedConfig, setModelChoice, setSessionStore, secretEnvNamesOf, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift } from "./state.js";
48
+ import { agentModel, atFiles, body, bodyLog, codingToolOptions, kisoHome, workspaceRoot, projectRoot, ownSessionsDir, setOpenSessionFolder, builtInExtensions, currentFaux, dock, extensionsDir, loadedExtensions, mergedConfig, mergedTempPaths, modelChoice, projectExtensions, configModels, configuredWindow, agentBaseUrl, currentModelName, currentAgentExtensions, sessionStoreRef, sessionsDir, setAgentModel, setBody, setConfigModels, setConfiguredWindow, setCurrentAgentExtensions, setCurrentFaux, setCurrentModelName, setExtensionLists, setUserProtectedPaths, protectedFiles, setMergedConfig, setModelChoice, setSessionStore, setRetryShown, setNeverInherited, secretEnvNamesOf, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift, setFloorOn, floorOn, loadedSkillsCatalog } from "./state.js";
49
+ import { maxRetriesFromEnv } from "./retries.js";
41
50
  import { askUi, resolveProjectTrust } from "./trust-ui.js";
42
51
  import { isFirstRun, scaffoldFirstRun } from "./first-run.js";
43
52
  import { fauxSkip, readFauxScript } from "./faux-glue.js";
44
- import { chat, displayCtxRatio, microcompactThresholdFor, statusModelLabel } from "./chat.js";
53
+ import { chat, contextWindowTokens, displayCtxRatio, microcompactThresholdFor, statusModelLabel } from "./chat.js";
45
54
  import { adapterOptionsFor } from "./auth/adapter-options.js";
46
55
  import { loadProjectConfig, loadUserConfig, mergeConfigs, resolveAutoCompact, resolveContextWindow, resolveModel } from "./config.js";
47
56
  import { checkForUpdate, knownUpdate, updateCardLines } from "./update-check.js";
@@ -49,10 +58,11 @@ import { tmuxMouseHint } from "./tmux-hint.js";
49
58
  import { resume } from "./resume.js";
50
59
  import { paintWindowTitle } from "./window-title.js";
51
60
  import { resumeTail } from "./resume-tail.js";
61
+ import { replayInto } from "./replay.js";
52
62
  import { armByteTrace } from "./byte-trace.js";
53
63
  import { tmpdir, homedir } from "node:os";
54
64
  import { clipboardImage } from "./clipboard.js";
55
- import { collectSessionCards, projectSessionCard } from "./session-cards.js";
65
+ import { cardsFromListings } from "./session-cards.js";
56
66
  // The moved exports stay reachable from this entry — the test imports
57
67
  // (project-trust, coding-agent) never change (B4: zero assertion changes).
58
68
  export { applyProjectMerges } from "./trust-ui.js";
@@ -316,8 +326,8 @@ function editorInput(editor) {
316
326
  // TUI2-R2 ②: the session picker — the editor owns the keys (the
317
327
  // selection walk, the filter, enter/esc), the compositor draws the
318
328
  // band, and the id comes back here.
319
- pick(cards, onPick) {
320
- editor.beginPick(cards, onPick);
329
+ pick(cards, onPick, here) {
330
+ editor.beginPick(cards, onPick, here);
321
331
  },
322
332
  // W22: the pending-turn queue — the ↑ pop walk (the keys); the
323
333
  // chips are the compositor's bindQueue (the dock side).
@@ -372,7 +382,10 @@ function userThemeSetting() {
372
382
  function makeLineInput() {
373
383
  const userTheme = userThemeSetting();
374
384
  if (process.stdin.isTTY) {
375
- const editor = new Editor(() => (dock.active ? dock.redraw() : editor.selfRender()));
385
+ const editor = new Editor((fromKey) => (dock.active ? dock.redraw(fromKey) : editor.selfRender()));
386
+ // 0.40.1: the installed skills join the `/` menu — read live, so a
387
+ // skill /reload adds is offered on the next keystroke
388
+ editor.bindMenuExtras(() => skillMenuItems(loadedSkillsCatalog(), slashCommandNames()));
376
389
  editor.enter();
377
390
  // DC-3: ask the terminal what its background is, and paint the
378
391
  // first frame without waiting for the answer.
@@ -484,7 +497,11 @@ function bannerExtensionText() {
484
497
  // terminal layer (extensionsBannerText — a pure function of the three
485
498
  // name lists, including the "(connecting…)" in-flight label). Which
486
499
  // lists exist is the CLI's fact and stays here.
487
- return extensionsBannerText(builtInExtensions, userExtensions, projectExtensions);
500
+ // 0.40.0: in dontAsk the ask extension is loaded but offers no tool
501
+ // (builtin.ts offInDontAsk) — the banner says so beside the tier that
502
+ // turns it off, rather than listing it as if it could ask.
503
+ const builtIn = builtInExtensions.map((e) => (e.name === "ask" && getMode() === "dontAsk" ? { name: e.name, note: "off in dontAsk" } : e));
504
+ return extensionsBannerText(builtIn, userExtensions, projectExtensions);
488
505
  }
489
506
  /** E1: the startup banner line(s) — TTY: logo + merged extensions + the
490
507
  * W5 resume list as a LIVE banner cell (W1: the tier re-derives on
@@ -619,9 +636,18 @@ const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
619
636
  * return it as an injected section, or "" when none exists. Truncated at
620
637
  * 8KB with an explicit note. Pure — read once per session, so the prompt
621
638
  * is byte-stable for the session's lifetime.
639
+ *
640
+ * kiso never serves its own credential store to a model, and this read is
641
+ * the one that needs no model at all: a cloned repository whose AGENTS.md
642
+ * is a symlink to the store would put it in the SYSTEM PROMPT of every
643
+ * request, with no tool call and no trust prompt. A file that resolves to
644
+ * a protected one is skipped as though absent.
622
645
  */
623
- export function readProjectInstructions(cwd) {
646
+ export function readProjectInstructions(cwd, protectedFiles = []) {
647
+ const id = protectedIdentity(protectedFiles);
624
648
  for (const name of INSTRUCTION_FILES) {
649
+ if (isProtectedPath(join(cwd, name), id))
650
+ continue;
625
651
  let text;
626
652
  try {
627
653
  text = readFileSync(join(cwd, name), "utf8");
@@ -635,8 +661,8 @@ export function readProjectInstructions(cwd) {
635
661
  }
636
662
  /** A area: the session's system prompt — the constant plus any project
637
663
  * instructions found in the workspace. Deterministic per cwd. */
638
- export function composeSystemPrompt(cwd) {
639
- const injected = readProjectInstructions(cwd);
664
+ export function composeSystemPrompt(cwd, protectedFiles = []) {
665
+ const injected = readProjectInstructions(cwd, protectedFiles);
640
666
  return injected === "" ? SYSTEM_PROMPT : `${SYSTEM_PROMPT}\n${injected}`;
641
667
  }
642
668
  /**
@@ -700,7 +726,12 @@ async function makeAgent(sessionId, input, modelFlag) {
700
726
  // the trust record is the first home write, the scaffold the second.
701
727
  if (isFirstRun())
702
728
  scaffoldFirstRun();
703
- const store = new SessionStore(sessionsDir());
729
+ // 0.40.0 — one folder per project: the one-time move of the legacy
730
+ // folder, then this project's folder claimed (made, its workspace
731
+ // recorded). Both write, so both sit behind the verdict, with the store.
732
+ prepareSessionFolders();
733
+ activeStoreDir = sessionsDir();
734
+ const store = new SessionStore(activeStoreDir);
704
735
  // TUI2-R2 ②/③: the navigation surfaces read through THIS store — one
705
736
  // store per process, and the picker/listing never write through it.
706
737
  setSessionStore(store);
@@ -736,7 +767,12 @@ async function makeAgent(sessionId, input, modelFlag) {
736
767
  // DT-1a: what a delegated task may NAME — the configured checks and the
737
768
  // model profiles — handed to the (in-process) subagent extension through
738
769
  // the environment. A model never supplies a command; it names a check.
739
- process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({ checks: merged.checks ?? {}, profiles: Object.keys(merged.models ?? {}) });
770
+ // 0.40.0: and the folder this process keeps its sessions in — a child
771
+ // writes beside its parent, whatever directory it runs in. Through this
772
+ // channel and not an exported KISO_SESSIONS_DIR: a variable in
773
+ // process.env would reach every shell child, and a kiso started from a
774
+ // shell tool would then write into this project's folder.
775
+ process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({ checks: merged.checks ?? {}, profiles: Object.keys(merged.models ?? {}), sessionsDir: sessionsDir() });
740
776
  setConfigModels(merged.models ?? {});
741
777
  const resolved = resolveModel(modelFlag, merged);
742
778
  // AFTER the model resolves: the window a PROFILE states is about that
@@ -762,14 +798,45 @@ async function makeAgent(sessionId, input, modelFlag) {
762
798
  // re-reads the config's extensions array per run).
763
799
  // LT-2: the loop breaker at the chain HEAD — it speaks first when it speaks,
764
800
  // so `decidedBy` names it; a deny there beats every tier, bypass included.
765
- const extensions = [breakerExtension(), ...modeExtensions(), ...loadedExtensions];
801
+ // 0.40.0: the read-only shell allow sits after the tiers — an allow from
802
+ // it outranks a tier's ask and names itself in decidedBy.
803
+ // 0.40.0: a saved allow never carries a write into .git/ or .kiso/, nor
804
+ // a destructive shell command.
805
+ const workspaceRoot = () => codingToolOptions().workspaceRoot;
806
+ const neverInherited = (call) => isProtectedWrite(call, workspaceRoot()) || isDestructiveCall(call);
807
+ setNeverInherited(neverInherited);
808
+ // 0.40.0: the catastrophe floor, at the chain's HEAD — a deny there
809
+ // names itself in decidedBy and outranks every tier, bypass included.
810
+ // Read per agent, so /reload picks up an edited user config.
811
+ const userConfig = loadUserConfig();
812
+ setFloorOn(userConfig?.floor !== "off");
813
+ // kiso never serves its own credential store to a model: the file tools
814
+ // refuse it through codingToolOptions, and this member denies a shell
815
+ // line naming it — at the HEAD with the floor, in every mode, and NOT
816
+ // switched off with it (`floor: "off"` lowers the catastrophe floor,
817
+ // never this).
818
+ setUserProtectedPaths(userConfig?.protectedPaths);
819
+ const extensions = [
820
+ protectedShellExtension({ files: protectedFiles, workspaceRoot, env: () => ({ home: homedir(), kisoHome: kisoHome() }) }),
821
+ floorExtension(() => floorOn, workspaceRoot),
822
+ breakerExtension(),
823
+ ...modeExtensions(workspaceRoot),
824
+ readOnlyShellExtension(codingToolOptions),
825
+ ...loadedExtensions.map((e) => guardSavedAllow(e, neverInherited)),
826
+ ];
766
827
  setCurrentAgentExtensions(extensions);
767
828
  // E6: the run-start context policy (captured once — exactOptionalPropertyTypes).
768
829
  const contextPolicy = contextPolicyFromEnv();
769
830
  const idleFromEnv = streamIdleFromEnv(); // read once: a narrowed const, not a call per spread
831
+ const retriesFromEnv = maxRetriesFromEnv();
770
832
  const definition = {
771
833
  model,
772
834
  store,
835
+ // 0.40.0: a NEW session records where it started; the picker scopes by
836
+ // it. The profile name is recorded only when the model came from a
837
+ // config profile — a direct provider/model or an env key names none.
838
+ workspace: workspaceRoot(),
839
+ ...(resolved !== null && merged.models?.[resolved.name] === resolved.profile ? { profileName: resolved.name } : {}),
773
840
  // Area 5: the coding tools are bound to the workspace — every path
774
841
  // they touch is canonicalized inside cwd, escapes are refused.
775
842
  tools: [...createCodingTools(codingToolOptions())], // DC-49 — the options live in state.ts, shared with the `!` command's runner
@@ -779,19 +846,19 @@ async function makeAgent(sessionId, input, modelFlag) {
779
846
  // still counts loadedExtensions only — the modes are in-process,
780
847
  // never a file extension.
781
848
  systemPrompt: (() => {
782
- const sp = composeSystemPrompt(process.cwd());
849
+ const sp = composeSystemPrompt(process.cwd(), protectedFiles());
783
850
  const extra = modeSystemPrompt();
784
851
  return extra === undefined ? sp : `${sp}\n\n${extra}`;
785
852
  })(),
786
- // C area: microcompact is ON by default in the product — threshold =
787
- // half the model window (KISO_CONTEXT_WINDOW override included;
788
- // 200k window → 100k tokens). Long sessions compact old read/list/
789
- // search/shell outputs instead of silently growing past the window.
790
- // CTX-1: the startup value; `/model` recomputes it through the same
791
- // function, so the two can never drift apart.
792
- microcompact: { thresholdTokens: microcompactThresholdFor() },
793
- // E6: the run-start context policy — OFF unless env-armed (beats the microcompact default when both fire).
794
- ...(contextPolicy !== undefined ? { contextPolicy } : {}),
853
+ // ADR-0055 Amendment 1 (A1b): compaction is ON by default and runs
854
+ // INSIDE a run, by tiers drawn from the model's window (CTX-1: the
855
+ // binding step moves the window with /model and /resume). The
856
+ // standing microcompact at half the window is gone (A4). Phase rule
857
+ // 1 reads the user's configured checks first, then the runner table.
858
+ contextPolicy: {
859
+ ...(contextPolicy ?? {}),
860
+ tiers: { windowTokens: contextWindowTokens(), isCheck: (command) => runsACheck(command, Object.values(merged.checks ?? {})) },
861
+ },
795
862
  // R3e (owner ruling, 2026-08-28): NO turn limit on an interactive
796
863
  // session. This was `maxTurns: 20`, hardcoded on 2026-08-03 with no
797
864
  // stated reason and no way to change it — and it was the thing that
@@ -823,9 +890,19 @@ async function makeAgent(sessionId, input, modelFlag) {
823
890
  // LT-1: KISO_STREAM_IDLE_MS (the test rigs' knob) beats the profile —
824
891
  // the last spread wins, which is why it sits after the profile's.
825
892
  ...(idleFromEnv !== undefined ? { streamIdleMs: idleFromEnv } : {}),
893
+ ...(retriesFromEnv !== undefined ? { maxRetries: retriesFromEnv } : {}),
894
+ // ADR-0005 Amendment 2: the kernel announces each retry before its
895
+ // wait; the running row shows it. Composed with every extension's
896
+ // hooks by the runtime — an extension observing retries too is heard.
897
+ hooks: {
898
+ onRetry: async (info) => {
899
+ setRetryShown({ attempt: info.attempt, maxRetries: info.maxRetries, code: info.code, until: Date.now() + info.delayMs });
900
+ },
901
+ },
826
902
  };
827
903
  return createAgent(definition);
828
904
  }
905
+ export { workspaceRoot } from "./state.js";
829
906
  /** TUI2-R2 ② — the picker's affordance row: the keys, said where the
830
907
  * keys are useful. */
831
908
  const PICKER_HINT = "↑↓ pick · ⏎ resumes · type filters · esc";
@@ -834,11 +911,139 @@ const PICKER_HINT = "↑↓ pick · ⏎ resumes · type filters · esc";
834
911
  * runtime's own accessors (see session-cards.ts); this is only the
835
912
  * plumbing that hands it the store's read side.
836
913
  */
837
- async function sessionCards(agent) {
914
+ async function sessionCards(_agent, announce = (l) => bodyLog(l)) {
838
915
  const store = sessionStoreRef;
839
916
  if (store === null)
840
917
  return []; // unreachable: makeAgent builds the store first
841
- return collectSessionCards(agent, (id) => store.load(id));
918
+ const folders = listingFolders();
919
+ // 0.40.0 dogfood (item 2, lead's ruling A): the ONE-TIME migration — the
920
+ // first list after the upgrade reads each legacy log exactly once and
921
+ // writes its summary into the sidecar; announced with its count. Per
922
+ // folder: each carries its own marker.
923
+ const pending = folders.filter((f) => summaryMigrationPending(f.dir));
924
+ const count = pending.reduce((n, f) => n + listSessionSidecars(f.dir).filter((l) => l.summary === null).length, 0);
925
+ if (count > 0)
926
+ announce(`recording summaries for ${count} older session${count === 1 ? "" : "s"} — once`);
927
+ for (const f of pending) {
928
+ const reader = f.dir === activeStoreDir ? store : new SessionStore(f.dir);
929
+ migrateSummaries(f.dir, (id) => reader.load(id));
930
+ }
931
+ // …and from then on, the SIDECARS only: no log is opened to draw a row.
932
+ // A project folder's sessions are that project's; one without a recorded
933
+ // workspace was placed by inference and says so.
934
+ return cardsFromListings(folders.flatMap((f) => listSessionSidecars(f.dir).map((l) => f.kind === "project" ? { ...l, workspace: f.workspace ?? l.workspace, inferred: l.workspace === null } : f.kind === "unknown" ? { ...l, workspace: null } : l)));
935
+ }
936
+ /**
937
+ * 0.40.0 — the folders a listing reads: every project's under the
938
+ * per-project layout (the picker opens on this one, `tab` shows all), and
939
+ * the one folder otherwise. This process's folder is always first, even
940
+ * before its first session exists.
941
+ */
942
+ function listingFolders() {
943
+ const here = ownSessionsDir();
944
+ const home = kisoHome();
945
+ if (!projectLayoutActive(home))
946
+ return [{ dir: here, workspace: null, kind: "legacy" }];
947
+ const all = sessionFolders(home);
948
+ const mine = all.find((f) => f.dir === here) ?? { dir: here, workspace: projectRoot(), kind: "project" };
949
+ return [mine, ...all.filter((f) => f.dir !== here)];
950
+ }
951
+ /**
952
+ * 0.40.0 — before any session opens: the one-time move of the legacy
953
+ * folder into project folders (announced once, with the undo), then this
954
+ * project's folder claimed. Nothing happens under a pinned folder or after
955
+ * a reversed migration.
956
+ */
957
+ function prepareSessionFolders() {
958
+ const home = kisoHome();
959
+ if (!projectLayoutActive(home))
960
+ return;
961
+ if (pendingLegacyIds(home).length > 0) {
962
+ const result = runMigration(home, planMigration(home));
963
+ if (result !== null && result.moved > 0) {
964
+ const line = migrationNotice(result);
965
+ if (process.stdout.isTTY)
966
+ bodyLog(line);
967
+ else
968
+ process.stderr.write(`${line}\n`);
969
+ }
970
+ }
971
+ claimProjectDir(ownSessionsDir(), projectRoot());
972
+ }
973
+ /**
974
+ * 0.40.0 (the owner's dogfood: a uooki session resumed from flowpix2) —
975
+ * whether a session id may open in this project. Its own folder: yes. A
976
+ * session another project RECORDED: refused, with where to go. One whose
977
+ * project is unknown, or was only inferred, is never refused and never
978
+ * moved (the lead's ruling: a resume changes no placement) — it opens where
979
+ * it is, and the line says the tools work here. A pure read. `null` = not
980
+ * found anywhere (the caller's own rule applies: a new session, or "no
981
+ * such session").
982
+ */
983
+ export function routeSession(id) {
984
+ const own = ownSessionsDir();
985
+ if (hasSession(own, id))
986
+ return { kind: "here" };
987
+ const home = kisoHome();
988
+ if (!projectLayoutActive(home))
989
+ return null;
990
+ const found = locateSession(home, id, own);
991
+ if (found === null)
992
+ return null;
993
+ const profile = readProfile(found.dir, id);
994
+ const recorded = profile.kind === "ok" && profile.profile.workspace !== null ? canonicalPath(profile.profile.workspace) : null;
995
+ const cwd = projectRoot();
996
+ const owner = found.kind === "project" ? (found.workspace ?? recorded) : recorded;
997
+ if (recorded !== null && owner !== null && owner !== cwd) {
998
+ return { kind: "refused", line: `this session belongs to ${tildeOf(owner)} — cd there to resume it` };
999
+ }
1000
+ // recorded here, and still in the legacy folder (open elsewhere when the
1001
+ // move ran): nothing to warn about
1002
+ if (recorded !== null)
1003
+ return { kind: "elsewhere", dir: found.dir, line: null };
1004
+ const inferred = found.kind === "project" && owner !== null ? `workspace inferred as ${tildeOf(owner)}, never recorded` : "workspace unknown";
1005
+ return { kind: "elsewhere", dir: found.dir, line: `${inferred} — tools work in ${tildeOf(workspaceRoot())}` };
1006
+ }
1007
+ /** The folder a session opens in: the one the store is on when it holds
1008
+ * it, else this project's, else where the route found it. A new id opens
1009
+ * in this project's folder. */
1010
+ function sessionFolderOf(id) {
1011
+ if (hasSession(activeStoreDir, id))
1012
+ return activeStoreDir;
1013
+ const route = routeSession(id);
1014
+ return route?.kind === "elsewhere" ? route.dir : ownSessionsDir();
1015
+ }
1016
+ /** The folder makeAgent built the store on. */
1017
+ let activeStoreDir = "";
1018
+ /** The workspace a listing is scoped to: a project folder's identity under
1019
+ * the per-project layout, the recorded realpath in one pinned folder. */
1020
+ function scopeRoot() {
1021
+ return projectLayoutActive(kisoHome()) ? projectRoot() : workspaceRoot();
1022
+ }
1023
+ function tildeOf(path) {
1024
+ const home = homedir();
1025
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
1026
+ }
1027
+ /** The explicit-id doors (`chat <id>`, `resume <id>`, `-p … <id>`), BEFORE
1028
+ * makeAgent builds the store: a refusal is the entry's error (the line on
1029
+ * stderr, exit 2); a session in another folder opens there. Returns the
1030
+ * line to say once the session is up. */
1031
+ function enterRouted(id) {
1032
+ const route = routeSession(id);
1033
+ if (route === null || route.kind === "here")
1034
+ return null;
1035
+ if (route.kind === "refused")
1036
+ throw new CliUsageError(route.line);
1037
+ setOpenSessionFolder(route.dir);
1038
+ return route.line;
1039
+ }
1040
+ function sayRouteLine(line) {
1041
+ if (line === null)
1042
+ return;
1043
+ if (process.stdout.isTTY)
1044
+ bodyLog(line);
1045
+ else
1046
+ process.stderr.write(`${line}\n`);
842
1047
  }
843
1048
  /**
844
1049
  * TUI2-R2 ② — the resume picker: the band, the keys, the id.
@@ -866,7 +1071,8 @@ async function pickSession(agent, input) {
866
1071
  }
867
1072
  dock.setStatus("", PICKER_HINT);
868
1073
  const picked = await new Promise((resolve) => {
869
- input.pick(() => cards, resolve);
1074
+ // 0.40.0: scoped to the running workspace; tab flips to all
1075
+ input.pick(() => cards, resolve, scopeRoot());
870
1076
  });
871
1077
  dock.setStatus("", null);
872
1078
  return picked;
@@ -892,6 +1098,21 @@ async function pickSession(agent, input) {
892
1098
  * formatter — never a boot-time copy, which would drift from the real
893
1099
  * row the moment either changed.
894
1100
  */
1101
+ /**
1102
+ * ADR-0055 Amendment 1 (the owner, 2026-09-18): `autoCompact` — the
1103
+ * between-turn ratio, from config or KISO_AUTO_COMPACT — is RETIRED. The
1104
+ * in-run tiers compact inside a run and before its first request, which is
1105
+ * everything it did and more. 0.40.0 accepts it, ignores it and says so
1106
+ * once; 0.41.0 removes it.
1107
+ */
1108
+ let autoCompactNoticed = false;
1109
+ function retiredAutoCompact(merged) {
1110
+ if (!autoCompactNoticed && (process.env.KISO_AUTO_COMPACT !== undefined || resolveAutoCompact(merged) !== undefined)) {
1111
+ autoCompactNoticed = true;
1112
+ console.error("[autoCompact] retired in 0.40.0 — compaction now runs inside a run, by tiers; this setting is ignored and goes in 0.41.0");
1113
+ }
1114
+ return undefined;
1115
+ }
895
1116
  /**
896
1117
  * CTX-1 (Astra F34-1): EVERY entry point that opens a session must bind the
897
1118
  * policy that follows the session's OWN model.
@@ -913,11 +1134,20 @@ async function pickSession(agent, input) {
913
1134
  * threshold got stuck in the first place.
914
1135
  */
915
1136
  function bindRestoredSession(session) {
1137
+ // 0.40.0: an acknowledged drift says what it did, once — including the
1138
+ // owner-ruled reasoning reset, which was silent until now.
1139
+ const ack = session.driftAcknowledgement ?? null;
1140
+ if (ack !== null) {
1141
+ const was = ack.reasoningReset;
1142
+ const prior = [was.effort !== "default" ? `effort ${was.effort}` : "", was.thinking !== "default" ? `thinking ${was.thinking}` : ""].filter((s) => s !== "").join(", ");
1143
+ bodyLog(`drift acknowledged — now on ${session.model}; reasoning reset to defaults${prior === "" ? "" : ` (was ${prior})`}`);
1144
+ }
916
1145
  setAgentModel(session.model, session.baseUrl);
917
1146
  session.setMicrocompactThreshold(microcompactThresholdFor({
918
1147
  model: session.model,
919
1148
  ...(session.baseUrl !== undefined ? { baseUrl: session.baseUrl } : {}),
920
1149
  }));
1150
+ session.setContextWindow(contextWindowTokens({ model: session.model, ...(session.baseUrl !== undefined ? { baseUrl: session.baseUrl } : {}) }));
921
1151
  }
922
1152
  function paintBootStatus(session) {
923
1153
  if (!dock.active)
@@ -971,7 +1201,7 @@ function skillCount() {
971
1201
  * is the durable log on disk. The record never moves: a reload is
972
1202
  * invisible in it, because nothing the model said or did changes.
973
1203
  */
974
- async function reloadAgent(old, id, input) {
1204
+ async function reloadAgent(old, id, input, announce = true) {
975
1205
  // Snapshot BEFORE makeAgent: it overwrites every one of these, so the
976
1206
  // old set would be unreachable by the time we needed to dispose it.
977
1207
  const oldLoaded = loadedExtensions;
@@ -1051,7 +1281,8 @@ async function reloadAgent(old, id, input) {
1051
1281
  // best-effort — the temp dir would be reaped by the OS
1052
1282
  }
1053
1283
  }
1054
- bodyLog(`[reload] ${loadedExtensions.length} extensions, ${skillCount()} skills — the conversation is unchanged`);
1284
+ if (announce)
1285
+ bodyLog(`[reload] ${loadedExtensions.length} extensions, ${skillCount()} skills — the conversation is unchanged`);
1055
1286
  return next;
1056
1287
  }
1057
1288
  async function chatLoop(first, firstId, input, autoCompact) {
@@ -1064,6 +1295,29 @@ async function chatLoop(first, firstId, input, autoCompact) {
1064
1295
  // as a new session, which is the one thing it is not.
1065
1296
  let rebuilt = false;
1066
1297
  for (;;) {
1298
+ // 0.40.0 (the lead's ruling): a session opens in the folder that holds
1299
+ // it, never moved — a switch that crosses folders rebuilds the agent
1300
+ // on the other folder's store, and one back rebuilds it on this
1301
+ // project's. A failed rebuild switches nothing.
1302
+ const folder = sessionFolderOf(id);
1303
+ if (folder !== activeStoreDir) {
1304
+ const was = { dir: activeStoreDir, store: sessionStoreRef };
1305
+ setOpenSessionFolder(folder === ownSessionsDir() ? null : folder);
1306
+ const next = await reloadAgent(agent, id, input, false);
1307
+ if (next === agent) {
1308
+ // makeAgent may have built the new store before it failed
1309
+ activeStoreDir = was.dir;
1310
+ if (was.store !== null)
1311
+ setSessionStore(was.store);
1312
+ setOpenSessionFolder(was.dir === ownSessionsDir() ? null : was.dir);
1313
+ if (prev === null)
1314
+ return;
1315
+ id = prev;
1316
+ rebuilt = true;
1317
+ continue;
1318
+ }
1319
+ agent = next;
1320
+ }
1067
1321
  const session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
1068
1322
  if (rebuilt) {
1069
1323
  rebuilt = false;
@@ -1075,7 +1329,7 @@ async function chatLoop(first, firstId, input, autoCompact) {
1075
1329
  // prompt inside a conversation with thousands of events — the
1076
1330
  // durable log was right there and none of it was shown. Empty for
1077
1331
  // a fresh session, so `kiso chat` is byte-identical.
1078
- bodyLog(resumeTail(session.log.all, process.stdout.columns ?? 80).join("\n")); // DC-51: one call, one cell
1332
+ showResumeTail(session.log.all);
1079
1333
  // R2 (owner, 2026-08-27): the resume list is NOT on the opening
1080
1334
  // screen. `/resume` is where you go looking for a session; the
1081
1335
  // opening's job is to say what THIS one is.
@@ -1107,7 +1361,7 @@ async function chatLoop(first, firstId, input, autoCompact) {
1107
1361
  }
1108
1362
  else {
1109
1363
  bodyLog(`session ${id} (switched — previous: ${prev}, /resume ${prev} returns)\n`);
1110
- bodyLog(resumeTail(session.log.all, process.stdout.columns ?? 80).join("\n")); // DC-51: one call, one cell
1364
+ showResumeTail(session.log.all);
1111
1365
  if (currentFaux)
1112
1366
  session.setAdapter(createFauxProvider(readFauxScript().slice(fauxSkip(id))));
1113
1367
  }
@@ -1132,7 +1386,10 @@ async function chatLoop(first, firstId, input, autoCompact) {
1132
1386
  // be left naming the session the user just left.
1133
1387
  paintWindowTitle(session.log.all);
1134
1388
  const nav = {
1135
- sessions: () => agent.sessions().map((m) => m.id),
1389
+ // 0.40.0 dogfood: the ids only — agent.sessions() read every log whole
1390
+ // (seconds on the owner's 118 sessions) before /resume could open
1391
+ sessions: () => agent.sessionIds(),
1392
+ route: (sessionId) => routeSession(sessionId),
1136
1393
  ...(process.stdin.isTTY ? { pick: () => pickSession(agent, input) } : {}),
1137
1394
  };
1138
1395
  const end = await chat(session, currentFaux, input, autoCompact, nav);
@@ -1197,6 +1454,20 @@ async function readSecret(prompt) {
1197
1454
  process.stdin.on("data", onData);
1198
1455
  });
1199
1456
  }
1457
+ /**
1458
+ * What a resumed session shows (REL-0152-D5, 4c). On the compositor: the
1459
+ * durable events replayed into cells — the last two turns in full, the
1460
+ * earlier ones in one fold row the ctrl+r viewer reads. Through a pipe
1461
+ * there is no viewer and no cell renderer, so the plain text tail stays
1462
+ * (DC-51: one call, one cell).
1463
+ */
1464
+ function showResumeTail(events) {
1465
+ const W = process.stdout.columns ?? 80;
1466
+ if (dock.active)
1467
+ replayInto(body, events, W);
1468
+ else
1469
+ bodyLog(resumeTail(events, W).join("\n"));
1470
+ }
1200
1471
  async function main() {
1201
1472
  // E group (the graceful-exit gate ③, R-G 0.1.48): a terminal closing
1202
1473
  // turns the in-flight stdout/stderr writes into EIO, and node's
@@ -1273,7 +1544,7 @@ async function main() {
1273
1544
  if (modeFlag !== -1) {
1274
1545
  const m = MODES.find((x) => x === args[modeFlag + 1]);
1275
1546
  if (m === undefined) {
1276
- console.error(`unknown mode: ${args[modeFlag + 1]} (tiers: ${MODES.join(", ")})`);
1547
+ console.error(`unknown mode: ${args[modeFlag + 1]} (tiers: ${OFFERED_MODES.join(", ")})`);
1277
1548
  process.exit(2);
1278
1549
  }
1279
1550
  setMode(m);
@@ -1292,6 +1563,11 @@ async function main() {
1292
1563
  const path = args[i + 1];
1293
1564
  if (path === undefined)
1294
1565
  throw new CliUsageError("--task-file needs a path");
1566
+ // the file becomes the turn a model reads — never the credential
1567
+ // store. The list here is the store's own path: the user config's
1568
+ // protectedPaths is read later, with the agent
1569
+ if (isProtectedPath(path, protectedIdentity(protectedFiles())))
1570
+ throw new CliUsageError(`--task-file: ${path} — ${PROTECTED_REFUSAL}`);
1295
1571
  try {
1296
1572
  taskFile = readFileSync(path, "utf8");
1297
1573
  }
@@ -1301,6 +1577,30 @@ async function main() {
1301
1577
  args.splice(i, 2);
1302
1578
  }
1303
1579
  }
1580
+ // 0.40.0: `kiso sessions --all | --current` — the one command that takes
1581
+ // them. Parsed only there, so neither ever becomes a session id. Absent
1582
+ // = each form's default (TTY: current; pipe: all, today's bytes).
1583
+ let listScope;
1584
+ let reverseManifest;
1585
+ if (args[0] === "sessions") {
1586
+ // 0.40.0: the undo of the per-project move, named by its manifest
1587
+ const r = args.indexOf("--reverse-migration");
1588
+ if (r !== -1) {
1589
+ reverseManifest = args[r + 1];
1590
+ if (reverseManifest === undefined || reverseManifest.startsWith("--"))
1591
+ throw new CliUsageError("kiso sessions --reverse-migration <manifest> — the path the move announced");
1592
+ args.splice(r, 2);
1593
+ }
1594
+ for (const flag of ["--all", "--current"]) {
1595
+ const i = args.indexOf(flag);
1596
+ if (i === -1)
1597
+ continue;
1598
+ args.splice(i, 1);
1599
+ if (listScope !== undefined && listScope !== flag.slice(2))
1600
+ throw new CliUsageError("kiso sessions: --all and --current contradict each other");
1601
+ listScope = flag.slice(2);
1602
+ }
1603
+ }
1304
1604
  const [command, arg] = args;
1305
1605
  // round 8: faux mode is the keyless demo script — an exhausted script must
1306
1606
  // exit non-zero, never masquerade as a successful provider run. The
@@ -1413,8 +1713,10 @@ async function main() {
1413
1713
  // the -p flow: recovery-first one-shot, the resume() machinery
1414
1714
  // verbatim (a fresh id makes the recovery a no-op)
1415
1715
  const id = command ?? newSessionId(sessionsDir());
1716
+ const routeLine = command !== undefined ? enterRouted(id) : null;
1416
1717
  agent = await makeAgent(id, input, modelFlag);
1417
1718
  applyConfigMode();
1719
+ sayRouteLine(routeLine);
1418
1720
  const session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
1419
1721
  bindRestoredSession(session); // CTX-1 (F34-1): the -p door, too
1420
1722
  faux = currentFaux;
@@ -1431,10 +1733,12 @@ async function main() {
1431
1733
  dock.enter();
1432
1734
  // E area: a resumed session continues the script at its durable
1433
1735
  // position — never restarts it (fauxSkip).
1736
+ const routeLine = arg !== undefined ? enterRouted(id) : null;
1434
1737
  agent = await makeAgent(id, input, modelFlag);
1435
1738
  applyConfigMode();
1739
+ sayRouteLine(routeLine);
1436
1740
  faux = currentFaux;
1437
- await chatLoop(agent, id, input, resolveAutoCompact(mergedConfig));
1741
+ await chatLoop(agent, id, input, retiredAutoCompact(mergedConfig));
1438
1742
  break;
1439
1743
  }
1440
1744
  case "resume": {
@@ -1451,6 +1755,7 @@ async function main() {
1451
1755
  // (argv = [node, script, resume, id, prompt?]).
1452
1756
  const prompt = process.argv[4];
1453
1757
  dock.enter();
1758
+ const routeLine = arg !== undefined ? enterRouted(arg) : null;
1454
1759
  agent = await makeAgent(arg, input, modelFlag);
1455
1760
  applyConfigMode();
1456
1761
  let id = arg;
@@ -1461,15 +1766,25 @@ async function main() {
1461
1766
  // an error, never a session started behind their back.
1462
1767
  if (picked === null)
1463
1768
  break;
1769
+ // 0.40.0: `tab` lists every project, and another project's
1770
+ // recorded session is refused here, with where to go
1771
+ const route = routeSession(picked);
1772
+ if (route?.kind === "refused") {
1773
+ bodyLog(route.line);
1774
+ break;
1775
+ }
1776
+ if (route?.kind === "elsewhere" && route.line !== null)
1777
+ bodyLog(route.line);
1464
1778
  // the mini-spec (a DECLARED SUPERSESSION of the one-shot
1465
1779
  // picker flow): a PICKED session enters the full REPL —
1466
1780
  // "resume and keep working" no longer requires knowing to
1467
1781
  // type `kiso chat <id>`. The explicit-id one-shot form
1468
1782
  // (`kiso resume <id> ["prompt"]`) keeps its exact bytes.
1469
1783
  faux = currentFaux;
1470
- await chatLoop(agent, picked, input, resolveAutoCompact(mergedConfig));
1784
+ await chatLoop(agent, picked, input, retiredAutoCompact(mergedConfig));
1471
1785
  break;
1472
1786
  }
1787
+ sayRouteLine(routeLine);
1473
1788
  const session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
1474
1789
  bindRestoredSession(session); // CTX-1 (F34-1): the explicit-id door
1475
1790
  faux = currentFaux;
@@ -1482,7 +1797,7 @@ async function main() {
1482
1797
  session.setAdapter(createFauxProvider(readFauxScript().slice(fauxSkip(id))));
1483
1798
  // REL-0152-D5 — the same tail on the explicit-id form. NOT on
1484
1799
  // the -p path above: that one's stdout is a machine's input.
1485
- bodyLog(resumeTail(session.log.all, process.stdout.columns ?? 80).join("\n")); // DC-51: one call, one cell
1800
+ showResumeTail(session.log.all);
1486
1801
  await resume(session, prompt, faux, input);
1487
1802
  break;
1488
1803
  }
@@ -1495,22 +1810,60 @@ async function main() {
1495
1810
  // "reading 'question'" on the dock-less branch). The input
1496
1811
  // exists so the gate's ask can be answered; the listing
1497
1812
  // itself never touches it.
1813
+ if (reverseManifest !== undefined) {
1814
+ // before makeAgent: its folder preparation would otherwise run
1815
+ // the very move this undoes
1816
+ const { restored, left } = reverseMigration(kisoHome(), reverseManifest);
1817
+ console.log(`moved ${restored} session${restored === 1 ? "" : "s"} back to ${join(kisoHome(), "sessions")}; one folder per project is off until ${join(kisoHome(), "projects", ".migration-reversed")} is removed${left > 0 ? ` — ${left} created since the move stay in their project folders` : ""}`);
1818
+ break;
1819
+ }
1498
1820
  agent = await makeAgent(undefined, input, modelFlag);
1499
1821
  // TUI2-R2 ③ — the same projection the picker renders, printed.
1500
1822
  // The PIPE keeps today's bytes exactly: `kiso sessions` is
1501
1823
  // something scripts read, and a badge column is a TTY-render
1502
1824
  // concern, not a change to a machine interface.
1825
+ // 0.40.0 (lead's ruling): the TTY defaults to THIS workspace and
1826
+ // says so in a header line with both counts; the PIPE defaults to
1827
+ // every session with today's bytes — scripts must not change
1828
+ // under them. An explicit --all or --current applies to both.
1829
+ const here = scopeRoot();
1503
1830
  if (process.stdout.isTTY) {
1504
- const cards = await sessionCards(agent);
1831
+ const every = await sessionCards(agent, (l) => console.log(l));
1832
+ const all = (listScope ?? "current") === "all";
1833
+ const inHere = every.filter((c) => c.workspace === here);
1834
+ const cards = all ? every : inHere;
1505
1835
  const W = process.stdout.columns ?? 80;
1506
1836
  const col = idColumn(cards);
1507
1837
  const now = Date.now();
1838
+ console.log(sessionListHeader(inHere.length, every.length, all, W));
1839
+ // 0.40.1: the sessions with no workspace — one counted line, never
1840
+ // listed, unless --all
1841
+ if (!all) {
1842
+ const unknownLine = sessionListUnknownLine(every.filter((c) => c.workspace === null).length, W);
1843
+ if (unknownLine !== "")
1844
+ console.log(unknownLine);
1845
+ }
1508
1846
  for (const card of cards)
1509
- console.log(sessionListRow(card, W, now, col));
1847
+ console.log(sessionListRow(card, W, now, col, all ? here : null));
1510
1848
  console.log(sessionListFooter(cards.length, W));
1511
1849
  }
1512
1850
  else {
1513
- for (const meta of agent.sessions()) {
1851
+ // 0.40.0: every folder's sessions, merged in id order — today's
1852
+ // bytes; --current is this project's folder (in a single pinned
1853
+ // folder, the sessions that recorded this workspace)
1854
+ const folders = listingFolders();
1855
+ const single = folders.length === 1 && folders[0].kind === "legacy";
1856
+ const workspaceOf = (id) => {
1857
+ const r = readProfile(sessionsDir(), id);
1858
+ return r.kind === "ok" ? r.profile.workspace : null;
1859
+ };
1860
+ const metas = folders
1861
+ .filter((f) => listScope !== "current" || single || f.dir === sessionsDir())
1862
+ .flatMap((f) => (f.dir === sessionsDir() ? agent.sessions() : new SessionStore(f.dir).list()))
1863
+ .sort((a, b) => a.id.localeCompare(b.id));
1864
+ for (const meta of metas) {
1865
+ if (listScope === "current" && single && workspaceOf(meta.id) !== here)
1866
+ continue;
1514
1867
  console.log(renderSessionLine(meta));
1515
1868
  }
1516
1869
  }
@@ -1615,7 +1968,7 @@ async function main() {
1615
1968
  " kiso chat [sessionId] same as above\n" +
1616
1969
  " kiso resume pick a session to continue (TTY picker)\n" +
1617
1970
  " kiso resume <id> [prompt] continue a session (one-shot)\n" +
1618
- " kiso sessions list durable sessions\n" +
1971
+ " kiso sessions [--all|--current] list durable sessions (a terminal shows this workspace's by default)\n" +
1619
1972
  " kiso login <provider> anthropic|openai|deepseek|zai: store an API key (hidden prompt, or stdin when piped);\n" +
1620
1973
  " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
1621
1974
  " kiso logout <provider> remove the stored credential\n" +
@@ -1624,7 +1977,7 @@ async function main() {
1624
1977
  " kiso help this help\n\n" +
1625
1978
  "flags (any position):\n" +
1626
1979
  " --model <profile|provider/model> pick the model (also /model in-session)\n" +
1627
- " --mode <tier> approval tier: manual|default|accept-edits|plan|bypass\n" +
1980
+ " --mode <tier> approval tier: default|accept-edits|plan|dontAsk|bypass\n" +
1628
1981
  " --version print the version\n\n" +
1629
1982
  "configuration:\n" +
1630
1983
  " no key keyless faux demo (a scripted four-round session)\n" +
@@ -1654,7 +2007,7 @@ async function main() {
1654
2007
  // lives inside the resolver. It used to pass the env-only
1655
2008
  // reader, so a user's config.json autoCompact worked on
1656
2009
  // `kiso chat` and silently vanished on the default entry.
1657
- await chatLoop(agent, id, input, resolveAutoCompact(mergedConfig));
2010
+ await chatLoop(agent, id, input, retiredAutoCompact(mergedConfig));
1658
2011
  break;
1659
2012
  }
1660
2013
  }