@lazyingart/agintiflow 0.20.43 → 0.20.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,6 +138,8 @@ aginti update
138
138
  aginti sessions list
139
139
  aginti sessions show <session-id>
140
140
  aginti sessions rename <session-id> "friendly title"
141
+ aginti --remove-empty-sessions
142
+ aginti --remove-sessions
141
143
  aginti storage migrate
142
144
  aginti resume
143
145
  aginti resume --all-sessions
@@ -151,6 +153,8 @@ aginti "set up this project and run the tests"
151
153
 
152
154
  Bare `aginti resume` lists sessions for the current cwd by default. Use `--all-sessions` to browse the global session index; in the interactive selector, type a number to resume, `q` to quit, `/text` to filter the visible list, or `/` to clear the filter.
153
155
 
156
+ Session cleanup is cwd-scoped by default. `aginti --remove-empty-sessions` shows only empty sessions and preselects them; `aginti --remove-sessions` shows all cwd sessions with nothing preselected. The cleanup selector uses Space to select, Up/Down to move, Tab to switch to OK/Cancel, and a second Yes/Cancel confirmation before deleting the project pointer and central `~/.agintiflow/sessions/<session-id>` data.
157
+
154
158
  Run from a source checkout:
155
159
 
156
160
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.43",
3
+ "version": "0.20.44",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a web-first coding agent and CLI with DeepSeek routing, sandboxed tools, model providers, canvas artifacts, and optional wrappers.",
6
6
  "license": "Apache-2.0",
@@ -2,7 +2,13 @@
2
2
  import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { ensureProjectSessionStorage, listProjectSessions, sessionStoreOptions } from "../src/project.js";
5
+ import {
6
+ ensureProjectSessionStorage,
7
+ listProjectSessionRemovalCandidates,
8
+ listProjectSessions,
9
+ removeProjectSessions,
10
+ sessionStoreOptions,
11
+ } from "../src/project.js";
6
12
  import { SessionStore } from "../src/session-store.js";
7
13
 
8
14
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-inbox-"));
@@ -77,6 +83,48 @@ try {
77
83
  const allSessions = await listProjectSessions(legacyProject, { limit: 10, allSessions: true });
78
84
  assert(allSessions.some((session) => session.sessionId === "other-cwd-smoke"), "--all-sessions mode did not include a different cwd session");
79
85
 
86
+ const emptyStore = new SessionStore(paths.globalSessionsDir, "empty-session-smoke", sessionStoreOptions(legacyProject, "empty-session-smoke"));
87
+ await emptyStore.saveState({
88
+ sessionId: "empty-session-smoke",
89
+ createdAt: "2026-01-01T00:04:00.000Z",
90
+ updatedAt: "2026-01-01T00:04:00.000Z",
91
+ provider: "mock",
92
+ model: "mock-agent",
93
+ projectRoot: legacyProject,
94
+ commandCwd: legacyProject,
95
+ chat: [],
96
+ stepsCompleted: 0,
97
+ });
98
+ const nonEmptyStore = new SessionStore(paths.globalSessionsDir, "nonempty-session-smoke", sessionStoreOptions(legacyProject, "nonempty-session-smoke"));
99
+ await nonEmptyStore.saveState({
100
+ sessionId: "nonempty-session-smoke",
101
+ createdAt: "2026-01-01T00:05:00.000Z",
102
+ updatedAt: "2026-01-01T00:05:00.000Z",
103
+ provider: "mock",
104
+ model: "mock-agent",
105
+ goal: "keep this non-empty session",
106
+ projectRoot: legacyProject,
107
+ commandCwd: legacyProject,
108
+ chat: [{ role: "user", content: "hello" }],
109
+ stepsCompleted: 1,
110
+ });
111
+ const removalCandidates = await listProjectSessionRemovalCandidates(legacyProject, { limit: 20, commandCwd: legacyProject });
112
+ assert(removalCandidates.find((session) => session.sessionId === "empty-session-smoke")?.isEmpty, "empty session was not detected");
113
+ assert(removalCandidates.find((session) => session.sessionId === "nonempty-session-smoke")?.isEmpty === false, "non-empty session was classified as empty");
114
+ const emptyOnly = await listProjectSessionRemovalCandidates(legacyProject, { limit: 20, commandCwd: legacyProject, emptyOnly: true });
115
+ assert(emptyOnly.some((session) => session.sessionId === "empty-session-smoke"), "empty-only removal list omitted the empty session");
116
+ assert(!emptyOnly.some((session) => session.sessionId === "nonempty-session-smoke"), "empty-only removal list included a non-empty session");
117
+ const removed = await removeProjectSessions(legacyProject, ["empty-session-smoke"]);
118
+ assert(removed.removed.length === 1, "empty session removal did not report one removed session");
119
+ assert(
120
+ !(await fs.stat(path.join(paths.globalSessionsDir, "empty-session-smoke", "state.json")).then((stat) => stat.isFile()).catch(() => false)),
121
+ "empty session global state was not removed"
122
+ );
123
+ assert(
124
+ !(await fs.stat(path.join(paths.sessionsDir, "empty-session-smoke", "session.json")).then((stat) => stat.isFile()).catch(() => false)),
125
+ "empty session project pointer was not removed"
126
+ );
127
+
80
128
  console.log(
81
129
  JSON.stringify(
82
130
  {
@@ -89,6 +137,8 @@ try {
89
137
  "global-session-store",
90
138
  "cwd-session-filter",
91
139
  "all-sessions-list",
140
+ "empty-session-detection",
141
+ "empty-session-removal",
92
142
  ],
93
143
  },
94
144
  null,
package/src/cli.js CHANGED
@@ -17,8 +17,10 @@ import {
17
17
  doctorReport,
18
18
  ensureProjectSessionStorage,
19
19
  initProject,
20
+ listProjectSessionRemovalCandidates,
20
21
  listProjectSessions,
21
22
  renameProjectSession,
23
+ removeProjectSessions,
22
24
  providerKeyStatus,
23
25
  setProviderKey,
24
26
  showProjectSession,
@@ -34,6 +36,7 @@ import fs from "node:fs/promises";
34
36
  import path from "node:path";
35
37
  import { fileURLToPath } from "node:url";
36
38
  import readline from "node:readline/promises";
39
+ import * as readlineRaw from "node:readline";
37
40
 
38
41
  const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
39
42
  const packageJson = JSON.parse(await fs.readFile(path.join(packageDir, "package.json"), "utf8"));
@@ -378,7 +381,7 @@ export function parseArgs(argv) {
378
381
 
379
382
  function printUsage() {
380
383
  console.log(
381
- 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti update OR aginti models OR aginti skills [query] OR aginti auth [deepseek|openai|qwen|venice|grsai] OR aginti resume [--all-sessions] [latest|<session-id>] ["prompt"] OR aginti queue <session-id> "message" OR aginti [--no-auto-update] [--language en|ja|zh-Hans|zh-Hant|ko|fr|es|ar|vi|de|ru] [--image] [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|venice|mock] [--model MODEL] [--route-model MODEL] [--main-model MODEL] [--spare-model MODEL --spare-reasoning medium] [--aux-provider grsai|venice --aux-model MODEL] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex --wrapper-model gpt-5.5] [--list-models|--list-routes] "your task"'
384
+ 'Usage: aginti [chat] OR aginti web [--port 3210] OR aginti update OR aginti models OR aginti skills [query] OR aginti auth [deepseek|openai|qwen|venice|grsai] OR aginti resume [--all-sessions] [latest|<session-id>] ["prompt"] OR aginti --remove-empty-sessions OR aginti --remove-sessions OR aginti queue <session-id> "message" OR aginti [--no-auto-update] [--language en|ja|zh-Hans|zh-Hant|ko|fr|es|ar|vi|de|ru] [--image] [--latex] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|venice|mock] [--model MODEL] [--route-model MODEL] [--main-model MODEL] [--spare-model MODEL --spare-reasoning medium] [--aux-provider grsai|venice --aux-model MODEL] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex --wrapper-model gpt-5.5] [--list-models|--list-routes] "your task"'
382
385
  );
383
386
  console.log(`Languages: ${["en", "ja", "zh-Hans", "zh-Hant", "ko", "fr", "es", "ar", "vi", "de", "ru"].map((code) => `${code}=${languageLabel(code)}`).join(", ")}`);
384
387
  }
@@ -600,9 +603,214 @@ function printAuthWizardResult(result) {
600
603
  }
601
604
  }
602
605
 
606
+ const removeSessionAnsi = {
607
+ reset: "\x1b[0m",
608
+ bold: "\x1b[1m",
609
+ dim: "\x1b[2m",
610
+ inverse: "\x1b[7m",
611
+ };
612
+
613
+ function removeSessionColor(text, ...codes) {
614
+ return `${codes.join("")}${text}${removeSessionAnsi.reset}`;
615
+ }
616
+
617
+ function stripAnsi(text) {
618
+ return String(text || "").replace(/\x1b\[[0-9;]*m/g, "");
619
+ }
620
+
621
+ function ellipsize(text, width) {
622
+ const value = String(text || "").replace(/\s+/g, " ").trim();
623
+ if (value.length <= width) return value.padEnd(width, " ");
624
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
625
+ }
626
+
627
+ function buttonLabel(label, focused, disabled = false) {
628
+ const text = ` ${label} `;
629
+ if (disabled) return removeSessionColor(text, removeSessionAnsi.dim);
630
+ return focused ? removeSessionColor(text, removeSessionAnsi.inverse, removeSessionAnsi.bold) : text;
631
+ }
632
+
633
+ function renderSessionRemovalWizard(state) {
634
+ const width = Math.min(Math.max(process.stdout.columns || 100, 80), 128);
635
+ const rows = process.stdout.rows || 28;
636
+ const bodyWidth = width - 4;
637
+ const visibleCount = Math.max(5, Math.min(state.items.length || 1, rows - 11));
638
+ if (state.cursor < state.scroll) state.scroll = state.cursor;
639
+ if (state.cursor >= state.scroll + visibleCount) state.scroll = state.cursor - visibleCount + 1;
640
+ const shown = state.items.slice(state.scroll, state.scroll + visibleCount);
641
+ const selectedCount = state.selected.size;
642
+ const border = "─".repeat(width - 2);
643
+ const line = (content = "") => {
644
+ const value = String(content || "");
645
+ const clipped = stripAnsi(value).length > bodyWidth ? ellipsize(stripAnsi(value), bodyWidth) : value;
646
+ return `│ ${clipped}${" ".repeat(Math.max(0, bodyWidth - stripAnsi(clipped).length))} │`;
647
+ };
648
+ const listRows = shown.map((session, offset) => {
649
+ const index = state.scroll + offset;
650
+ const checked = state.selected.has(session.sessionId) ? "[x]" : "[ ]";
651
+ const cursor = index === state.cursor ? ">" : " ";
652
+ const badge = session.isEmpty ? "empty" : "work";
653
+ const title = session.title || session.goal || "(no title)";
654
+ const meta = `${session.provider || "unknown"}/${session.model || "unknown"} chat=${session.chatCount} steps=${session.stepsCompleted} files=${session.artifactFileCount}`;
655
+ const row = `${cursor} ${checked} ${badge.padEnd(5)} ${session.sessionId} ${meta} ${title}`;
656
+ const clipped = ellipsize(row, bodyWidth);
657
+ return state.focus === "list" && index === state.cursor ? line(removeSessionColor(clipped, removeSessionAnsi.inverse)) : line(clipped);
658
+ });
659
+ const footer =
660
+ state.phase === "confirm"
661
+ ? `Confirm deletion: ${buttonLabel("Yes, delete", state.confirmFocus === "yes")} ${buttonLabel("Cancel", state.confirmFocus === "cancel")}`
662
+ : `Actions: ${buttonLabel(`OK delete ${selectedCount}`, state.focus === "ok", selectedCount === 0)} ${buttonLabel("Cancel", state.focus === "cancel")}`;
663
+ const guidance =
664
+ state.phase === "confirm"
665
+ ? "Left/Right switches choice. Enter confirms. Esc/q cancels."
666
+ : "Space toggles. Up/Down moves. Tab changes focus. Enter opens confirm. Esc/q cancels.";
667
+ const lines = [
668
+ `╭${border}╮`,
669
+ line(state.title),
670
+ line(state.subtitle),
671
+ line(`Showing ${state.items.length === 0 ? 0 : state.scroll + 1}-${Math.min(state.items.length, state.scroll + visibleCount)} of ${state.items.length}; selected ${selectedCount}`),
672
+ `├${border}┤`,
673
+ ...listRows,
674
+ `├${border}┤`,
675
+ line(footer),
676
+ line(state.message || guidance),
677
+ `╰${border}╯`,
678
+ ];
679
+ process.stdout.write(`\x1b[H\x1b[2J${lines.join("\n")}`);
680
+ }
681
+
682
+ async function promptRemoveSessions(candidates, { defaultSelectedIds = [], title = "Remove sessions", subtitle = "" } = {}) {
683
+ if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== "function") {
684
+ console.log("Interactive terminal required; no sessions removed.");
685
+ return null;
686
+ }
687
+ const state = {
688
+ items: candidates,
689
+ selected: new Set(defaultSelectedIds),
690
+ cursor: 0,
691
+ scroll: 0,
692
+ focus: "list",
693
+ phase: "select",
694
+ confirmFocus: "cancel",
695
+ message: "",
696
+ title,
697
+ subtitle,
698
+ };
699
+ return await new Promise((resolve) => {
700
+ const input = process.stdin;
701
+ const output = process.stdout;
702
+ const cleanup = (value) => {
703
+ input.off("keypress", onKeypress);
704
+ if (input.isTTY) input.setRawMode(false);
705
+ input.pause();
706
+ output.write("\x1b[?25h\x1b[?1049l");
707
+ resolve(value);
708
+ };
709
+ const moveCursor = (delta) => {
710
+ state.focus = "list";
711
+ state.cursor = Math.min(Math.max(state.cursor + delta, 0), Math.max(0, state.items.length - 1));
712
+ state.message = "";
713
+ };
714
+ const toggleCurrent = () => {
715
+ const session = state.items[state.cursor];
716
+ if (!session) return;
717
+ if (state.selected.has(session.sessionId)) state.selected.delete(session.sessionId);
718
+ else state.selected.add(session.sessionId);
719
+ state.message = `${state.selected.size} session(s) selected.`;
720
+ };
721
+ const openConfirm = () => {
722
+ if (state.selected.size === 0) {
723
+ state.message = "Select at least one session before confirming.";
724
+ return;
725
+ }
726
+ state.phase = "confirm";
727
+ state.confirmFocus = "cancel";
728
+ state.message = "Second confirmation required before deleting session data.";
729
+ };
730
+ function onKeypress(char, key = {}) {
731
+ if (key.ctrl && key.name === "c") return cleanup(null);
732
+ const name = key.name || char;
733
+ if (name === "escape" || name === "q") return cleanup(null);
734
+ if (state.phase === "confirm") {
735
+ if (name === "left" || name === "right" || name === "tab") state.confirmFocus = state.confirmFocus === "yes" ? "cancel" : "yes";
736
+ else if (name === "return" || name === "enter") return cleanup(state.confirmFocus === "yes" ? [...state.selected] : null);
737
+ renderSessionRemovalWizard(state);
738
+ return;
739
+ }
740
+ if (name === "up") moveCursor(-1);
741
+ else if (name === "down") moveCursor(1);
742
+ else if (name === "pageup") moveCursor(-8);
743
+ else if (name === "pagedown") moveCursor(8);
744
+ else if (name === "space") toggleCurrent();
745
+ else if (name === "tab") state.focus = state.focus === "list" ? "ok" : state.focus === "ok" ? "cancel" : "list";
746
+ else if (name === "left" || name === "right") state.focus = state.focus === "cancel" ? "ok" : "cancel";
747
+ else if (name === "return" || name === "enter") {
748
+ if (state.focus === "cancel") return cleanup(null);
749
+ openConfirm();
750
+ }
751
+ renderSessionRemovalWizard(state);
752
+ }
753
+ readlineRaw.emitKeypressEvents(input);
754
+ input.setRawMode(true);
755
+ input.resume();
756
+ output.write("\x1b[?1049h\x1b[?25l");
757
+ input.on("keypress", onKeypress);
758
+ renderSessionRemovalWizard(state);
759
+ });
760
+ }
761
+
762
+ function printRemovalPreview(candidates) {
763
+ for (const session of candidates) {
764
+ const title = session.title || session.goal || "(no title)";
765
+ const status = session.isEmpty ? "empty" : "work";
766
+ console.log(`${status.padEnd(5)} ${session.sessionId} ${session.updatedAt || ""} ${title.slice(0, 90)}`);
767
+ }
768
+ }
769
+
770
+ async function handleRemoveSessionsCommand({ emptyOnly = false } = {}) {
771
+ const candidates = await listProjectSessionRemovalCandidates(process.cwd(), {
772
+ limit: 1000,
773
+ emptyOnly,
774
+ });
775
+ if (candidates.length === 0) {
776
+ console.log(emptyOnly ? "No empty sessions found for this cwd." : "No sessions found for this cwd.");
777
+ return;
778
+ }
779
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
780
+ printRemovalPreview(candidates);
781
+ console.log("No sessions removed because this command needs an interactive terminal.");
782
+ return;
783
+ }
784
+ const defaultSelectedIds = emptyOnly ? candidates.map((session) => session.sessionId) : [];
785
+ const selected = await promptRemoveSessions(candidates, {
786
+ defaultSelectedIds,
787
+ title: emptyOnly ? "Remove empty AgInTiFlow sessions in this cwd" : "Remove AgInTiFlow sessions in this cwd",
788
+ subtitle: emptyOnly
789
+ ? "Only empty sessions are shown and selected by default."
790
+ : "All cwd sessions are shown; nothing is selected by default.",
791
+ });
792
+ if (!selected || selected.length === 0) {
793
+ console.log("No sessions removed.");
794
+ return;
795
+ }
796
+ const allowedIds = new Set(candidates.map((session) => session.sessionId));
797
+ const safeSelected = selected.filter((sessionId) => allowedIds.has(sessionId));
798
+ const result = await removeProjectSessions(process.cwd(), safeSelected);
799
+ console.log(`Removed ${result.removed.length} session(s) from this cwd:`);
800
+ for (const item of result.removed) console.log(`- ${item.sessionId}`);
801
+ }
802
+
603
803
  async function handleSessionsCommand(argv) {
604
804
  const normalizedArgv = argv[0] === "--all-sessions" ? ["list", ...argv] : argv;
605
805
  const [verb = "list", sessionId = "", ...rest] = normalizedArgv;
806
+ if (verb === "remove-empty" || verb === "delete-empty") {
807
+ await handleRemoveSessionsCommand({ emptyOnly: true });
808
+ return;
809
+ }
810
+ if (verb === "remove" || verb === "delete") {
811
+ await handleRemoveSessionsCommand({ emptyOnly: false });
812
+ return;
813
+ }
606
814
  if (verb === "list") {
607
815
  const allSessions = normalizedArgv.includes("--all-sessions");
608
816
  const sessions = await listProjectSessions(process.cwd(), { limit: 80, allSessions });
@@ -638,7 +846,7 @@ async function handleSessionsCommand(argv) {
638
846
  return;
639
847
  }
640
848
 
641
- console.error('Usage: aginti sessions list OR aginti sessions show <session-id> OR aginti sessions rename <session-id> "title"');
849
+ console.error('Usage: aginti sessions list OR aginti sessions show <session-id> OR aginti sessions rename <session-id> "title" OR aginti sessions remove-empty OR aginti sessions remove');
642
850
  process.exit(1);
643
851
  }
644
852
 
@@ -788,6 +996,16 @@ export async function main(argv = process.argv.slice(2)) {
788
996
  });
789
997
  if (autoUpdateResult.restarted) process.exit(autoUpdateResult.exitCode ?? 0);
790
998
 
999
+ if (argv.includes("--remove-empty-sessions") || argv[0] === "remove-empty-sessions") {
1000
+ await handleRemoveSessionsCommand({ emptyOnly: true });
1001
+ return;
1002
+ }
1003
+
1004
+ if (argv.includes("--remove-sessions") || argv[0] === "remove-sessions") {
1005
+ await handleRemoveSessionsCommand({ emptyOnly: false });
1006
+ return;
1007
+ }
1008
+
791
1009
  if (argv[0] === "init") {
792
1010
  printInitResult(await initProject(process.cwd()));
793
1011
  return;
package/src/project.js CHANGED
@@ -9,6 +9,7 @@ import { platformInfo, platformLabel, platformSetupHints } from "./platform.js";
9
9
  import {
10
10
  LEGACY_PROJECT_SESSIONS_DIR_NAME,
11
11
  PROJECT_SESSIONS_DIR_NAME,
12
+ deleteSessionIndex,
12
13
  globalSessionPaths,
13
14
  isSafeSessionId,
14
15
  listSessionIndex,
@@ -561,6 +562,118 @@ export async function listProjectSessions(projectRoot = process.cwd(), limitOrOp
561
562
  return sessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))).slice(0, options.limit);
562
563
  }
563
564
 
565
+ async function readJsonFile(filePath, fallback = {}) {
566
+ try {
567
+ return JSON.parse(await fsp.readFile(filePath, "utf8"));
568
+ } catch {
569
+ return fallback;
570
+ }
571
+ }
572
+
573
+ async function readJsonLines(filePath) {
574
+ try {
575
+ const raw = await fsp.readFile(filePath, "utf8");
576
+ return raw
577
+ .split("\n")
578
+ .map((line) => line.trim())
579
+ .filter(Boolean)
580
+ .map((line) => JSON.parse(line));
581
+ } catch {
582
+ return [];
583
+ }
584
+ }
585
+
586
+ async function countFilesRecursive(dirPath, limit = 200) {
587
+ let count = 0;
588
+ async function walk(current) {
589
+ if (count >= limit) return;
590
+ const entries = await fsp.readdir(current, { withFileTypes: true }).catch(() => []);
591
+ for (const entry of entries) {
592
+ if (count >= limit) return;
593
+ const child = path.join(current, entry.name);
594
+ if (entry.isDirectory()) await walk(child);
595
+ else if (entry.isFile()) count += 1;
596
+ }
597
+ }
598
+ await walk(dirPath);
599
+ return count;
600
+ }
601
+
602
+ function isMeaningfulSessionEvent(event = {}) {
603
+ const type = String(event.type || "");
604
+ if (!type) return false;
605
+ return /^(agent|assistant|browser|canvas|conversation|file|image|model|patch|plan|run|shell|tool|workspace|write)[.:_-]/.test(type);
606
+ }
607
+
608
+ export async function listProjectSessionRemovalCandidates(projectRoot = process.cwd(), options = {}) {
609
+ const paths = await ensureProjectSessionStorage(projectRoot);
610
+ const sessions = await listProjectSessions(projectRoot, {
611
+ limit: options.limit || 1000,
612
+ commandCwd: options.commandCwd,
613
+ allSessions: options.allSessions,
614
+ });
615
+ const candidates = [];
616
+ for (const session of sessions) {
617
+ const safeId = String(session.sessionId || "");
618
+ if (!isSafeSessionId(safeId)) continue;
619
+ const pointerPath = path.join(paths.sessionsDir, safeId, "session.json");
620
+ const pointer = await readJsonFile(pointerPath, {});
621
+ const sessionDir = session.sessionDir || pointer.sessionDir || path.join(paths.globalSessionsDir, safeId);
622
+ const state = await readJsonFile(path.join(sessionDir, "state.json"), {});
623
+ const events = await readJsonLines(path.join(sessionDir, "events.jsonl"));
624
+ const chat = Array.isArray(state.chat) ? state.chat : [];
625
+ const goal = String(state.goal || pointer.goal || session.goal || "").trim();
626
+ const title = String(state.title || pointer.title || session.title || "").trim();
627
+ const stepsCompleted = Number(state.stepsCompleted || 0);
628
+ const artifactsDir = state.artifactsDir || pointer.artifactsDir || path.join(sessionDir, "artifacts");
629
+ const artifactFileCount = await countFilesRecursive(artifactsDir);
630
+ const meaningfulEventCount = events.filter(isMeaningfulSessionEvent).length;
631
+ const isEmpty =
632
+ !goal &&
633
+ !title &&
634
+ chat.length === 0 &&
635
+ stepsCompleted === 0 &&
636
+ artifactFileCount === 0 &&
637
+ meaningfulEventCount === 0;
638
+ candidates.push({
639
+ ...session,
640
+ sessionId: safeId,
641
+ sessionDir,
642
+ pointerPath,
643
+ chatCount: chat.length,
644
+ eventCount: events.length,
645
+ meaningfulEventCount,
646
+ artifactFileCount,
647
+ stepsCompleted,
648
+ isEmpty,
649
+ });
650
+ }
651
+ return options.emptyOnly ? candidates.filter((session) => session.isEmpty) : candidates;
652
+ }
653
+
654
+ export async function removeProjectSessions(projectRoot = process.cwd(), sessionIds = []) {
655
+ const paths = await ensureProjectSessionStorage(projectRoot);
656
+ const removed = [];
657
+ for (const rawId of sessionIds) {
658
+ const safeId = String(rawId || "");
659
+ if (!isSafeSessionId(safeId)) throw new Error(`Invalid session id: ${rawId}`);
660
+ const pointerDir = path.join(paths.sessionsDir, safeId);
661
+ const pointer = await readJsonFile(path.join(pointerDir, "session.json"), {});
662
+ const sessionDir = pointer.sessionDir || path.join(paths.globalSessionsDir, safeId);
663
+ const legacyDir = path.join(paths.legacySessionsDir, safeId);
664
+ await fsp.rm(sessionDir, { recursive: true, force: true });
665
+ await fsp.rm(pointerDir, { recursive: true, force: true });
666
+ await fsp.rm(legacyDir, { recursive: true, force: true }).catch(() => {});
667
+ try {
668
+ deleteSessionIndex(safeId);
669
+ } catch {
670
+ // The on-disk session and pointer are already removed; stale index cleanup can be retried later.
671
+ }
672
+ removed.push({ sessionId: safeId, sessionDir, pointerDir });
673
+ }
674
+ return { ok: true, removed };
675
+ }
676
+
564
677
  export async function showProjectSession(projectRoot, sessionId) {
565
678
  const paths = await ensureProjectSessionStorage(projectRoot);
566
679
  const safeId = String(sessionId || "");