@cjhyy/code-shell-server 0.9.5 → 0.9.7

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.
@@ -7,6 +7,8 @@ export interface DesktopSessionSummary {
7
7
  id: string;
8
8
  /** Path to the session file on disk. */
9
9
  file: string;
10
+ /** Durable title/summary; UI-side renamed titles can override it. */
11
+ title?: string;
10
12
  size: number;
11
13
  createdAt: number;
12
14
  updatedAt: number;
@@ -35,8 +37,10 @@ export interface DiskSessionMeta {
35
37
  cwd: string;
36
38
  title: string;
37
39
  updatedAt: number;
38
- /** Session origin — only "desktop" / "automation" are listed (see filter). */
39
- origin: "desktop" | "automation";
40
+ /** Sub-agents are returned only by an explicit parentSessionId query. */
41
+ origin: "desktop" | "automation" | "subagent";
42
+ /** Present only when listing the direct children of a session. */
43
+ parentSessionId?: string;
40
44
  /** Normalized durable status for Pet/display consumers. */
41
45
  status?: "active" | "paused" | "completed" | "failed" | "cancelled";
42
46
  /** Exceptional completed-run boundary; absent means a normal final answer. */
@@ -51,16 +55,18 @@ export interface ListDiskSessionsResult {
51
55
  nextCursor: string | null;
52
56
  }
53
57
  /**
54
- * List top-level (non-sub-agent) sessions from disk, newest first, paginated.
58
+ * List sessions from disk, newest first, paginated. The default catalog is
59
+ * top-level only; parentSessionId selects direct children for transcript views.
55
60
  * Filter on state.json `parentSessionId`:
56
61
  * - key absent → legacy → skip (存量 not auto-rebuilt)
57
- * - null / "" → top-level → show
58
- * - non-empty → sub-agent → filter out
62
+ * - null / "" → top-level → show only in the default catalog
63
+ * - non-empty → sub-agent → show only for the matching parent query
59
64
  */
60
65
  export declare function listDiskSessions(opts: {
61
66
  limit: number;
62
67
  cursor?: string;
63
68
  includeArchived?: boolean;
69
+ parentSessionId?: string;
64
70
  }, baseDir?: string): Promise<ListDiskSessionsResult>;
65
71
  /**
66
72
  * Set (number) or clear (undefined) a session's durable archival marker on
@@ -8,6 +8,7 @@ import * as path from "node:path";
8
8
  const SAFE_ID = /^[A-Za-z0-9_.-]+$/;
9
9
  const QUICK_CHAT_SESSION_PREFIX = "qchat-";
10
10
  const PANEL_TASK_SESSION_PREFIX = "panel-task-";
11
+ const MAX_HISTORY_STATE_BYTES = 2 * 1024 * 1024;
11
12
  function isQuickChatSessionId(id) {
12
13
  return id.startsWith(QUICK_CHAT_SESSION_PREFIX);
13
14
  }
@@ -17,32 +18,17 @@ function isInternalEphemeralSessionId(id) {
17
18
  export async function listSessions(baseDir = sessionsRoot()) {
18
19
  try {
19
20
  const entries = await fs.readdir(baseDir, { withFileTypes: true });
20
- const summaries = [];
21
- for (const e of entries) {
22
- if (!e.isFile())
23
- continue;
24
- if (!e.name.endsWith(".jsonl") && !e.name.endsWith(".json"))
25
- continue;
26
- const id = e.name.replace(/\.jsonl?$/, "");
27
- if (isInternalEphemeralSessionId(id))
28
- continue;
29
- const full = path.join(baseDir, e.name);
30
- try {
31
- const st = await fs.stat(full);
32
- summaries.push({
33
- id,
34
- file: full,
35
- size: st.size,
36
- createdAt: st.birthtimeMs || st.mtimeMs,
37
- updatedAt: st.mtimeMs,
38
- });
39
- }
40
- catch {
41
- // Skip files we cannot stat (race with delete, permissions).
21
+ // Prefer the current directory layout when a migration left a flat copy.
22
+ const candidates = entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()));
23
+ const summaries = new Map();
24
+ for (let offset = 0; offset < candidates.length; offset += 32) {
25
+ const batch = await Promise.all(candidates.slice(offset, offset + 32).map((entry) => readHistorySummary(entry, baseDir)));
26
+ for (const summary of batch) {
27
+ if (summary && !summaries.has(summary.id))
28
+ summaries.set(summary.id, summary);
42
29
  }
43
30
  }
44
- summaries.sort((a, b) => b.updatedAt - a.updatedAt);
45
- return summaries;
31
+ return [...summaries.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
46
32
  }
47
33
  catch (e) {
48
34
  if (e.code === "ENOENT")
@@ -50,6 +36,66 @@ export async function listSessions(baseDir = sessionsRoot()) {
50
36
  throw e;
51
37
  }
52
38
  }
39
+ async function readHistorySummary(entry, baseDir) {
40
+ const directory = entry.isDirectory();
41
+ if (!directory && (!entry.isFile() || !/\.jsonl?$/.test(entry.name)))
42
+ return null;
43
+ const id = directory ? entry.name : entry.name.replace(/\.jsonl?$/, "");
44
+ if (!SAFE_ID.test(id) ||
45
+ id.startsWith(".") ||
46
+ id.includes("..") ||
47
+ id.length > 128 ||
48
+ isInternalEphemeralSessionId(id))
49
+ return null;
50
+ try {
51
+ const file = path.join(baseDir, entry.name, ...(directory ? ["state.json"] : []));
52
+ const info = await fs.lstat(file);
53
+ if (!info.isFile() || info.isSymbolicLink())
54
+ return null;
55
+ const summary = {
56
+ id,
57
+ file,
58
+ size: info.size,
59
+ createdAt: info.birthtimeMs || info.mtimeMs,
60
+ updatedAt: info.mtimeMs,
61
+ };
62
+ if (!directory)
63
+ return summary;
64
+ if (info.size > MAX_HISTORY_STATE_BYTES)
65
+ return null;
66
+ const state = JSON.parse(await fs.readFile(file, "utf8"));
67
+ if (!state || typeof state !== "object" || Array.isArray(state))
68
+ return null;
69
+ // History includes archived/legacy sessions, but internal conversations
70
+ // and temporary forks must stay out of the user-facing catalog.
71
+ if (state.ephemeral === true || state.kind === "pet" || state.parentSessionId)
72
+ return null;
73
+ const title = typeof state.title === "string" && state.title ? state.title : state.summary;
74
+ if (typeof title === "string" && title)
75
+ summary.title = title.slice(0, 1024);
76
+ if (typeof state.startedAt === "number" && Number.isFinite(new Date(state.startedAt).getTime()))
77
+ summary.createdAt = state.startedAt;
78
+ const transcript = path.join(baseDir, id, "transcript.jsonl");
79
+ try {
80
+ const transcriptInfo = await fs.lstat(transcript);
81
+ if (!transcriptInfo.isFile() || transcriptInfo.isSymbolicLink())
82
+ return null;
83
+ summary.file = transcript;
84
+ summary.size += transcriptInfo.size;
85
+ summary.updatedAt = Math.max(info.mtimeMs, transcriptInfo.mtimeMs);
86
+ }
87
+ catch (error) {
88
+ if (error.code !== "ENOENT")
89
+ throw error;
90
+ // A newly persisted session may not have a transcript yet.
91
+ }
92
+ return summary;
93
+ }
94
+ catch {
95
+ // One corrupt/unreadable session or a concurrent delete cannot hide the rest.
96
+ return null;
97
+ }
98
+ }
53
99
  /**
54
100
  * Remove a session's on-disk data. Modern sessions are a directory
55
101
  * `<sessionsDir>/<id>/` (transcript.jsonl + state.json — see core
@@ -147,13 +193,23 @@ function decodeDiskSessionCursor(value) {
147
193
  return Number.isFinite(mtime) ? { mtime, id: match[2] } : null;
148
194
  }
149
195
  /**
150
- * List top-level (non-sub-agent) sessions from disk, newest first, paginated.
196
+ * List sessions from disk, newest first, paginated. The default catalog is
197
+ * top-level only; parentSessionId selects direct children for transcript views.
151
198
  * Filter on state.json `parentSessionId`:
152
199
  * - key absent → legacy → skip (存量 not auto-rebuilt)
153
- * - null / "" → top-level → show
154
- * - non-empty → sub-agent → filter out
200
+ * - null / "" → top-level → show only in the default catalog
201
+ * - non-empty → sub-agent → show only for the matching parent query
155
202
  */
156
203
  export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
204
+ const parentSessionId = opts.parentSessionId;
205
+ if (parentSessionId !== undefined &&
206
+ (typeof parentSessionId !== "string" ||
207
+ !SAFE_ID.test(parentSessionId) ||
208
+ parentSessionId.length > 128 ||
209
+ parentSessionId === "." ||
210
+ parentSessionId.includes(".."))) {
211
+ throw new Error("invalid parent session id");
212
+ }
157
213
  // Async fs throughout: this is an IPC handler on the Electron main thread
158
214
  // (sessions:listDisk). The previous synchronous statSync/readFileSync loops
159
215
  // never yielded the event loop and froze the UI while enumerating sessions.
@@ -219,13 +275,22 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
219
275
  continue;
220
276
  if (!("parentSessionId" in state))
221
277
  continue; // legacy → skip
222
- if (state.parentSessionId)
223
- continue; // sub-agent (non-empty) → filter
224
- // Only desktop + automation belong in the desktop sidebar. tui / missing
225
- // origin are filtered out (tui shares ~/.code-shell; legacy has no origin).
278
+ if (parentSessionId !== undefined) {
279
+ if (state.parentSessionId !== parentSessionId)
280
+ continue;
281
+ }
282
+ else if (state.parentSessionId) {
283
+ continue;
284
+ }
285
+ // Only desktop + automation belong in the desktop sidebar. Targeted
286
+ // queries additionally expose subagent origin. tui / missing origins stay
287
+ // filtered out (tui shares ~/.code-shell; legacy has no origin).
226
288
  const origin = state.origin;
227
- if (origin !== "desktop" && origin !== "automation")
289
+ if (origin !== "desktop" &&
290
+ origin !== "automation" &&
291
+ !(parentSessionId !== undefined && origin === "subagent")) {
228
292
  continue;
293
+ }
229
294
  // Skip sessions whose project root has been deleted. Listing them led the
230
295
  // renderer's disk-rebuild to call createRepoForCwd → the deleted project
231
296
  // reappeared in the sidebar, and any subsequent write under that cwd
@@ -233,7 +298,9 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
233
298
  // is intentionally NOT filtered here — the renderer handles that via
234
299
  // isNoRepoCwd. (deleted-project resurrection)
235
300
  const cwdStr = typeof state.cwd === "string" ? state.cwd : "";
236
- if (cwdStr && !(await pathExists(cwdStr)))
301
+ // A child transcript remains useful after its temporary worktree is gone;
302
+ // targeted reads do not rebuild projects in the sidebar.
303
+ if (parentSessionId === undefined && cwdStr && !(await pathExists(cwdStr)))
237
304
  continue;
238
305
  // Archived sessions are hidden from the default catalog. Filtering happens
239
306
  // before the push (like every other skip above), so the mtime cursor —
@@ -254,6 +321,7 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
254
321
  (typeof state.summary === "string" && state.summary ? state.summary : id),
255
322
  updatedAt: mtime,
256
323
  origin,
324
+ ...(parentSessionId !== undefined ? { parentSessionId } : {}),
257
325
  status: state.status === "active" || state.status === "paused" || state.status === "completed"
258
326
  ? state.status
259
327
  : state.status === "aborted_streaming" || state.status === "aborted_tools"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -39,9 +39,9 @@
39
39
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
40
40
  },
41
41
  "dependencies": {
42
- "@cjhyy/code-shell-capability-coding": "0.9.5",
43
- "@cjhyy/code-shell-core": "0.9.5",
44
- "@cjhyy/code-shell-web": "0.9.5",
42
+ "@cjhyy/code-shell-capability-coding": "0.9.7",
43
+ "@cjhyy/code-shell-core": "0.9.7",
44
+ "@cjhyy/code-shell-web": "0.9.7",
45
45
  "@types/ws": "^8.18.1",
46
46
  "ws": "^8.21.0"
47
47
  },