@vincemakes/kiso-code 0.39.2 → 0.40.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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/attachments.d.ts +1 -1
  3. package/dist/attachments.js +11 -4
  4. package/dist/builtin.d.ts +12 -0
  5. package/dist/builtin.js +22 -1
  6. package/dist/chat.d.ts +31 -3
  7. package/dist/chat.js +169 -30
  8. package/dist/config.d.ts +11 -1
  9. package/dist/config.js +34 -3
  10. package/dist/dispatch.d.ts +12 -2
  11. package/dist/dispatch.js +165 -24
  12. package/dist/floor.d.ts +80 -0
  13. package/dist/floor.js +652 -0
  14. package/dist/index.d.ts +21 -2
  15. package/dist/index.js +463 -46
  16. package/dist/mode.d.ts +12 -4
  17. package/dist/mode.js +32 -10
  18. package/dist/projects.d.ts +106 -0
  19. package/dist/projects.js +212 -0
  20. package/dist/protected-shell.d.ts +88 -0
  21. package/dist/protected-shell.js +406 -0
  22. package/dist/protected-writes.d.ts +29 -0
  23. package/dist/protected-writes.js +54 -0
  24. package/dist/provider-label.d.ts +32 -0
  25. package/dist/provider-label.js +53 -0
  26. package/dist/readonly-shell.d.ts +58 -0
  27. package/dist/readonly-shell.js +590 -0
  28. package/dist/replay.d.ts +53 -0
  29. package/dist/replay.js +158 -0
  30. package/dist/resume-tail.js +4 -1
  31. package/dist/resume.js +3 -3
  32. package/dist/retries.d.ts +9 -0
  33. package/dist/retries.js +16 -0
  34. package/dist/session-cards.d.ts +33 -4
  35. package/dist/session-cards.js +44 -3
  36. package/dist/session-migration.d.ts +104 -0
  37. package/dist/session-migration.js +319 -0
  38. package/dist/shell-words.d.ts +134 -0
  39. package/dist/shell-words.js +628 -0
  40. package/dist/skill-invoke.d.ts +58 -0
  41. package/dist/skill-invoke.js +139 -0
  42. package/dist/state.d.ts +80 -1
  43. package/dist/state.js +113 -2
  44. package/dist/trust-ui.js +29 -5
  45. package/package.json +15 -15
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, setCurrentProfileName, setExtensionLists, setUserProtectedPaths, protectedFiles, setMergedConfig, setModelChoice, setSessionStore, setRetryShown, setNeverInherited, secretEnvNamesOf, userExtensions, VERSION, lastBinding, acceptDrift, setAcceptDrift, setFloorOn, floorOn, loadedSkillsCatalog, queuedSwitchLines } 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.
@@ -455,6 +468,7 @@ function makeLineInput() {
455
468
  // the panel's option rows, so the compositor is what the editor asks
456
469
  // where they are. Neither side computes the other's geometry.
457
470
  editor.bindPanelRows(() => dock.panelOptionRows());
471
+ editor.bindPickWindow(() => dock.visiblePickWindow());
458
472
  dock.bindMenu(() => editor.menuState()); // v3 §04: the slash-command menu
459
473
  editor.bindAtItems(atFiles); // KC3 §5: the file source — listed per OPEN
460
474
  dock.bindAt(() => editor.atState()); // KC3 §4: the picker's band
@@ -484,7 +498,11 @@ function bannerExtensionText() {
484
498
  // terminal layer (extensionsBannerText — a pure function of the three
485
499
  // name lists, including the "(connecting…)" in-flight label). Which
486
500
  // lists exist is the CLI's fact and stays here.
487
- return extensionsBannerText(builtInExtensions, userExtensions, projectExtensions);
501
+ // 0.40.0: in dontAsk the ask extension is loaded but offers no tool
502
+ // (builtin.ts offInDontAsk) — the banner says so beside the tier that
503
+ // turns it off, rather than listing it as if it could ask.
504
+ const builtIn = builtInExtensions.map((e) => (e.name === "ask" && getMode() === "dontAsk" ? { name: e.name, note: "off in dontAsk" } : e));
505
+ return extensionsBannerText(builtIn, userExtensions, projectExtensions);
488
506
  }
489
507
  /** E1: the startup banner line(s) — TTY: logo + merged extensions + the
490
508
  * W5 resume list as a LIVE banner cell (W1: the tier re-derives on
@@ -619,9 +637,18 @@ const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
619
637
  * return it as an injected section, or "" when none exists. Truncated at
620
638
  * 8KB with an explicit note. Pure — read once per session, so the prompt
621
639
  * is byte-stable for the session's lifetime.
640
+ *
641
+ * kiso never serves its own credential store to a model, and this read is
642
+ * the one that needs no model at all: a cloned repository whose AGENTS.md
643
+ * is a symlink to the store would put it in the SYSTEM PROMPT of every
644
+ * request, with no tool call and no trust prompt. A file that resolves to
645
+ * a protected one is skipped as though absent.
622
646
  */
623
- export function readProjectInstructions(cwd) {
647
+ export function readProjectInstructions(cwd, protectedFiles = []) {
648
+ const id = protectedIdentity(protectedFiles);
624
649
  for (const name of INSTRUCTION_FILES) {
650
+ if (isProtectedPath(join(cwd, name), id))
651
+ continue;
625
652
  let text;
626
653
  try {
627
654
  text = readFileSync(join(cwd, name), "utf8");
@@ -635,8 +662,8 @@ export function readProjectInstructions(cwd) {
635
662
  }
636
663
  /** A area: the session's system prompt — the constant plus any project
637
664
  * instructions found in the workspace. Deterministic per cwd. */
638
- export function composeSystemPrompt(cwd) {
639
- const injected = readProjectInstructions(cwd);
665
+ export function composeSystemPrompt(cwd, protectedFiles = []) {
666
+ const injected = readProjectInstructions(cwd, protectedFiles);
640
667
  return injected === "" ? SYSTEM_PROMPT : `${SYSTEM_PROMPT}\n${injected}`;
641
668
  }
642
669
  /**
@@ -700,7 +727,12 @@ async function makeAgent(sessionId, input, modelFlag) {
700
727
  // the trust record is the first home write, the scaffold the second.
701
728
  if (isFirstRun())
702
729
  scaffoldFirstRun();
703
- const store = new SessionStore(sessionsDir());
730
+ // 0.40.0 — one folder per project: the one-time move of the legacy
731
+ // folder, then this project's folder claimed (made, its workspace
732
+ // recorded). Both write, so both sit behind the verdict, with the store.
733
+ prepareSessionFolders();
734
+ activeStoreDir = sessionsDir();
735
+ const store = new SessionStore(activeStoreDir);
704
736
  // TUI2-R2 ②/③: the navigation surfaces read through THIS store — one
705
737
  // store per process, and the picker/listing never write through it.
706
738
  setSessionStore(store);
@@ -736,7 +768,12 @@ async function makeAgent(sessionId, input, modelFlag) {
736
768
  // DT-1a: what a delegated task may NAME — the configured checks and the
737
769
  // model profiles — handed to the (in-process) subagent extension through
738
770
  // 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 ?? {}) });
771
+ // 0.40.0: and the folder this process keeps its sessions in — a child
772
+ // writes beside its parent, whatever directory it runs in. Through this
773
+ // channel and not an exported KISO_SESSIONS_DIR: a variable in
774
+ // process.env would reach every shell child, and a kiso started from a
775
+ // shell tool would then write into this project's folder.
776
+ process.env.KISO_DELEGATION_CONFIG_JSON = JSON.stringify({ checks: merged.checks ?? {}, profiles: Object.keys(merged.models ?? {}), sessionsDir: sessionsDir() });
740
777
  setConfigModels(merged.models ?? {});
741
778
  const resolved = resolveModel(modelFlag, merged);
742
779
  // AFTER the model resolves: the window a PROFILE states is about that
@@ -749,10 +786,12 @@ async function makeAgent(sessionId, input, modelFlag) {
749
786
  console.log("[faux mode — set ANTHROPIC_API_KEY or OPENAI_API_KEY, or configure models in ~/.kiso/config.json]\n");
750
787
  setCurrentFaux(true);
751
788
  setCurrentModelName("faux");
789
+ setCurrentProfileName(null);
752
790
  }
753
791
  else {
754
792
  setCurrentFaux(false);
755
793
  setCurrentModelName(resolved.name);
794
+ setCurrentProfileName(resolved.name);
756
795
  }
757
796
  setAgentModel(model, resolved?.profile.baseUrl); // v2b: the status bar shows it; OR-1: the endpoint rides along
758
797
  // W21: the extensions array is built ONCE per agent and shared with
@@ -762,14 +801,45 @@ async function makeAgent(sessionId, input, modelFlag) {
762
801
  // re-reads the config's extensions array per run).
763
802
  // LT-2: the loop breaker at the chain HEAD — it speaks first when it speaks,
764
803
  // so `decidedBy` names it; a deny there beats every tier, bypass included.
765
- const extensions = [breakerExtension(), ...modeExtensions(), ...loadedExtensions];
804
+ // 0.40.0: the read-only shell allow sits after the tiers — an allow from
805
+ // it outranks a tier's ask and names itself in decidedBy.
806
+ // 0.40.0: a saved allow never carries a write into .git/ or .kiso/, nor
807
+ // a destructive shell command.
808
+ const workspaceRoot = () => codingToolOptions().workspaceRoot;
809
+ const neverInherited = (call) => isProtectedWrite(call, workspaceRoot()) || isDestructiveCall(call);
810
+ setNeverInherited(neverInherited);
811
+ // 0.40.0: the catastrophe floor, at the chain's HEAD — a deny there
812
+ // names itself in decidedBy and outranks every tier, bypass included.
813
+ // Read per agent, so /reload picks up an edited user config.
814
+ const userConfig = loadUserConfig();
815
+ setFloorOn(userConfig?.floor !== "off");
816
+ // kiso never serves its own credential store to a model: the file tools
817
+ // refuse it through codingToolOptions, and this member denies a shell
818
+ // line naming it — at the HEAD with the floor, in every mode, and NOT
819
+ // switched off with it (`floor: "off"` lowers the catastrophe floor,
820
+ // never this).
821
+ setUserProtectedPaths(userConfig?.protectedPaths);
822
+ const extensions = [
823
+ protectedShellExtension({ files: protectedFiles, workspaceRoot, env: () => ({ home: homedir(), kisoHome: kisoHome() }) }),
824
+ floorExtension(() => floorOn, workspaceRoot),
825
+ breakerExtension(),
826
+ ...modeExtensions(workspaceRoot),
827
+ readOnlyShellExtension(codingToolOptions),
828
+ ...loadedExtensions.map((e) => guardSavedAllow(e, neverInherited)),
829
+ ];
766
830
  setCurrentAgentExtensions(extensions);
767
831
  // E6: the run-start context policy (captured once — exactOptionalPropertyTypes).
768
832
  const contextPolicy = contextPolicyFromEnv();
769
833
  const idleFromEnv = streamIdleFromEnv(); // read once: a narrowed const, not a call per spread
834
+ const retriesFromEnv = maxRetriesFromEnv();
770
835
  const definition = {
771
836
  model,
772
837
  store,
838
+ // 0.40.0: a NEW session records where it started; the picker scopes by
839
+ // it. The profile name is recorded only when the model came from a
840
+ // config profile — a direct provider/model or an env key names none.
841
+ workspace: workspaceRoot(),
842
+ ...(resolved !== null && merged.models?.[resolved.name] === resolved.profile ? { profileName: resolved.name } : {}),
773
843
  // Area 5: the coding tools are bound to the workspace — every path
774
844
  // they touch is canonicalized inside cwd, escapes are refused.
775
845
  tools: [...createCodingTools(codingToolOptions())], // DC-49 — the options live in state.ts, shared with the `!` command's runner
@@ -779,19 +849,19 @@ async function makeAgent(sessionId, input, modelFlag) {
779
849
  // still counts loadedExtensions only — the modes are in-process,
780
850
  // never a file extension.
781
851
  systemPrompt: (() => {
782
- const sp = composeSystemPrompt(process.cwd());
852
+ const sp = composeSystemPrompt(process.cwd(), protectedFiles());
783
853
  const extra = modeSystemPrompt();
784
854
  return extra === undefined ? sp : `${sp}\n\n${extra}`;
785
855
  })(),
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 } : {}),
856
+ // ADR-0055 Amendment 1 (A1b): compaction is ON by default and runs
857
+ // INSIDE a run, by tiers drawn from the model's window (CTX-1: the
858
+ // binding step moves the window with /model and /resume). The
859
+ // standing microcompact at half the window is gone (A4). Phase rule
860
+ // 1 reads the user's configured checks first, then the runner table.
861
+ contextPolicy: {
862
+ ...(contextPolicy ?? {}),
863
+ tiers: { windowTokens: contextWindowTokens(), isCheck: (command) => runsACheck(command, Object.values(merged.checks ?? {})) },
864
+ },
795
865
  // R3e (owner ruling, 2026-08-28): NO turn limit on an interactive
796
866
  // session. This was `maxTurns: 20`, hardcoded on 2026-08-03 with no
797
867
  // stated reason and no way to change it — and it was the thing that
@@ -823,9 +893,19 @@ async function makeAgent(sessionId, input, modelFlag) {
823
893
  // LT-1: KISO_STREAM_IDLE_MS (the test rigs' knob) beats the profile —
824
894
  // the last spread wins, which is why it sits after the profile's.
825
895
  ...(idleFromEnv !== undefined ? { streamIdleMs: idleFromEnv } : {}),
896
+ ...(retriesFromEnv !== undefined ? { maxRetries: retriesFromEnv } : {}),
897
+ // ADR-0005 Amendment 2: the kernel announces each retry before its
898
+ // wait; the running row shows it. Composed with every extension's
899
+ // hooks by the runtime — an extension observing retries too is heard.
900
+ hooks: {
901
+ onRetry: async (info) => {
902
+ setRetryShown({ attempt: info.attempt, maxRetries: info.maxRetries, code: info.code, until: Date.now() + info.delayMs });
903
+ },
904
+ },
826
905
  };
827
906
  return createAgent(definition);
828
907
  }
908
+ export { workspaceRoot } from "./state.js";
829
909
  /** TUI2-R2 ② — the picker's affordance row: the keys, said where the
830
910
  * keys are useful. */
831
911
  const PICKER_HINT = "↑↓ pick · ⏎ resumes · type filters · esc";
@@ -834,11 +914,139 @@ const PICKER_HINT = "↑↓ pick · ⏎ resumes · type filters · esc";
834
914
  * runtime's own accessors (see session-cards.ts); this is only the
835
915
  * plumbing that hands it the store's read side.
836
916
  */
837
- async function sessionCards(agent) {
917
+ async function sessionCards(_agent, announce = (l) => bodyLog(l)) {
838
918
  const store = sessionStoreRef;
839
919
  if (store === null)
840
920
  return []; // unreachable: makeAgent builds the store first
841
- return collectSessionCards(agent, (id) => store.load(id));
921
+ const folders = listingFolders();
922
+ // 0.40.0 dogfood (item 2, lead's ruling A): the ONE-TIME migration — the
923
+ // first list after the upgrade reads each legacy log exactly once and
924
+ // writes its summary into the sidecar; announced with its count. Per
925
+ // folder: each carries its own marker.
926
+ const pending = folders.filter((f) => summaryMigrationPending(f.dir));
927
+ const count = pending.reduce((n, f) => n + listSessionSidecars(f.dir).filter((l) => l.summary === null).length, 0);
928
+ if (count > 0)
929
+ announce(`recording summaries for ${count} older session${count === 1 ? "" : "s"} — once`);
930
+ for (const f of pending) {
931
+ const reader = f.dir === activeStoreDir ? store : new SessionStore(f.dir);
932
+ migrateSummaries(f.dir, (id) => reader.load(id));
933
+ }
934
+ // …and from then on, the SIDECARS only: no log is opened to draw a row.
935
+ // A project folder's sessions are that project's; one without a recorded
936
+ // workspace was placed by inference and says so.
937
+ 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)));
938
+ }
939
+ /**
940
+ * 0.40.0 — the folders a listing reads: every project's under the
941
+ * per-project layout (the picker opens on this one, `tab` shows all), and
942
+ * the one folder otherwise. This process's folder is always first, even
943
+ * before its first session exists.
944
+ */
945
+ function listingFolders() {
946
+ const here = ownSessionsDir();
947
+ const home = kisoHome();
948
+ if (!projectLayoutActive(home))
949
+ return [{ dir: here, workspace: null, kind: "legacy" }];
950
+ const all = sessionFolders(home);
951
+ const mine = all.find((f) => f.dir === here) ?? { dir: here, workspace: projectRoot(), kind: "project" };
952
+ return [mine, ...all.filter((f) => f.dir !== here)];
953
+ }
954
+ /**
955
+ * 0.40.0 — before any session opens: the one-time move of the legacy
956
+ * folder into project folders (announced once, with the undo), then this
957
+ * project's folder claimed. Nothing happens under a pinned folder or after
958
+ * a reversed migration.
959
+ */
960
+ function prepareSessionFolders() {
961
+ const home = kisoHome();
962
+ if (!projectLayoutActive(home))
963
+ return;
964
+ if (pendingLegacyIds(home).length > 0) {
965
+ const result = runMigration(home, planMigration(home));
966
+ if (result !== null && result.moved > 0) {
967
+ const line = migrationNotice(result);
968
+ if (process.stdout.isTTY)
969
+ bodyLog(line);
970
+ else
971
+ process.stderr.write(`${line}\n`);
972
+ }
973
+ }
974
+ claimProjectDir(ownSessionsDir(), projectRoot());
975
+ }
976
+ /**
977
+ * 0.40.0 (the owner's dogfood: a uooki session resumed from flowpix2) —
978
+ * whether a session id may open in this project. Its own folder: yes. A
979
+ * session another project RECORDED: refused, with where to go. One whose
980
+ * project is unknown, or was only inferred, is never refused and never
981
+ * moved (the lead's ruling: a resume changes no placement) — it opens where
982
+ * it is, and the line says the tools work here. A pure read. `null` = not
983
+ * found anywhere (the caller's own rule applies: a new session, or "no
984
+ * such session").
985
+ */
986
+ export function routeSession(id) {
987
+ const own = ownSessionsDir();
988
+ if (hasSession(own, id))
989
+ return { kind: "here" };
990
+ const home = kisoHome();
991
+ if (!projectLayoutActive(home))
992
+ return null;
993
+ const found = locateSession(home, id, own);
994
+ if (found === null)
995
+ return null;
996
+ const profile = readProfile(found.dir, id);
997
+ const recorded = profile.kind === "ok" && profile.profile.workspace !== null ? canonicalPath(profile.profile.workspace) : null;
998
+ const cwd = projectRoot();
999
+ const owner = found.kind === "project" ? (found.workspace ?? recorded) : recorded;
1000
+ if (recorded !== null && owner !== null && owner !== cwd) {
1001
+ return { kind: "refused", line: `this session belongs to ${tildeOf(owner)} — cd there to resume it` };
1002
+ }
1003
+ // recorded here, and still in the legacy folder (open elsewhere when the
1004
+ // move ran): nothing to warn about
1005
+ if (recorded !== null)
1006
+ return { kind: "elsewhere", dir: found.dir, line: null };
1007
+ const inferred = found.kind === "project" && owner !== null ? `workspace inferred as ${tildeOf(owner)}, never recorded` : "workspace unknown";
1008
+ return { kind: "elsewhere", dir: found.dir, line: `${inferred} — tools work in ${tildeOf(workspaceRoot())}` };
1009
+ }
1010
+ /** The folder a session opens in: the one the store is on when it holds
1011
+ * it, else this project's, else where the route found it. A new id opens
1012
+ * in this project's folder. */
1013
+ function sessionFolderOf(id) {
1014
+ if (hasSession(activeStoreDir, id))
1015
+ return activeStoreDir;
1016
+ const route = routeSession(id);
1017
+ return route?.kind === "elsewhere" ? route.dir : ownSessionsDir();
1018
+ }
1019
+ /** The folder makeAgent built the store on. */
1020
+ let activeStoreDir = "";
1021
+ /** The workspace a listing is scoped to: a project folder's identity under
1022
+ * the per-project layout, the recorded realpath in one pinned folder. */
1023
+ function scopeRoot() {
1024
+ return projectLayoutActive(kisoHome()) ? projectRoot() : workspaceRoot();
1025
+ }
1026
+ function tildeOf(path) {
1027
+ const home = homedir();
1028
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
1029
+ }
1030
+ /** The explicit-id doors (`chat <id>`, `resume <id>`, `-p … <id>`), BEFORE
1031
+ * makeAgent builds the store: a refusal is the entry's error (the line on
1032
+ * stderr, exit 2); a session in another folder opens there. Returns the
1033
+ * line to say once the session is up. */
1034
+ function enterRouted(id) {
1035
+ const route = routeSession(id);
1036
+ if (route === null || route.kind === "here")
1037
+ return null;
1038
+ if (route.kind === "refused")
1039
+ throw new CliUsageError(route.line);
1040
+ setOpenSessionFolder(route.dir);
1041
+ return route.line;
1042
+ }
1043
+ function sayRouteLine(line) {
1044
+ if (line === null)
1045
+ return;
1046
+ if (process.stdout.isTTY)
1047
+ bodyLog(line);
1048
+ else
1049
+ process.stderr.write(`${line}\n`);
842
1050
  }
843
1051
  /**
844
1052
  * TUI2-R2 ② — the resume picker: the band, the keys, the id.
@@ -866,7 +1074,8 @@ async function pickSession(agent, input) {
866
1074
  }
867
1075
  dock.setStatus("", PICKER_HINT);
868
1076
  const picked = await new Promise((resolve) => {
869
- input.pick(() => cards, resolve);
1077
+ // 0.40.0: scoped to the running workspace; tab flips to all
1078
+ input.pick(() => cards, resolve, scopeRoot());
870
1079
  });
871
1080
  dock.setStatus("", null);
872
1081
  return picked;
@@ -892,6 +1101,21 @@ async function pickSession(agent, input) {
892
1101
  * formatter — never a boot-time copy, which would drift from the real
893
1102
  * row the moment either changed.
894
1103
  */
1104
+ /**
1105
+ * ADR-0055 Amendment 1 (the owner, 2026-09-18): `autoCompact` — the
1106
+ * between-turn ratio, from config or KISO_AUTO_COMPACT — is RETIRED. The
1107
+ * in-run tiers compact inside a run and before its first request, which is
1108
+ * everything it did and more. 0.40.0 accepts it, ignores it and says so
1109
+ * once; 0.41.0 removes it.
1110
+ */
1111
+ let autoCompactNoticed = false;
1112
+ function retiredAutoCompact(merged) {
1113
+ if (!autoCompactNoticed && (process.env.KISO_AUTO_COMPACT !== undefined || resolveAutoCompact(merged) !== undefined)) {
1114
+ autoCompactNoticed = true;
1115
+ 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");
1116
+ }
1117
+ return undefined;
1118
+ }
895
1119
  /**
896
1120
  * CTX-1 (Astra F34-1): EVERY entry point that opens a session must bind the
897
1121
  * policy that follows the session's OWN model.
@@ -913,11 +1137,22 @@ async function pickSession(agent, input) {
913
1137
  * threshold got stuck in the first place.
914
1138
  */
915
1139
  function bindRestoredSession(session) {
1140
+ // 0.40.1 (the owner's ruling of 2026-09-21): a changed binding is neither
1141
+ // blocked nor "acknowledged" — it is recorded as the next revision under
1142
+ // the CURRENT configuration, and this ONE line says which binding and why
1143
+ // (the owner-ruled reasoning reset rides with it, silently until 0.40.0).
1144
+ const ack = session.driftAcknowledgement ?? null;
1145
+ if (ack !== null) {
1146
+ const was = ack.reasoningReset;
1147
+ const prior = [was.effort !== "default" ? `effort ${was.effort}` : "", was.thinking !== "default" ? `thinking ${was.thinking}` : ""].filter((s) => s !== "").join(", ");
1148
+ bodyLog(`binding changed — now on ${session.model}: ${ack.reasons.join("; ")}; reasoning reset to defaults${prior === "" ? "" : ` (was ${prior})`}`);
1149
+ }
916
1150
  setAgentModel(session.model, session.baseUrl);
917
1151
  session.setMicrocompactThreshold(microcompactThresholdFor({
918
1152
  model: session.model,
919
1153
  ...(session.baseUrl !== undefined ? { baseUrl: session.baseUrl } : {}),
920
1154
  }));
1155
+ session.setContextWindow(contextWindowTokens({ model: session.model, ...(session.baseUrl !== undefined ? { baseUrl: session.baseUrl } : {}) }));
921
1156
  }
922
1157
  function paintBootStatus(session) {
923
1158
  if (!dock.active)
@@ -971,7 +1206,7 @@ function skillCount() {
971
1206
  * is the durable log on disk. The record never moves: a reload is
972
1207
  * invisible in it, because nothing the model said or did changes.
973
1208
  */
974
- async function reloadAgent(old, id, input) {
1209
+ async function reloadAgent(old, id, input, announce = true) {
975
1210
  // Snapshot BEFORE makeAgent: it overwrites every one of these, so the
976
1211
  // old set would be unreachable by the time we needed to dispose it.
977
1212
  const oldLoaded = loadedExtensions;
@@ -1031,6 +1266,7 @@ async function reloadAgent(old, id, input) {
1031
1266
  setConfiguredWindow(oldCfg.window);
1032
1267
  setCurrentFaux(oldCfg.faux);
1033
1268
  setCurrentModelName(oldCfg.modelName);
1269
+ setCurrentProfileName(null); // the reload snapshot carries no profile name: no mark beats a guessed one
1034
1270
  setModelChoice(oldCfg.choice);
1035
1271
  setAgentModel(oldCfg.agent, oldCfg.endpoint);
1036
1272
  if (oldCfg.delegation === undefined)
@@ -1051,7 +1287,8 @@ async function reloadAgent(old, id, input) {
1051
1287
  // best-effort — the temp dir would be reaped by the OS
1052
1288
  }
1053
1289
  }
1054
- bodyLog(`[reload] ${loadedExtensions.length} extensions, ${skillCount()} skills — the conversation is unchanged`);
1290
+ if (announce)
1291
+ bodyLog(`[reload] ${loadedExtensions.length} extensions, ${skillCount()} skills — the conversation is unchanged`);
1055
1292
  return next;
1056
1293
  }
1057
1294
  async function chatLoop(first, firstId, input, autoCompact) {
@@ -1063,11 +1300,83 @@ async function chatLoop(first, firstId, input, autoCompact) {
1063
1300
  // reload said what it did; repeating the session's opening would read
1064
1301
  // as a new session, which is the one thing it is not.
1065
1302
  let rebuilt = false;
1303
+ // XP-1: the id of the session that IS open — null until the first open
1304
+ // succeeds, and cleared by a refused switch, so one refusal can never
1305
+ // recurse into another.
1306
+ let opened = null;
1307
+ // XP-1: the re-open that follows a refused switch. The session is the one
1308
+ // already on screen, so this step prints neither banner nor tail.
1309
+ let refused = false;
1310
+ /** DC-57: lines that arrived with a switch command belong to the session being asked for. */
1311
+ let seed = [];
1066
1312
  for (;;) {
1067
- const session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
1313
+ // 0.40.0 (the lead's ruling): a session opens in the folder that holds
1314
+ // it, never moved — a switch that crosses folders rebuilds the agent
1315
+ // on the other folder's store, and one back rebuilds it on this
1316
+ // project's. A failed rebuild switches nothing.
1317
+ const folder = sessionFolderOf(id);
1318
+ if (folder !== activeStoreDir) {
1319
+ const was = { dir: activeStoreDir, store: sessionStoreRef };
1320
+ setOpenSessionFolder(folder === ownSessionsDir() ? null : folder);
1321
+ const next = await reloadAgent(agent, id, input, false);
1322
+ if (next === agent) {
1323
+ // makeAgent may have built the new store before it failed
1324
+ activeStoreDir = was.dir;
1325
+ if (was.store !== null)
1326
+ setSessionStore(was.store);
1327
+ setOpenSessionFolder(was.dir === ownSessionsDir() ? null : was.dir);
1328
+ if (prev === null)
1329
+ return;
1330
+ id = prev;
1331
+ rebuilt = true;
1332
+ continue;
1333
+ }
1334
+ agent = next;
1335
+ }
1336
+ let session;
1337
+ try {
1338
+ session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
1339
+ }
1340
+ catch (err) {
1341
+ // The profile contract's BLOCKED cases — a sidecar that cannot be
1342
+ // read, an XP-era log without one — belong to the ENTRY: nothing is
1343
+ // on screen yet, and the message says what to do. An in-session
1344
+ // SWITCH is a LINE: it switches NOTHING and the session you were in
1345
+ // stays open. The throw used to escape chatLoop, which killed the
1346
+ // REPL and left the session that was open behind with no log at all
1347
+ // (announced once, then empty and unopenable). Every refusal this
1348
+ // open can throw is treated the same way — the message itself is
1349
+ // what says what to do about it.
1350
+ if (opened === null)
1351
+ throw err;
1352
+ bodyLog(escapeTerminal(err.message));
1353
+ // DC-57's other half + DC-55 (the owner's ruling of 2026-09-21,
1354
+ // option ①): the lines that arrived WITH the refused switch were aimed
1355
+ // at a session that EXISTS and could not be opened. They are NOT run
1356
+ // here — a departing session never answers lines meant for the target —
1357
+ // and not dropped in silence either: what was held is printed, so
1358
+ // nothing vanishes without the person seeing it. (Leaving them in the
1359
+ // queue would hand them to the old session on its next entry, which is
1360
+ // the thing this ruling forbids.)
1361
+ seed = [];
1362
+ const held = queuedSwitchLines.splice(0);
1363
+ if (held.length > 0) {
1364
+ bodyLog(`[${held.length} line${held.length === 1 ? "" : "s"} held back — the switch was refused, so nothing was run]`);
1365
+ for (const line of held)
1366
+ bodyLog(` ${escapeTerminal(line)}`);
1367
+ }
1368
+ id = opened; // the switch is undone
1369
+ opened = null;
1370
+ refused = true;
1371
+ continue;
1372
+ }
1373
+ opened = id;
1068
1374
  if (rebuilt) {
1069
1375
  rebuilt = false;
1070
1376
  }
1377
+ else if (refused) {
1378
+ refused = false; // back in the session that was already on screen
1379
+ }
1071
1380
  else if (prev === null) {
1072
1381
  bodyLog(`session ${id}\n`);
1073
1382
  // REL-0152-D5: a session with history says what that history WAS.
@@ -1075,7 +1384,7 @@ async function chatLoop(first, firstId, input, autoCompact) {
1075
1384
  // prompt inside a conversation with thousands of events — the
1076
1385
  // durable log was right there and none of it was shown. Empty for
1077
1386
  // 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
1387
+ showResumeTail(session.log.all);
1079
1388
  // R2 (owner, 2026-08-27): the resume list is NOT on the opening
1080
1389
  // screen. `/resume` is where you go looking for a session; the
1081
1390
  // opening's job is to say what THIS one is.
@@ -1107,7 +1416,7 @@ async function chatLoop(first, firstId, input, autoCompact) {
1107
1416
  }
1108
1417
  else {
1109
1418
  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
1419
+ showResumeTail(session.log.all);
1111
1420
  if (currentFaux)
1112
1421
  session.setAdapter(createFauxProvider(readFauxScript().slice(fauxSkip(id))));
1113
1422
  }
@@ -1125,6 +1434,12 @@ async function chatLoop(first, firstId, input, autoCompact) {
1125
1434
  // the threshold. One step, shared by all three entry points.
1126
1435
  bindRestoredSession(session);
1127
1436
  setCurrentModelName(session.model);
1437
+ // A SWITCH knows only a model id here — nothing that names the profile —
1438
+ // so the mark is cleared rather than guessed. A FRESH start keeps what
1439
+ // makeAgent resolved from `--model <profile>`, which is the one place
1440
+ // the name is known (prev is null only on the first entry).
1441
+ if (prev !== null)
1442
+ setCurrentProfileName(null);
1128
1443
  paintBootStatus(session);
1129
1444
  // The terminal's window title, HERE for the same reason the three
1130
1445
  // lines above are here: this is the one step all three entry points
@@ -1132,12 +1447,18 @@ async function chatLoop(first, firstId, input, autoCompact) {
1132
1447
  // be left naming the session the user just left.
1133
1448
  paintWindowTitle(session.log.all);
1134
1449
  const nav = {
1135
- sessions: () => agent.sessions().map((m) => m.id),
1450
+ // 0.40.0 dogfood: the ids only — agent.sessions() read every log whole
1451
+ // (seconds on the owner's 118 sessions) before /resume could open
1452
+ sessions: () => agent.sessionIds(),
1453
+ route: (sessionId) => routeSession(sessionId),
1136
1454
  ...(process.stdin.isTTY ? { pick: () => pickSession(agent, input) } : {}),
1137
1455
  };
1138
- const end = await chat(session, currentFaux, input, autoCompact, nav);
1456
+ const end = await chat(session, currentFaux, input, autoCompact, nav, seed);
1139
1457
  if (end.next === "exit")
1140
1458
  return;
1459
+ // DC-57: the lines that arrived with the switch command ride INTO the
1460
+ // next entry — they were aimed at the session being asked for.
1461
+ seed = end.lines ?? [];
1141
1462
  if (end.next === "reload") {
1142
1463
  agent = await reloadAgent(agent, id, input);
1143
1464
  rebuilt = true;
@@ -1197,6 +1518,20 @@ async function readSecret(prompt) {
1197
1518
  process.stdin.on("data", onData);
1198
1519
  });
1199
1520
  }
1521
+ /**
1522
+ * What a resumed session shows (REL-0152-D5, 4c). On the compositor: the
1523
+ * durable events replayed into cells — the last two turns in full, the
1524
+ * earlier ones in one fold row the ctrl+r viewer reads. Through a pipe
1525
+ * there is no viewer and no cell renderer, so the plain text tail stays
1526
+ * (DC-51: one call, one cell).
1527
+ */
1528
+ function showResumeTail(events) {
1529
+ const W = process.stdout.columns ?? 80;
1530
+ if (dock.active)
1531
+ replayInto(body, events, W);
1532
+ else
1533
+ bodyLog(resumeTail(events, W).join("\n"));
1534
+ }
1200
1535
  async function main() {
1201
1536
  // E group (the graceful-exit gate ③, R-G 0.1.48): a terminal closing
1202
1537
  // turns the in-flight stdout/stderr writes into EIO, and node's
@@ -1273,7 +1608,7 @@ async function main() {
1273
1608
  if (modeFlag !== -1) {
1274
1609
  const m = MODES.find((x) => x === args[modeFlag + 1]);
1275
1610
  if (m === undefined) {
1276
- console.error(`unknown mode: ${args[modeFlag + 1]} (tiers: ${MODES.join(", ")})`);
1611
+ console.error(`unknown mode: ${args[modeFlag + 1]} (tiers: ${OFFERED_MODES.join(", ")})`);
1277
1612
  process.exit(2);
1278
1613
  }
1279
1614
  setMode(m);
@@ -1292,6 +1627,11 @@ async function main() {
1292
1627
  const path = args[i + 1];
1293
1628
  if (path === undefined)
1294
1629
  throw new CliUsageError("--task-file needs a path");
1630
+ // the file becomes the turn a model reads — never the credential
1631
+ // store. The list here is the store's own path: the user config's
1632
+ // protectedPaths is read later, with the agent
1633
+ if (isProtectedPath(path, protectedIdentity(protectedFiles())))
1634
+ throw new CliUsageError(`--task-file: ${path} — ${PROTECTED_REFUSAL}`);
1295
1635
  try {
1296
1636
  taskFile = readFileSync(path, "utf8");
1297
1637
  }
@@ -1301,6 +1641,30 @@ async function main() {
1301
1641
  args.splice(i, 2);
1302
1642
  }
1303
1643
  }
1644
+ // 0.40.0: `kiso sessions --all | --current` — the one command that takes
1645
+ // them. Parsed only there, so neither ever becomes a session id. Absent
1646
+ // = each form's default (TTY: current; pipe: all, today's bytes).
1647
+ let listScope;
1648
+ let reverseManifest;
1649
+ if (args[0] === "sessions") {
1650
+ // 0.40.0: the undo of the per-project move, named by its manifest
1651
+ const r = args.indexOf("--reverse-migration");
1652
+ if (r !== -1) {
1653
+ reverseManifest = args[r + 1];
1654
+ if (reverseManifest === undefined || reverseManifest.startsWith("--"))
1655
+ throw new CliUsageError("kiso sessions --reverse-migration <manifest> — the path the move announced");
1656
+ args.splice(r, 2);
1657
+ }
1658
+ for (const flag of ["--all", "--current"]) {
1659
+ const i = args.indexOf(flag);
1660
+ if (i === -1)
1661
+ continue;
1662
+ args.splice(i, 1);
1663
+ if (listScope !== undefined && listScope !== flag.slice(2))
1664
+ throw new CliUsageError("kiso sessions: --all and --current contradict each other");
1665
+ listScope = flag.slice(2);
1666
+ }
1667
+ }
1304
1668
  const [command, arg] = args;
1305
1669
  // round 8: faux mode is the keyless demo script — an exhausted script must
1306
1670
  // exit non-zero, never masquerade as a successful provider run. The
@@ -1413,8 +1777,10 @@ async function main() {
1413
1777
  // the -p flow: recovery-first one-shot, the resume() machinery
1414
1778
  // verbatim (a fresh id makes the recovery a no-op)
1415
1779
  const id = command ?? newSessionId(sessionsDir());
1780
+ const routeLine = command !== undefined ? enterRouted(id) : null;
1416
1781
  agent = await makeAgent(id, input, modelFlag);
1417
1782
  applyConfigMode();
1783
+ sayRouteLine(routeLine);
1418
1784
  const session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
1419
1785
  bindRestoredSession(session); // CTX-1 (F34-1): the -p door, too
1420
1786
  faux = currentFaux;
@@ -1431,10 +1797,12 @@ async function main() {
1431
1797
  dock.enter();
1432
1798
  // E area: a resumed session continues the script at its durable
1433
1799
  // position — never restarts it (fauxSkip).
1800
+ const routeLine = arg !== undefined ? enterRouted(id) : null;
1434
1801
  agent = await makeAgent(id, input, modelFlag);
1435
1802
  applyConfigMode();
1803
+ sayRouteLine(routeLine);
1436
1804
  faux = currentFaux;
1437
- await chatLoop(agent, id, input, resolveAutoCompact(mergedConfig));
1805
+ await chatLoop(agent, id, input, retiredAutoCompact(mergedConfig));
1438
1806
  break;
1439
1807
  }
1440
1808
  case "resume": {
@@ -1451,6 +1819,7 @@ async function main() {
1451
1819
  // (argv = [node, script, resume, id, prompt?]).
1452
1820
  const prompt = process.argv[4];
1453
1821
  dock.enter();
1822
+ const routeLine = arg !== undefined ? enterRouted(arg) : null;
1454
1823
  agent = await makeAgent(arg, input, modelFlag);
1455
1824
  applyConfigMode();
1456
1825
  let id = arg;
@@ -1461,15 +1830,25 @@ async function main() {
1461
1830
  // an error, never a session started behind their back.
1462
1831
  if (picked === null)
1463
1832
  break;
1833
+ // 0.40.0: `tab` lists every project, and another project's
1834
+ // recorded session is refused here, with where to go
1835
+ const route = routeSession(picked);
1836
+ if (route?.kind === "refused") {
1837
+ bodyLog(route.line);
1838
+ break;
1839
+ }
1840
+ if (route?.kind === "elsewhere" && route.line !== null)
1841
+ bodyLog(route.line);
1464
1842
  // the mini-spec (a DECLARED SUPERSESSION of the one-shot
1465
1843
  // picker flow): a PICKED session enters the full REPL —
1466
1844
  // "resume and keep working" no longer requires knowing to
1467
1845
  // type `kiso chat <id>`. The explicit-id one-shot form
1468
1846
  // (`kiso resume <id> ["prompt"]`) keeps its exact bytes.
1469
1847
  faux = currentFaux;
1470
- await chatLoop(agent, picked, input, resolveAutoCompact(mergedConfig));
1848
+ await chatLoop(agent, picked, input, retiredAutoCompact(mergedConfig));
1471
1849
  break;
1472
1850
  }
1851
+ sayRouteLine(routeLine);
1473
1852
  const session = await agent.session({ id, ...(acceptDrift() ? { acceptDrift: true } : {}) });
1474
1853
  bindRestoredSession(session); // CTX-1 (F34-1): the explicit-id door
1475
1854
  faux = currentFaux;
@@ -1482,7 +1861,7 @@ async function main() {
1482
1861
  session.setAdapter(createFauxProvider(readFauxScript().slice(fauxSkip(id))));
1483
1862
  // REL-0152-D5 — the same tail on the explicit-id form. NOT on
1484
1863
  // 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
1864
+ showResumeTail(session.log.all);
1486
1865
  await resume(session, prompt, faux, input);
1487
1866
  break;
1488
1867
  }
@@ -1495,22 +1874,60 @@ async function main() {
1495
1874
  // "reading 'question'" on the dock-less branch). The input
1496
1875
  // exists so the gate's ask can be answered; the listing
1497
1876
  // itself never touches it.
1877
+ if (reverseManifest !== undefined) {
1878
+ // before makeAgent: its folder preparation would otherwise run
1879
+ // the very move this undoes
1880
+ const { restored, left } = reverseMigration(kisoHome(), reverseManifest);
1881
+ 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` : ""}`);
1882
+ break;
1883
+ }
1498
1884
  agent = await makeAgent(undefined, input, modelFlag);
1499
1885
  // TUI2-R2 ③ — the same projection the picker renders, printed.
1500
1886
  // The PIPE keeps today's bytes exactly: `kiso sessions` is
1501
1887
  // something scripts read, and a badge column is a TTY-render
1502
1888
  // concern, not a change to a machine interface.
1889
+ // 0.40.0 (lead's ruling): the TTY defaults to THIS workspace and
1890
+ // says so in a header line with both counts; the PIPE defaults to
1891
+ // every session with today's bytes — scripts must not change
1892
+ // under them. An explicit --all or --current applies to both.
1893
+ const here = scopeRoot();
1503
1894
  if (process.stdout.isTTY) {
1504
- const cards = await sessionCards(agent);
1895
+ const every = await sessionCards(agent, (l) => console.log(l));
1896
+ const all = (listScope ?? "current") === "all";
1897
+ const inHere = every.filter((c) => c.workspace === here);
1898
+ const cards = all ? every : inHere;
1505
1899
  const W = process.stdout.columns ?? 80;
1506
1900
  const col = idColumn(cards);
1507
1901
  const now = Date.now();
1902
+ console.log(sessionListHeader(inHere.length, every.length, all, W));
1903
+ // 0.40.1: the sessions with no workspace — one counted line, never
1904
+ // listed, unless --all
1905
+ if (!all) {
1906
+ const unknownLine = sessionListUnknownLine(every.filter((c) => c.workspace === null).length, W);
1907
+ if (unknownLine !== "")
1908
+ console.log(unknownLine);
1909
+ }
1508
1910
  for (const card of cards)
1509
- console.log(sessionListRow(card, W, now, col));
1911
+ console.log(sessionListRow(card, W, now, col, all ? here : null));
1510
1912
  console.log(sessionListFooter(cards.length, W));
1511
1913
  }
1512
1914
  else {
1513
- for (const meta of agent.sessions()) {
1915
+ // 0.40.0: every folder's sessions, merged in id order — today's
1916
+ // bytes; --current is this project's folder (in a single pinned
1917
+ // folder, the sessions that recorded this workspace)
1918
+ const folders = listingFolders();
1919
+ const single = folders.length === 1 && folders[0].kind === "legacy";
1920
+ const workspaceOf = (id) => {
1921
+ const r = readProfile(sessionsDir(), id);
1922
+ return r.kind === "ok" ? r.profile.workspace : null;
1923
+ };
1924
+ const metas = folders
1925
+ .filter((f) => listScope !== "current" || single || f.dir === sessionsDir())
1926
+ .flatMap((f) => (f.dir === sessionsDir() ? agent.sessions() : new SessionStore(f.dir).list()))
1927
+ .sort((a, b) => a.id.localeCompare(b.id));
1928
+ for (const meta of metas) {
1929
+ if (listScope === "current" && single && workspaceOf(meta.id) !== here)
1930
+ continue;
1514
1931
  console.log(renderSessionLine(meta));
1515
1932
  }
1516
1933
  }
@@ -1615,7 +2032,7 @@ async function main() {
1615
2032
  " kiso chat [sessionId] same as above\n" +
1616
2033
  " kiso resume pick a session to continue (TTY picker)\n" +
1617
2034
  " kiso resume <id> [prompt] continue a session (one-shot)\n" +
1618
- " kiso sessions list durable sessions\n" +
2035
+ " kiso sessions [--all|--current] list durable sessions (a terminal shows this workspace's by default)\n" +
1619
2036
  " kiso login <provider> anthropic|openai|deepseek|zai: store an API key (hidden prompt, or stdin when piped);\n" +
1620
2037
  " chatgpt: sign in with a ChatGPT subscription (browser; unofficial third-party flow)\n" +
1621
2038
  " kiso logout <provider> remove the stored credential\n" +
@@ -1624,7 +2041,7 @@ async function main() {
1624
2041
  " kiso help this help\n\n" +
1625
2042
  "flags (any position):\n" +
1626
2043
  " --model <profile|provider/model> pick the model (also /model in-session)\n" +
1627
- " --mode <tier> approval tier: manual|default|accept-edits|plan|bypass\n" +
2044
+ " --mode <tier> approval tier: default|accept-edits|plan|dontAsk|bypass\n" +
1628
2045
  " --version print the version\n\n" +
1629
2046
  "configuration:\n" +
1630
2047
  " no key keyless faux demo (a scripted four-round session)\n" +
@@ -1654,7 +2071,7 @@ async function main() {
1654
2071
  // lives inside the resolver. It used to pass the env-only
1655
2072
  // reader, so a user's config.json autoCompact worked on
1656
2073
  // `kiso chat` and silently vanished on the default entry.
1657
- await chatLoop(agent, id, input, resolveAutoCompact(mergedConfig));
2074
+ await chatLoop(agent, id, input, retiredAutoCompact(mergedConfig));
1658
2075
  break;
1659
2076
  }
1660
2077
  }