From c380113e26f9819b92f3ad207fa64e788ff982e5 Mon Sep 17 00:00:00 2001
From: Yaowei Zheng
Date: Thu, 23 Jul 2026 00:58:43 +0800
Subject: [PATCH] feat: session source in session_meta and an Automated sidebar
folder (#40)
Co-authored-by: Alice
Co-authored-by: Claude Fable 5
---
packages/core/src/agent.ts | 10 +
packages/core/src/omnimessage/types.ts | 2 +
packages/core/test/agent.test.ts | 37 +++-
packages/core/test/resume.test.ts | 45 ++++-
packages/docs/content/omni-message.en.md | 1 +
packages/docs/content/omni-message.zh.md | 1 +
packages/server/src/api/types.ts | 2 +-
packages/server/src/app.ts | 12 +-
packages/server/src/db/repos/sessions.ts | 15 +-
packages/server/src/db/schema.ts | 3 +-
packages/server/src/http/routes/sessions.ts | 19 +-
packages/server/src/http/validate.ts | 22 +++
.../server/src/runtime/session-manager.ts | 25 ++-
.../server/src/runtime/session-sources.ts | 41 +++++
.../server/src/services/session-service.ts | 96 ++++++++--
packages/server/test/scheduler.test.ts | 5 +-
packages/server/test/session-index.test.ts | 134 ++++++++++++++
packages/server/test/session-loader.test.ts | 35 ++++
packages/server/test/session-manager.test.ts | 13 ++
packages/web/e2e/paging.spec.mjs | 66 +++++++
packages/web/e2e/subagent.spec.mjs | 34 +++-
packages/web/src/api/endpoints.ts | 11 +-
.../web/src/components/layout/sidebar.tsx | 173 +++++++++++++++---
packages/web/src/features/chat/chat-page.tsx | 25 ++-
.../src/features/traces/trace-event-row.tsx | 2 +
packages/web/src/lib/session-grouping.ts | 66 +++++++
packages/web/src/lib/strings-en.ts | 7 +
packages/web/src/lib/strings.ts | 7 +
packages/web/src/state/sessions.tsx | 104 ++++++++++-
packages/web/test/session-grouping.test.ts | 84 ++++++++-
30 files changed, 1023 insertions(+), 74 deletions(-)
create mode 100644 packages/server/src/runtime/session-sources.ts
create mode 100644 packages/web/e2e/paging.spec.mjs
diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts
index 6b9a6d8..72f9f9c 100644
--- a/packages/core/src/agent.ts
+++ b/packages/core/src/agent.ts
@@ -126,6 +126,8 @@ export interface CreateSessionOptions {
baseUrl?: string;
/** Internal use: this Session's depth in the subagent spawn chain (0 at the top level), used to cap spawn depth. */
subagentDepth?: number;
+ /** Session origin recorded in session_meta (absent = user-created); the subagent spawn site passes "subagent", callers driven by a scheduled task pass "schedule". */
+ source?: "subagent" | "schedule";
}
export interface ResumeSessionOptions {
@@ -298,6 +300,7 @@ export class Agent {
thinking_level: thinkingLevel ?? "default",
agent_state: this.state.stateDir,
workspace: workspaceDir,
+ ...(opts.source !== undefined ? { source: opts.source } : {}),
},
llm: rt.llm,
environment: rt.environment,
@@ -444,6 +447,12 @@ export class Agent {
thinking_level: thinkingLevel ?? "default",
agent_state: this.state.stateDir,
workspace: workspaceDir,
+ // The origin carries over from the original session_meta (a resumed scheduled/subagent
+ // Session stays marked). The on-disk value is untrusted: only the exact known origins
+ // pass; junk written by a third party is dropped rather than cast through.
+ ...(meta.source === "subagent" || meta.source === "schedule"
+ ? { source: meta.source }
+ : {}),
},
llm: rt.llm,
environment: rt.environment,
@@ -573,6 +582,7 @@ export class Agent {
...childModel,
thinkingLevel: thinkingLevel ?? null,
subagentDepth: subagentDepth + 1,
+ source: "subagent",
});
// All child-session messages are tagged with an origin (the child Session id,
// prepended as one hop from outer to inner); the first turn forwards the
diff --git a/packages/core/src/omnimessage/types.ts b/packages/core/src/omnimessage/types.ts
index 7cdd613..bf0943e 100644
--- a/packages/core/src/omnimessage/types.ts
+++ b/packages/core/src/omnimessage/types.ts
@@ -92,6 +92,8 @@ export interface SessionMetaPayload {
agent_state: string;
/** Absolute path to the Workspace. */
workspace: string;
+ /** Session origin: spawned by a subagent / triggered by a scheduled task; absent = user-created. */
+ source?: "subagent" | "schedule";
}
// ---------------------------------------------------------------------------
diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts
index 8cc5c7c..6852087 100644
--- a/packages/core/test/agent.test.ts
+++ b/packages/core/test/agent.test.ts
@@ -220,6 +220,31 @@ describe("Agent.createSession model reference ((provider, model_id) pair)", () =
});
});
+describe("Agent.createSession session source (session_meta origin marker)", () => {
+ it("records the given source in session_meta; a user-created session carries no source key", async () => {
+ const agent = await createAgent();
+ const ws = path.join(tmpRoot, "ws-source");
+ await fs.mkdir(ws, { recursive: true });
+
+ const scheduled = await agent.createSession({ workspaceDir: ws, source: "schedule" });
+ try {
+ expect((scheduled.metaMessage.payload as { source?: string }).source).toBe("schedule");
+ } finally {
+ scheduled.dispose();
+ }
+
+ // Absent = user-created: the key must not appear at all (Trace consumers treat absence as the default).
+ const plain = await agent.createSession({ workspaceDir: ws });
+ try {
+ expect("source" in (plain.metaMessage.payload as unknown as Record)).toBe(
+ false,
+ );
+ } finally {
+ plain.dispose();
+ }
+ });
+});
+
describe("Agent.createSession thinking level (explicit option wins over the Agent config)", () => {
const uniThinkingOf = (llm: unknown): unknown =>
((llm as { uniConfig?: { thinking_level?: unknown } }).uniConfig ?? {}).thinking_level;
@@ -276,7 +301,13 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
async function spawnedChildMeta(
runner: SubagentRunner,
input: Parameters[0],
- ): Promise<{ provider: string; model_id: string; thinking_level: string; workspace: string }> {
+ ): Promise<{
+ provider: string;
+ model_id: string;
+ thinking_level: string;
+ workspace: string;
+ source?: string;
+ }> {
const handle = await runner.spawn(input);
try {
const gen = handle.run({ prompt: "noop" });
@@ -292,6 +323,7 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
model_id: string;
thinking_level: string;
workspace: string;
+ source?: string;
};
} finally {
handle.dispose();
@@ -320,6 +352,9 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
expect(child.thinking_level).toBe("high");
// Workspace inheritance (behavior that predates model/thinking inheritance): locked here.
expect(child.workspace).toBe(ws);
+ // The spawn site marks the child's own session_meta as subagent-created — the single
+ // source of truth the server derives from (its registration fallback cannot mask this).
+ expect(child.source).toBe("subagent");
} finally {
parent.dispose();
}
diff --git a/packages/core/test/resume.test.ts b/packages/core/test/resume.test.ts
index f8fc271..72a8eaa 100644
--- a/packages/core/test/resume.test.ts
+++ b/packages/core/test/resume.test.ts
@@ -77,7 +77,12 @@ async function writeTraceFile(
return file;
}
-function metaFor(sessionId: string, workspaceDir: string, model = MODEL): OmniMessage {
+function metaFor(
+ sessionId: string,
+ workspaceDir: string,
+ model = MODEL,
+ source?: "subagent" | "schedule",
+): OmniMessage {
return sessionMeta({
session_id: sessionId,
provider: model.provider,
@@ -88,6 +93,7 @@ function metaFor(sessionId: string, workspaceDir: string, model = MODEL): OmniMe
thinking_level: "default",
agent_state: "/agent/state",
workspace: workspaceDir,
+ ...(source !== undefined ? { source } : {}),
});
}
@@ -116,6 +122,43 @@ describe("agent.resumeSession", () => {
expect(texts).toEqual(["hello", "hi there"]);
});
+ it("preserves the stored session source across resume (and its absence for user sessions)", async () => {
+ const agent = await createAgent({});
+ await writeTraceFile(tmpRoot, SID, [
+ metaFor(SID, workspace, MODEL, "subagent"),
+ userText("child task"),
+ requestBegin(),
+ assistantText("done"),
+ requestEnd("completed"),
+ ]);
+ const session = await agent.resumeSession({ sessionId: SID });
+ // The rebuilt session_meta carries the origin over (a rotated Trace file must re-record it).
+ expect((session.metaMessage.payload as { source?: string }).source).toBe("subagent");
+ session.dispose();
+
+ // A user-created session's meta has no source key, and resume must not invent one.
+ const SID2 = "session-2026-07-06-11-00-00-abcdef02";
+ await writeTraceFile(tmpRoot, SID2, [metaFor(SID2, workspace), userText("hi")]);
+ const plain = await agent.resumeSession({ sessionId: SID2 });
+ expect("source" in (plain.metaMessage.payload as unknown as Record)).toBe(
+ false,
+ );
+ plain.dispose();
+
+ // On-disk values are untrusted: a junk source written by a third party is dropped on
+ // resume (only the exact known origins pass), not cast through into the rebuilt meta.
+ const SID3 = "session-2026-07-06-12-00-00-abcdef03";
+ await writeTraceFile(tmpRoot, SID3, [
+ metaFor(SID3, workspace, MODEL, "weird-origin" as unknown as "subagent"),
+ userText("hi"),
+ ]);
+ const junk = await agent.resumeSession({ sessionId: SID3 });
+ expect("source" in (junk.metaMessage.payload as unknown as Record)).toBe(
+ false,
+ );
+ junk.dispose();
+ });
+
it("keeps abort events in resumed render history", async () => {
const agent = await createAgent({});
await writeTraceFile(tmpRoot, SID, [
diff --git a/packages/docs/content/omni-message.en.md b/packages/docs/content/omni-message.en.md
index 83d7194..ffb0248 100644
--- a/packages/docs/content/omni-message.en.md
+++ b/packages/docs/content/omni-message.en.md
@@ -41,6 +41,7 @@ interface SessionMetaPayload {
thinking_level: string; // "default" when unconfigured
agent_state: string; // absolute path of the Agent State
workspace: string; // absolute path of the Workspace
+ source?: "subagent" | "schedule"; // session origin; absent = user-created
}
interface ToolDefinition {
diff --git a/packages/docs/content/omni-message.zh.md b/packages/docs/content/omni-message.zh.md
index 53e538b..9e1e6ec 100644
--- a/packages/docs/content/omni-message.zh.md
+++ b/packages/docs/content/omni-message.zh.md
@@ -41,6 +41,7 @@ interface SessionMetaPayload {
thinking_level: string; // 未配置时为 "default"
agent_state: string; // Agent State 绝对路径
workspace: string; // Workspace 绝对路径
+ source?: "subagent" | "schedule"; // Session 来源;缺省 = 用户创建
}
interface ToolDefinition {
diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts
index 710be5d..fca96cd 100644
--- a/packages/server/src/api/types.ts
+++ b/packages/server/src/api/types.ts
@@ -454,7 +454,7 @@ export interface SessionInfo {
approvalMode: ApprovalMode;
/** Short title auto-generated by the model after the first turn; unset until generated (frontend shows "New Chat"). */
title?: string;
- /** Session source (for list badges); unset for user-created sessions. */
+ /** Session source (for list badges/folders), derived from core session_meta — the single source of truth (not stored in the DB); unset for user-created sessions. */
source?: SessionSource;
createdAt: string;
status: SessionStatus;
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 7188b51..5161987 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -50,6 +50,7 @@ import { agentSessionsRoutes, sessionsRoutes } from "./http/routes/sessions.js";
import { ChannelHub } from "./runtime/channel.js";
import { ErrorRecorder } from "./runtime/error-recorder.js";
import { createCoreSessionLoader, SessionManager } from "./runtime/session-manager.js";
+import { SessionSources } from "./runtime/session-sources.js";
import type { SessionLoader } from "./runtime/session-manager.js";
import { Scheduler } from "./runtime/scheduler.js";
import { TitleGenerator } from "./runtime/title-generator.js";
@@ -91,6 +92,8 @@ export interface AppDeps {
scheduler: Scheduler;
channels: ChannelHub;
manager: SessionManager;
+ /** Session-origin registry derived from session_meta (single source of truth; no DB column). */
+ sessionSources: SessionSources;
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
errors: ErrorRecorder;
/** Request log output (minimal one-liner); tests inject a noop. */
@@ -149,10 +152,15 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const titles =
overrides.titles ??
new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log });
+ // Session-origin registry: session_meta is the single source of truth (no DB column);
+ // shared by the manager (subagent registration), the loader (self-heal rebuild) and
+ // SessionService (creation / adoption / lazy list resolution).
+ const sessionSources = new SessionSources();
const manager = new SessionManager({
sessions: sessionsRepo,
channels,
- loader: overrides.loader ?? createCoreSessionLoader(config.root),
+ loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources),
+ sources: sessionSources,
recorder,
errors,
titles,
@@ -194,6 +202,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
sessions: sessionsRepo,
manager,
projectConfig: projectConfigService,
+ sources: sessionSources,
});
// Schedule scheduler: active only while the server is running. Only
// assembled here; start() is called in index.ts (tests drive it via tickOnce, no real timer).
@@ -232,6 +241,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
scheduler,
channels,
manager,
+ sessionSources,
errors,
log,
};
diff --git a/packages/server/src/db/repos/sessions.ts b/packages/server/src/db/repos/sessions.ts
index 6980a41..522b4c9 100644
--- a/packages/server/src/db/repos/sessions.ts
+++ b/packages/server/src/db/repos/sessions.ts
@@ -19,8 +19,8 @@ export interface SessionRow {
title: string | null;
/** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */
archivedAt?: string | null;
- /** Session origin: NULL = user-created; schedule = triggered by a Schedule; subagent = registered as a subagent session. */
- source?: "schedule" | "subagent" | null;
+ // The Session origin (schedule / subagent) is deliberately NOT a row field: core
+ // session_meta in the Trace is the single source of truth (runtime/session-sources.ts).
createdAt: string;
}
@@ -35,7 +35,6 @@ function mapRow(r: Record): SessionRow {
approvalMode: r.approval_mode as ApprovalMode,
title: (r.title as string | null) ?? null,
archivedAt: (r.archived_at as string | null) ?? null,
- source: (r.source as "schedule" | "subagent" | null) ?? null,
createdAt: r.created_at as string,
};
}
@@ -46,8 +45,8 @@ export class SessionsRepo {
insert(row: SessionRow): void {
this.db
.prepare(
- `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
row.sessionId,
@@ -58,7 +57,6 @@ export class SessionsRepo {
row.workspace,
row.approvalMode,
row.title,
- row.source ?? null,
row.createdAt,
);
}
@@ -67,8 +65,8 @@ export class SessionsRepo {
insertOrIgnore(row: SessionRow): void {
this.db
.prepare(
- `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
row.sessionId,
@@ -79,7 +77,6 @@ export class SessionsRepo {
row.workspace,
row.approvalMode,
row.title,
- row.source ?? null,
row.createdAt,
);
}
diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts
index b7304c8..d5ec503 100644
--- a/packages/server/src/db/schema.ts
+++ b/packages/server/src/db/schema.ts
@@ -48,9 +48,8 @@ CREATE TABLE IF NOT EXISTS sessions (
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
- source TEXT, -- session origin: NULL=user-created | schedule (scheduled task) | subagent (child session)
created_at TEXT NOT NULL
-);
+); -- session origin is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts)
CREATE TABLE IF NOT EXISTS usage_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
diff --git a/packages/server/src/http/routes/sessions.ts b/packages/server/src/http/routes/sessions.ts
index 659e614..2df287f 100644
--- a/packages/server/src/http/routes/sessions.ts
+++ b/packages/server/src/http/routes/sessions.ts
@@ -30,6 +30,7 @@ import { sseEndpoint } from "../sse.js";
import {
badRequest,
optionalEnum,
+ optionalPagingQuery,
optionalString,
paginationQuery,
pathParam,
@@ -96,7 +97,14 @@ export function agentSessionsRoutes(deps: AppDeps): Hono {
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
- const sessions = await deps.sessionService.listSessions(projectId, agentId);
+ // Optional paging (absent = full list, the pre-paging contract): the sidebar requests
+ // limit+1 and shows limit, detecting "has more" without a response-envelope change.
+ const paging = optionalPagingQuery(c);
+ const sessions = await deps.sessionService.listSessions(
+ projectId,
+ agentId,
+ ...(paging ? [paging] : []),
+ );
return c.json({ sessions } satisfies SessionsResponse);
});
@@ -167,7 +175,9 @@ export function sessionsRoutes(deps: AppDeps): Hono {
app.get("/:sessionId", async (c) => {
const row = resolveSession(c);
const hasTrace = await deps.sessionService.hasTrace(row);
- return c.json({ session: deps.sessionService.toInfo(row, hasTrace) } satisfies SessionResponse);
+ return c.json({
+ session: await deps.sessionService.toInfo(row, hasTrace),
+ } satisfies SessionResponse);
});
app.patch("/:sessionId", async (c) => {
@@ -216,7 +226,7 @@ export function sessionsRoutes(deps: AppDeps): Hono {
}
const hasTrace = await deps.sessionService.hasTrace(updated);
return c.json({
- session: deps.sessionService.toInfo(updated, hasTrace),
+ session: await deps.sessionService.toInfo(updated, hasTrace),
} satisfies SessionResponse);
});
@@ -244,6 +254,9 @@ export function sessionsRoutes(deps: AppDeps): Hono {
{ recursive: true, force: true },
);
deps.sessionsRepo.deleteById(row.sessionId);
+ // Drop the derived-origin entry along with the Session (bulk Agent/Project deletion
+ // may leave stale entries; session ids are never reused, so they are never matched).
+ deps.sessionSources.delete(row.sessionId);
} finally {
deps.manager.endSessionDeletion(row.sessionId);
}
diff --git a/packages/server/src/http/validate.ts b/packages/server/src/http/validate.ts
index 0d7a048..5dd7838 100644
--- a/packages/server/src/http/validate.ts
+++ b/packages/server/src/http/validate.ts
@@ -64,6 +64,28 @@ export function paginationQuery(c: Context): { offset: number; limit: number } {
return { offset, limit };
}
+/**
+ * Parse OPTIONAL list paging query params: both absent = null (caller returns the full
+ * list — the pre-paging behavior stays intact for existing callers). When paging, `limit`
+ * is required (1-1000) and `offset` optional (>= 0, default 0) — an offset alone would
+ * silently return the full list shifted, which no caller ever means.
+ */
+export function optionalPagingQuery(c: Context): { offset: number; limit: number } | null {
+ const rawLimit = c.req.query("limit");
+ const rawOffset = c.req.query("offset");
+ if (rawLimit === undefined && rawOffset === undefined) return null;
+ if (rawLimit === undefined) throw badRequest("offset requires limit.");
+ const limit = Number.parseInt(rawLimit, 10);
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
+ throw badRequest("limit must be an integer between 1 and 1000.");
+ }
+ const offset = Number.parseInt(rawOffset ?? "0", 10);
+ if (!Number.isInteger(offset) || offset < 0) {
+ throw badRequest("offset must be a non-negative integer.");
+ }
+ return { offset, limit };
+}
+
/** Read the JSON request body (parse failure / non-object -> 400). */
export async function readJson(c: Context): Promise> {
let body: unknown;
diff --git a/packages/server/src/runtime/session-manager.ts b/packages/server/src/runtime/session-manager.ts
index 7bb79a3..5b6ba23 100644
--- a/packages/server/src/runtime/session-manager.ts
+++ b/packages/server/src/runtime/session-manager.ts
@@ -48,6 +48,8 @@ import { ApprovalRegistry, makeApprove } from "./approvals.js";
import type { PendingApproval } from "./approvals.js";
import type { ChannelHub } from "./channel.js";
import type { ErrorSink } from "./error-recorder.js";
+import { asSessionSource } from "./session-sources.js";
+import type { SessionSources } from "./session-sources.js";
import { StreamErrorWatcher } from "./stream-error-watcher.js";
import type { TitleNotifier } from "./title-generator.js";
import type { UsageContext } from "./usage-recorder.js";
@@ -96,8 +98,13 @@ export interface SessionLoader {
load(row: SessionRow): Promise;
}
-/** Production loader: the core SDK's resumeSession / createSession. */
-export function createCoreSessionLoader(root: string): SessionLoader {
+/**
+ * Production loader: the core SDK's resumeSession / createSession. `sources` (when given)
+ * lets the no-Trace self-heal rebuild re-record a known origin into the fresh session_meta;
+ * with no registry entry (e.g. the process restarted and no Trace was ever written) the
+ * rebuilt Session is unsourced — session_meta is the single source of truth, and none survived.
+ */
+export function createCoreSessionLoader(root: string, sources?: SessionSources): SessionLoader {
return {
async load(row: SessionRow): Promise {
const agent = await createAgent({
@@ -136,11 +143,14 @@ export function createCoreSessionLoader(root: string): SessionLoader {
`This Session's Workspace no longer exists: ${row.workspace}, so it cannot continue. Create a new Session.`,
);
}
+ const knownSource = sources?.get(row.sessionId);
try {
return await agent.createSession({
workspaceDir: row.workspace,
modelId: row.modelId,
provider: row.provider,
+ // The rebuilt Session re-records a known origin in its fresh session_meta.
+ ...(knownSource != null ? { source: knownSource } : {}),
});
} catch (err) {
if (isMissingCredential(err)) throw modelCredentialMissing(row.modelId);
@@ -168,6 +178,8 @@ export interface SessionManagerDeps {
sessions: SessionsRepo;
channels: ChannelHub;
loader: SessionLoader;
+ /** Session-origin registry (session_meta is the single source of truth; subagent registration records the forwarded meta's source here). */
+ sources: SessionSources;
recorder: UsageRecorderLike;
/** Automatic Session title generation (optional: not injected in tests or when disabled). */
titles?: TitleNotifier;
@@ -879,6 +891,12 @@ export class SessionManager {
const p = msg.payload as SessionMetaPayload;
const agentId = path.basename(path.dirname(p.agent_state));
if (!agentId || agentId === "." || agentId === "..") return null;
+ // The forwarded session_meta records the origin at the source (core's spawn site); fall
+ // back to inferring "subagent" from the registration path for older metas (narrowed —
+ // a junk value also falls back). It goes into the in-process registry only — the index
+ // row deliberately stores no source column.
+ const source = asSessionSource(p.source) ?? "subagent";
+ this.deps.sources.set(childSid, source);
this.deps.sessions.insertOrIgnore({
sessionId: childSid,
projectId: entry.projectId,
@@ -890,7 +908,6 @@ export class SessionManager {
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
approvalMode: "allow-all",
title: null,
- source: "subagent",
createdAt: new Date().toISOString(),
});
// Make the subagent appear immediately in the sidebar: notify via the parent
@@ -900,7 +917,7 @@ export class SessionManager {
projectId: entry.projectId,
agentId,
sessionId: childSid,
- source: "subagent",
+ source,
});
const child: ChildSession = {
sessionId: childSid,
diff --git a/packages/server/src/runtime/session-sources.ts b/packages/server/src/runtime/session-sources.ts
new file mode 100644
index 0000000..3dbae53
--- /dev/null
+++ b/packages/server/src/runtime/session-sources.ts
@@ -0,0 +1,41 @@
+/**
+ * In-process registry of Session origins, derived from core `session_meta` — the single
+ * source of truth for a Session's origin (the DB stores no `source` column).
+ *
+ * Populated wherever the server actually has the meta in hand: Session creation
+ * (SessionService reads the just-created core Session's meta), subagent registration
+ * (SessionManager reads the forwarded child meta), and Trace adoption / lazy list
+ * resolution (SessionService reads the Trace head's session_meta). `null` records a
+ * **known** user-created Session (meta seen, no source) so the Trace is not re-read on
+ * every list; an absent entry means "unknown" and the list path resolves it from the
+ * Trace once per process lifetime.
+ */
+import type { SessionSource } from "../api/types.js";
+
+/**
+ * Narrows an untrusted value (on-disk Trace JSON / forwarded meta) to a SessionSource:
+ * only the exact known origins pass; anything else — including junk written by third
+ * parties — is treated as absent rather than cast through.
+ */
+export function asSessionSource(v: unknown): SessionSource | undefined {
+ return v === "schedule" || v === "subagent" ? v : undefined;
+}
+
+export class SessionSources {
+ private readonly map = new Map();
+
+ /** Records a Session's origin as read from session_meta (`null` = meta seen, user-created). */
+ set(sessionId: string, source: SessionSource | null): void {
+ this.map.set(sessionId, source);
+ }
+
+ /** Known origin, `null` for a known user-created Session, `undefined` when this process has not seen the meta. */
+ get(sessionId: string): SessionSource | null | undefined {
+ return this.map.get(sessionId);
+ }
+
+ /** Drops a deleted Session's entry (bulk Agent/Project deletion may leave stale entries; they are never matched again). */
+ delete(sessionId: string): void {
+ this.map.delete(sessionId);
+ }
+}
diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts
index 36deabf..6936d8e 100644
--- a/packages/server/src/services/session-service.ts
+++ b/packages/server/src/services/session-service.ts
@@ -20,11 +20,14 @@ import {
readTraceTolerant,
tracesDir,
} from "@prismshadow/penguin-core";
-import type { ApprovalMode, SessionInfo } from "../api/types.js";
+import type { SessionMetaMessage } from "@prismshadow/penguin-core";
+import type { ApprovalMode, SessionInfo, SessionSource } from "../api/types.js";
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
import { badRequest } from "../http/validate.js";
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
import type { SessionManager } from "../runtime/session-manager.js";
+import { asSessionSource } from "../runtime/session-sources.js";
+import type { SessionSources } from "../runtime/session-sources.js";
import type { ProjectConfigService } from "./project-config-service.js";
const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
@@ -44,13 +47,20 @@ export interface SessionServiceDeps {
sessions: SessionsRepo;
manager: SessionManager;
projectConfig: ProjectConfigService;
+ /** In-process origin registry derived from session_meta (the DB stores no source column). */
+ sources: SessionSources;
}
export class SessionService {
constructor(private readonly deps: SessionServiceDeps) {}
- /** DB row -> SessionInfo (run status and pending approval count come from session-manager). */
- toInfo(row: SessionRow, hasTrace: boolean): SessionInfo {
+ /**
+ * DB row -> SessionInfo (run status and pending approval count come from session-manager).
+ * Async because `source` is derived from session_meta: a registry miss (Session predating
+ * this process) falls back to reading the Trace head once (see sourceOf).
+ */
+ async toInfo(row: SessionRow, hasTrace: boolean): Promise {
+ const source = await this.sourceOf(row, hasTrace);
return {
sessionId: row.sessionId,
projectId: row.projectId,
@@ -60,7 +70,7 @@ export class SessionService {
workspace: row.workspace,
approvalMode: row.approvalMode,
...(row.title !== null ? { title: row.title } : {}),
- ...(row.source != null ? { source: row.source } : {}),
+ ...(source !== undefined ? { source } : {}),
createdAt: row.createdAt,
status: this.deps.manager.statusOf(row.sessionId),
pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
@@ -69,14 +79,43 @@ export class SessionService {
};
}
+ /**
+ * A Session's origin, with session_meta as the single source of truth: the in-process
+ * registry answers first (populated at creation / subagent registration / adoption);
+ * on a miss (a Session created before this process started) the earliest Trace shard's
+ * session_meta is read once and cached. A Session with no Trace yet stays unknown and
+ * is NOT cached negatively — its meta may appear with the first run.
+ */
+ private async sourceOf(row: SessionRow, hasTrace: boolean): Promise {
+ const known = this.deps.sources.get(row.sessionId);
+ if (known !== undefined) return known ?? undefined;
+ if (!hasTrace) return undefined;
+ const meta = await this.readTraceMeta(row.projectId, row.agentId, row.sessionId);
+ if (!meta) return undefined; // Unreadable/corrupt Trace: stay unknown, retry on the next list.
+ // On-disk values are untrusted: only the exact known origins pass, junk = user-created.
+ const source = asSessionSource(meta.payload.source) ?? null;
+ this.deps.sources.set(row.sessionId, source);
+ return source ?? undefined;
+ }
+
/** Whether this Session already has a Trace record (a Task has been run). */
async hasTrace(row: SessionRow): Promise {
const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
return ids.has(row.sessionId);
}
- /** List: DB ∪ Trace directory discovery, sorted by createdAt descending. */
- async listSessions(projectId: string, agentId: string): Promise {
+ /**
+ * List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
+ * `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
+ * more"); slicing happens before toInfo, so per-request source derivation (lazy
+ * Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
+ * the whole directory — the union and global ordering need every id.
+ */
+ async listSessions(
+ projectId: string,
+ agentId: string,
+ paging?: { offset: number; limit: number },
+ ): Promise {
const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
const rows = new Map(
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]),
@@ -89,11 +128,11 @@ export class SessionService {
if (discovered) rows.set(sessionId, discovered);
}
- return [...rows.values()]
- .sort(
- (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
- )
- .map((row) => this.toInfo(row, traceIds.has(row.sessionId)));
+ const sorted = [...rows.values()].sort(
+ (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
+ );
+ const page = paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted;
+ return Promise.all(page.map((row) => this.toInfo(row, traceIds.has(row.sessionId))));
}
/**
@@ -196,6 +235,8 @@ export class SessionService {
modelId,
provider,
...(args.workspace !== undefined ? { workspaceDir: args.workspace } : {}),
+ // The origin is also recorded in core session_meta (Trace), not just the index row.
+ ...(args.source !== undefined ? { source: args.source } : {}),
});
} catch (err) {
// A missing credential is its own category (the frontend shows localized text
@@ -208,6 +249,14 @@ export class SessionService {
err instanceof Error ? err.message : String(err),
);
}
+ // The origin is derived from the just-created core Session's session_meta (the single
+ // source of truth) rather than echoing args.source back: what the registry serves is
+ // exactly what the Trace will record.
+ const metaMsg = session.metaMessage;
+ this.deps.sources.set(
+ session.sessionId,
+ isSessionMeta(metaMsg) ? (asSessionSource(metaMsg.payload.source) ?? null) : null,
+ );
const row: SessionRow = {
sessionId: session.sessionId,
projectId: args.projectId,
@@ -218,7 +267,6 @@ export class SessionService {
approvalMode: args.approvalMode ?? "allow-all",
title: null,
createdAt: new Date().toISOString(),
- ...(args.source !== undefined ? { source: args.source } : {}),
};
this.deps.sessions.insert(row);
this.deps.manager.adopt(row, session);
@@ -238,12 +286,16 @@ export class SessionService {
return ids;
}
- /** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
- private async adoptTraceSession(
+ /**
+ * session_meta from the earliest Trace shard of a Session (the shard whose head carries
+ * the original meta); null when there is no readable Trace or it has no meta. Shared by
+ * adoption backfill and lazy `source` resolution.
+ */
+ private async readTraceMeta(
projectId: string,
agentId: string,
sessionId: string,
- ): Promise {
+ ): Promise {
const dir = tracesDir(this.deps.root, projectId, agentId);
let earliest: { path: string; index: number } | null = null;
for (const dateDir of await listDirsSafe(dir)) {
@@ -263,13 +315,25 @@ export class SessionService {
} catch {
return null; // Corrupt file: skip (does not block the list)
}
- const meta = messages.find(isSessionMeta);
+ return messages.find(isSessionMeta) ?? null;
+ }
+
+ /** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
+ private async adoptTraceSession(
+ projectId: string,
+ agentId: string,
+ sessionId: string,
+ ): Promise {
+ const meta = await this.readTraceMeta(projectId, agentId, sessionId);
if (!meta) return null;
// An older Trace version's session_meta lacks provider (the model reference
// wasn't split into separate fields yet): no backward compat, skip adoption
// (core will give a clear error on resume; the product hasn't launched yet, so
// old data can simply be deleted and recreated).
if (typeof meta.payload.provider !== "string") return null;
+ // The adoption read already has the meta in hand: record the origin (single source of
+ // truth); on-disk values are narrowed — junk counts as user-created.
+ this.deps.sources.set(sessionId, asSessionSource(meta.payload.source) ?? null);
const row: SessionRow = {
sessionId,
projectId,
diff --git a/packages/server/test/scheduler.test.ts b/packages/server/test/scheduler.test.ts
index d684c7b..4071a5d 100644
--- a/packages/server/test/scheduler.test.ts
+++ b/packages/server/test/scheduler.test.ts
@@ -40,6 +40,7 @@ describe("scheduler", () => {
workspace?: string;
provider?: string;
modelId?: string;
+ source?: "schedule";
}>;
let events: Array<{ userId: string; event: ScheduleServerEvent }>;
let errors: ErrorRecordArgs[];
@@ -288,13 +289,15 @@ describe("scheduler", () => {
nowMs = T0 + 6 * MIN;
await scheduler.tickOnce();
expect(created).toHaveLength(2);
- // The file's model reference is passed straight through as a pair.
+ // The file's model reference is passed straight through as a pair; every schedule-opened
+ // session is marked with its origin (SessionService stores it on the row and in core session_meta).
expect(created[0]).toMatchObject({
projectId: P,
agentId: A,
workspace: "/tmp/ws",
provider: "custom",
modelId: "m-bench",
+ source: "schedule",
});
expect(started.map((s) => s.sessionId)).toEqual(["session-new-1", "session-new-2"]);
});
diff --git a/packages/server/test/session-index.test.ts b/packages/server/test/session-index.test.ts
index beec5a7..b5fb289 100644
--- a/packages/server/test/session-index.test.ts
+++ b/packages/server/test/session-index.test.ts
@@ -103,6 +103,140 @@ describe("session-index", () => {
expect(list.sessions.map((s) => s.sessionId)).toContain(session.sessionId);
});
+ it("schedule-created Session: source derives from session_meta (registry), never from the DB row; user sessions carry none", async () => {
+ await configureModels();
+ // The scheduler goes through SessionService.createSession directly (no HTTP route exposes source).
+ const info = await t.deps.sessionService.createSession({
+ projectId,
+ agentId: "default_agent",
+ source: "schedule",
+ });
+ expect(info.source).toBe("schedule");
+ // The index row stores no origin: session_meta is the single source of truth.
+ const row = t.deps.sessionsRepo.findById(info.sessionId);
+ expect(row && "source" in row).toBe(false);
+ expect(t.deps.sessionSources.get(info.sessionId)).toBe("schedule");
+
+ // A user-created session (HTTP) has no source, and the list surfaces both accordingly.
+ const res = await api.post(base(), {});
+ expect(res.status).toBe(201);
+ const { session: plain } = (await res.json()) as SessionCreateResponse;
+ const list = (await (await api.get(base())).json()) as SessionsResponse;
+ expect(list.sessions.find((s) => s.sessionId === info.sessionId)?.source).toBe("schedule");
+ expect(list.sessions.find((s) => s.sessionId === plain.sessionId)?.source).toBeUndefined();
+ });
+
+ it("source survives a restart via the Trace head: an indexed row unknown to this process derives it lazily from session_meta", async () => {
+ await configureModels();
+ // Simulate a Session created by a previous process: the index row exists, but the
+ // in-process registry has never seen it — only its Trace's session_meta knows the origin.
+ const sid = "session-2026-07-02-09-00-00-feedc0de";
+ t.deps.sessionsRepo.insert({
+ sessionId: sid,
+ projectId,
+ agentId: "default_agent",
+ provider: "custom",
+ modelId: "m-x",
+ workspace: "/tmp/w-restart",
+ approvalMode: "allow-all",
+ title: null,
+ createdAt: "2026-07-02T09:00:00.000Z",
+ });
+ const meta: SessionMetaPayload = {
+ session_id: sid,
+ model_id: "m-x",
+ provider: "custom",
+ model_context_window: 1000,
+ system_prompt: "",
+ tools: [],
+ thinking_level: "default",
+ agent_state: "/tmp/a",
+ workspace: "/tmp/w-restart",
+ source: "subagent",
+ };
+ await writeTraceFile(t.root, projectId, "default_agent", "2026-07-02", sid, 1, [
+ sessionMeta(meta),
+ userText("child work"),
+ ]);
+ const list = (await (await api.get(base())).json()) as SessionsResponse;
+ expect(list.sessions.find((s) => s.sessionId === sid)?.source).toBe("subagent");
+ // The single-session endpoint derives it the same way (and the second read hits the registry).
+ const single = (await (await api.get(`/api/sessions/${sid}`)).json()) as SessionResponse;
+ expect(single.session.source).toBe("subagent");
+ });
+
+ it("adoption derives source from the Trace meta, narrowing junk values to user-created", async () => {
+ await configureModels();
+ // Discovered (no index row) with a valid origin: adoption records it.
+ const adopted = "session-2026-07-03-10-00-00-0badf00d";
+ const sourced: SessionMetaPayload = {
+ session_id: adopted,
+ model_id: "m-cli",
+ provider: "custom",
+ model_context_window: 1000,
+ system_prompt: "",
+ tools: [],
+ thinking_level: "default",
+ agent_state: "/tmp/a",
+ workspace: "/tmp/w-cli",
+ source: "schedule",
+ };
+ await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", adopted, 1, [
+ sessionMeta(sourced),
+ userText("adopted"),
+ ]);
+ // Discovered with a junk source (untrusted on-disk data): narrowed to user-created.
+ const junk = "session-2026-07-03-11-00-00-0badf00e";
+ const junkMeta = {
+ ...sourced,
+ session_id: junk,
+ source: "weird-origin",
+ } as unknown as SessionMetaPayload;
+ await writeTraceFile(t.root, projectId, "default_agent", "2026-07-03", junk, 1, [
+ sessionMeta(junkMeta),
+ userText("junk"),
+ ]);
+ const list = (await (await api.get(base())).json()) as SessionsResponse;
+ expect(list.sessions.find((s) => s.sessionId === adopted)?.source).toBe("schedule");
+ expect(list.sessions.find((s) => s.sessionId === junk)?.source).toBeUndefined();
+ });
+
+ it("list paging: limit/offset slice the newest-first list; absent params keep the full list; invalid values 400", async () => {
+ await configureModels();
+ // Three sessions with distinct createdAt ordering (insert directly for deterministic times).
+ const mk = (n: number) => ({
+ sessionId: `session-2026-07-0${n}-08-00-00-aaaa000${n}`,
+ projectId,
+ agentId: "default_agent",
+ provider: "custom",
+ modelId: "m-page",
+ workspace: `/tmp/w-${n}`,
+ approvalMode: "allow-all" as const,
+ title: null,
+ createdAt: `2026-07-0${n}T08:00:00.000Z`,
+ });
+ for (const n of [1, 2, 3]) t.deps.sessionsRepo.insert(mk(n));
+
+ const ids = async (qs: string) => {
+ const res = await api.get(`${base()}${qs}`);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SessionsResponse;
+ return body.sessions.map((s) => s.sessionId);
+ };
+ const all = await ids("");
+ expect(all).toEqual([mk(3).sessionId, mk(2).sessionId, mk(1).sessionId]); // newest first, unpaged
+ expect(await ids("?limit=2")).toEqual(all.slice(0, 2));
+ expect(await ids("?limit=2&offset=2")).toEqual(all.slice(2));
+ expect(await ids("?limit=2&offset=9")).toEqual([]); // past the end: empty page, not an error
+ // The sidebar's limit+1 trick: one extra row answers "has more" without an envelope change.
+ expect((await ids("?limit=3")).length).toBe(3);
+
+ for (const bad of ["?limit=0", "?limit=-1", "?limit=abc", "?limit=1001", "?offset=1"]) {
+ expect((await api.get(`${base()}${bad}`)).status, bad).toBe(400);
+ }
+ expect((await api.get(`${base()}?limit=2&offset=-1`)).status).toBe(400);
+ });
+
it("half a model reference is 400: the missing half is never inferred", async () => {
await configureModels();
// Only modelId: even though it names the one configured model, the provider is never
diff --git a/packages/server/test/session-loader.test.ts b/packages/server/test/session-loader.test.ts
index d2f6284..1eb14cd 100644
--- a/packages/server/test/session-loader.test.ts
+++ b/packages/server/test/session-loader.test.ts
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createAgent, saveProjectConfig, sessionMeta, userText } from "@prismshadow/penguin-core";
import type { SessionMetaPayload } from "@prismshadow/penguin-core";
import { createCoreSessionLoader } from "../src/runtime/session-manager.js";
+import { SessionSources } from "../src/runtime/session-sources.js";
import type { SessionRow } from "../src/db/repos/sessions.js";
import { HttpError } from "../src/http/errors.js";
import { makeTempRoot, writeTraceFile } from "./helpers.js";
@@ -99,4 +100,38 @@ describe("session-loader", () => {
expect((err as HttpError).status).toBe(409);
expect((err as HttpError).code).toBe("workspace_missing");
});
+
+ it("self-heal rebuild re-records a registry-known origin in the fresh session_meta; unknown stays absent", async () => {
+ // The anthropic pair constructs without a credential (the same pair session-index
+ // creates over HTTP); custom/m1 would demand a key at client construction.
+ await saveProjectConfig(root, PROJECT, {
+ default_model: { provider: "anthropic", model_id: "claude-sonnet-4-6" },
+ models: [{ provider: "anthropic", model_id: "claude-sonnet-4-6", context_window: 1000 }],
+ });
+ const ws = path.join(root, "ws-heal");
+ await fs.mkdir(ws, { recursive: true });
+ const healRow: SessionRow = {
+ ...row(ws),
+ modelId: "claude-sonnet-4-6",
+ provider: "anthropic",
+ };
+
+ // Origin known to this process (e.g. a schedule-created Session rebuilt after adoption):
+ // the fresh session_meta must re-record it — meta is the single source of truth, and the
+ // rebuilt Session's Trace is the only place the origin can survive.
+ const sources = new SessionSources();
+ sources.set(SID, "schedule");
+ const known = await createCoreSessionLoader(root, sources).load(healRow);
+ const knownMeta = (known as unknown as { metaMessage: { payload: { source?: string } } })
+ .metaMessage;
+ expect(knownMeta.payload.source).toBe("schedule");
+ (known as unknown as { dispose(): void }).dispose();
+
+ // No registry entry (e.g. the process restarted and no Trace was ever written): the
+ // rebuilt Session is unsourced — no source key is invented.
+ const unknown = await createCoreSessionLoader(root, new SessionSources()).load(healRow);
+ const unknownMeta = (unknown as unknown as { metaMessage: { payload: object } }).metaMessage;
+ expect("source" in unknownMeta.payload).toBe(false);
+ (unknown as unknown as { dispose(): void }).dispose();
+ });
});
diff --git a/packages/server/test/session-manager.test.ts b/packages/server/test/session-manager.test.ts
index a848042..bde8d88 100644
--- a/packages/server/test/session-manager.test.ts
+++ b/packages/server/test/session-manager.test.ts
@@ -33,6 +33,7 @@ import type { ChannelEvent } from "../src/runtime/channel.js";
import type { ErrorRecordArgs, ErrorSink } from "../src/runtime/error-recorder.js";
import { SessionManager } from "../src/runtime/session-manager.js";
import type { RuntimeSession, SessionLoader } from "../src/runtime/session-manager.js";
+import { SessionSources } from "../src/runtime/session-sources.js";
import type { TitleRequest } from "../src/runtime/title-generator.js";
import type { UsageContext } from "../src/runtime/usage-recorder.js";
import { waitFor } from "./helpers.js";
@@ -78,6 +79,7 @@ describe("session-manager", () => {
let db: DatabaseSync;
let sessions: SessionsRepo;
let channels: ChannelHub;
+ let sources: SessionSources;
let recorded: OmniMessage[];
let recordedCtx: UsageContext[];
@@ -85,6 +87,7 @@ describe("session-manager", () => {
new SessionManager({
sessions,
channels,
+ sources,
loader,
recorder: {
record: async (ctx, msg) => {
@@ -114,6 +117,7 @@ describe("session-manager", () => {
sessions = new SessionsRepo(db);
sessions.insert(ROW);
channels = new ChannelHub();
+ sources = new SessionSources();
recorded = [];
recordedCtx = [];
});
@@ -287,6 +291,7 @@ describe("session-manager", () => {
const manager = new SessionManager({
sessions,
channels,
+ sources,
loader: loaderOf(fake),
recorder: { record: async () => {} },
titles: {
@@ -301,6 +306,11 @@ describe("session-manager", () => {
expect(child?.agentId).toBe("child_agent");
expect(child?.modelId).toBe("m-child");
expect(child?.workspace).toBe("/tmp/w-child");
+ // The origin lands in the in-process registry (session_meta is the single source of
+ // truth; the row stores no source column) — "subagent" even when the forwarded meta
+ // predates the source field (this fake omits it): the registration path is the fallback.
+ expect(sources.get("child-1")).toBe("subagent");
+ expect(child && "source" in child).toBe(false);
// Title left blank: produced by the title generator from the sub-session's own conversation (falls back to the prompt's first line on failure).
expect(child?.title).toBeNull();
@@ -577,6 +587,7 @@ describe("session-manager", () => {
const manager = new SessionManager({
sessions,
channels,
+ sources,
loader: loaderOf(plainSession),
recorder: { record: async () => {} },
titles: {
@@ -629,6 +640,7 @@ describe("session-manager", () => {
const manager = new SessionManager({
sessions,
channels,
+ sources,
loader: loaderOf(longSession),
recorder: { record: async () => {} },
titles: {
@@ -695,6 +707,7 @@ describe("session-manager", () => {
const manager = new SessionManager({
sessions,
channels,
+ sources,
loader: loaderOf(delegating),
recorder: {
record: async (_ctx, msg) => {
diff --git a/packages/web/e2e/paging.spec.mjs b/packages/web/e2e/paging.spec.mjs
new file mode 100644
index 0000000..022e524
--- /dev/null
+++ b/packages/web/e2e/paging.spec.mjs
@@ -0,0 +1,66 @@
+/**
+ * Sidebar session paging: each group displays at most SIDEBAR_PAGE_SIZE (20) rows and the
+ * store fetches per Agent with limit+1 pages — 21 seeded sessions must load as one page of
+ * 20 plus a "更多" row; clicking it reveals the loaded rows AND fetches the next server
+ * page, after which all 21 rows are visible and the "更多" row disappears (no more hidden
+ * rows, no more server pages).
+ *
+ * Standalone spec: shares one server with the other specs, so it registers its own user
+ * (auto-provisions a default Project) and seeds sessions via the API.
+ */
+import { test, expect } from "@playwright/test";
+import { provisionAndLogin } from "./auth.mjs";
+
+const BASE = process.env.BASE_URL;
+const MOCK = process.env.MOCK_URL;
+const U = "pageuser";
+const P = "password123";
+const TOTAL = 21; // one past the 20-row page
+
+test("sidebar shows 20 sessions plus a More row; More loads the 21st and then disappears", async ({
+ page,
+}) => {
+ await provisionAndLogin(page.request, U, P);
+ const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
+ const projectId = projects.projects[0].projectId;
+
+ const put = await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
+ data: {
+ defaultModel: { provider: "custom", modelId: "claude-4-8" },
+ models: [
+ {
+ provider: "custom",
+ modelId: "claude-4-8",
+ apiKey: "sk-mock",
+ baseUrl: MOCK,
+ contextWindow: 200000,
+ },
+ ],
+ },
+ });
+ expect(put.ok(), "put models").toBeTruthy();
+
+ // Seed 21 sessions (each gets its own auto temp Workspace; the sidebar merges them into
+ // the single temp-workspace group, so the display cap applies to one group).
+ for (let i = 0; i < TOTAL; i++) {
+ const res = await page.request.post(
+ `${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
+ { data: {} },
+ );
+ expect(res.ok(), `create session ${i}: ${await res.text()}`).toBeTruthy();
+ }
+
+ await page.goto(`${BASE}/chat`);
+ const sidebar = page.getByRole("complementary");
+ // Untitled rows all read "新对话" (distinct from the nav's "新建对话", which is not matched
+ // by substring). One page: exactly 20 rows.
+ const rows = sidebar.getByText("新对话");
+ await expect(rows).toHaveCount(20);
+ const more = sidebar.getByRole("button", { name: "更多" });
+ await expect(more).toBeVisible();
+
+ // More: raises the display cap and fetches the next server page → all 21 rows, no More left.
+ await more.click();
+ await expect(rows).toHaveCount(TOTAL);
+ await expect(more).toHaveCount(0);
+});
diff --git a/packages/web/e2e/subagent.spec.mjs b/packages/web/e2e/subagent.spec.mjs
index bbffc34..c2134d8 100644
--- a/packages/web/e2e/subagent.spec.mjs
+++ b/packages/web/e2e/subagent.spec.mjs
@@ -105,11 +105,39 @@ test("subagent card survives a reload; child session title is generated from its
await openSubagent();
// --- Child session title: generated by the model from the child session's own conversation (async, poll until persisted) ---
- const childTitle = async () => {
+ const childOf = async () => {
const list = await (
await page.request.get(`${BASE}/api/projects/${projectId}/agents/${agentId}/sessions`)
).json();
- return list.sessions.find((s) => s.sessionId !== sessionId)?.title ?? null;
+ return list.sessions.find((s) => s.sessionId !== sessionId) ?? null;
};
- await expect.poll(childTitle, { timeout: 10000 }).toBe("Subagent TODO summary");
+ await expect
+ .poll(async () => (await childOf())?.title ?? null, { timeout: 10000 })
+ .toBe("Subagent TODO summary");
+ const child = await childOf();
+
+ // --- The child's OWN Trace session_meta records source=subagent: written by core's spawn
+ // site, the single source of truth (the server's registration fallback cannot mask this —
+ // it never writes the child Trace), and what the derived list source ultimately rests on. ---
+ const childMessages = await (
+ await page.request.get(`${BASE}/api/sessions/${child.sessionId}/messages`)
+ ).json();
+ const childMeta = childMessages.messages.find(
+ (m) => m.type === "session_meta" && !m.origin?.length,
+ );
+ expect(childMeta, "child trace session_meta").toBeTruthy();
+ expect(childMeta.payload.source).toBe("subagent");
+
+ // --- Sidebar: the child session (source=subagent) nests inside the collapsed "Subagents"
+ // folder (per-origin folders sit parallel to "Archived" within the same temp-workspace
+ // group). Reload first so the sidebar list carries the persisted title/source, and the
+ // folder is back to its default collapsed state. ---
+ await page.reload();
+ const sidebar = page.getByRole("complementary");
+ const subagentFolder = sidebar.getByRole("button", { name: "子智能体(1)" });
+ await expect(subagentFolder, "collapsed Subagents folder").toBeVisible();
+ // Collapsed by default: the child row is not rendered until the folder is expanded.
+ await expect(sidebar.getByText("Subagent TODO summary")).toHaveCount(0);
+ await subagentFolder.click();
+ await expect(sidebar.getByText("Subagent TODO summary")).toBeVisible();
});
diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts
index 44e3d3b..5922bbe 100644
--- a/packages/web/src/api/endpoints.ts
+++ b/packages/web/src/api/endpoints.ts
@@ -185,9 +185,16 @@ export const getAgentTraces = (projectId: string, agentId: string) =>
// Session ---------------------------------------------------------------------
-export const listSessions = (projectId: string, agentId: string) =>
+/** Optional paging (both absent = full list): the store requests `limit+1` per page to detect "has more". */
+export const listSessions = (
+ projectId: string,
+ agentId: string,
+ paging?: { offset: number; limit: number },
+) =>
apiFetch(
- `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions`,
+ `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${
+ paging ? `?limit=${paging.limit}&offset=${paging.offset}` : ""
+ }`,
);
/** Server directory browsing: `path` is an absolute path; empty means start from the server's home directory. */
diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx
index 14d40a5..086d7ce 100644
--- a/packages/web/src/components/layout/sidebar.tsx
+++ b/packages/web/src/components/layout/sidebar.tsx
@@ -15,10 +15,10 @@
* there's no longer a separate "quick / advanced" pair of new-chat dialogs.
* Color scheme is white/gray-based: active state uses a solid gray fill, running status uses a small color dot, no large blocks of color.
*/
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { NavLink, useMatch, useNavigate } from "react-router";
-import type { SessionInfo } from "@prismshadow/penguin-server/api";
+import type { SessionInfo, SessionSource } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
@@ -30,10 +30,14 @@ import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme";
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import {
+ SIDEBAR_PAGE_SIZE,
+ groupAgentsWithMore,
groupSessionsByWorkspace,
+ partitionSessions,
pinnedFirst,
workspaceGroupKey,
} from "../../lib/session-grouping";
+import type { SessionPartition } from "../../lib/session-grouping";
import { Dropdown } from "../ui/dropdown";
import { AgentAvatar } from "../ui/agent-avatar";
import { Chevron } from "../ui/chevron";
@@ -147,6 +151,13 @@ function saveGroupSet(storageKey: string | null, next: ReadonlySet): voi
}
}
+/**
+ * Open-state key of a per-origin folder (subagent / scheduled) inside a group: each folder
+ * has its own state. "\0" never appears in Agent ids or Workspace paths, so the composite
+ * never collides across groups or with plain group keys.
+ */
+const sourceFolderKey = (groupKey: string, source: SessionSource) => `${source}\0${groupKey}`;
+
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
function StatusDot({ session }: { session: SessionInfo }) {
if (session.status === "running") {
@@ -189,7 +200,8 @@ export function Sidebar({
currentAgent,
setCurrentAgentId,
} = useProject();
- const { sessions, byAgent, loading, remove, replace } = useSessions();
+ const { sessions, byAgent, hasMoreByAgent, loadMoreFor, loading, remove, replace } =
+ useSessions();
const chatMatch = useMatch("/chat/:sessionId");
const activeSessionId = chatMatch?.params.sessionId ?? null;
@@ -218,6 +230,10 @@ export function Sidebar({
}, [collapseStoreKey, pinStoreKey]);
/** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */
const [openArchived, setOpenArchived] = useState>(new Set());
+ /** Expanded per-origin folders (subagent / scheduled Sessions; collapsed by default), keyed by sourceFolderKey — each folder has its own open state. */
+ const [openSourceFolders, setOpenSourceFolders] = useState>(new Set());
+ /** Per-group display cap for active rows (keyed by group key; absent = SIDEBAR_PAGE_SIZE). "More" raises it a page at a time. */
+ const [groupCaps, setGroupCaps] = useState>(new Map());
/** Session pending delete confirmation (null = none). */
const [deletingSession, setDeletingSession] = useState(null);
const [deletingBusy, setDeletingBusy] = useState(false);
@@ -278,6 +294,36 @@ export function Sidebar({
return next;
});
+ const toggleSourceFolder = (groupKey: string, source: SessionSource) =>
+ setOpenSourceFolders((prev) => {
+ const key = sourceFolderKey(groupKey, source);
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+
+ // The open chat is an automation-created Session: expand exactly its origin's folder in its
+ // group, so the active row is never hidden inside a collapsed folder (mirrors the archived
+ // expansion on archiving the open chat; archived wins, so an archived Session is left to
+ // that folder). Auto-expansion fires ONCE per (grouping mode, active session): the ref guard
+ // keeps list mutations (status ticks, reloads) from re-opening a folder the user explicitly
+ // collapsed while that chat stays open. `sessions` must remain a dependency — the active
+ // session may not be in the list yet on first render, and the guard is only set once the
+ // row is actually found and expanded.
+ const lastAutoExpandedRef = useRef(null);
+ useEffect(() => {
+ if (!activeSessionId) return;
+ const s = sessions.find((x) => x.sessionId === activeSessionId);
+ if (!s || !s.source || s.archived) return;
+ const guard = `${groupMode}\0${activeSessionId}`;
+ if (lastAutoExpandedRef.current === guard) return;
+ lastAutoExpandedRef.current = guard;
+ const groupKey = groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace);
+ const key = sourceFolderKey(groupKey, s.source);
+ setOpenSourceFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
+ }, [activeSessionId, sessions, groupMode]);
+
/** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */
const toggleArchive = async (s: SessionInfo) => {
// Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back.
@@ -404,36 +450,108 @@ export function Sidebar({
);
- /** Expanded group body shared by both modes: active rows + the collapsed-by-default archived subgroup (keyed by the group key). */
- const renderGroupBody = (
+ const folderClass =
+ "flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50";
+
+ /** Collapsed-by-default per-origin folder (subagent / scheduled), parallel to the archived folder. */
+ const renderSourceFolder = (
groupKey: string,
- activeList: SessionInfo[],
- archivedList: SessionInfo[],
+ source: SessionSource,
+ rows: SessionInfo[],
withAgentHint: boolean,
+ ) => {
+ if (rows.length === 0) return null;
+ const open = openSourceFolders.has(sourceFolderKey(groupKey, source));
+ return (
+
+
+ {open && renderRows(rows, withAgentHint)}
+
+ );
+ };
+
+ /** "More": reveal one more page of already-loaded active rows AND fetch the next server page for every contributing Agent that still has one. */
+ const showMore = (groupKey: string, moreAgents: string[]) => {
+ setGroupCaps((prev) => {
+ const next = new Map(prev);
+ next.set(groupKey, (prev.get(groupKey) ?? SIDEBAR_PAGE_SIZE) + SIDEBAR_PAGE_SIZE);
+ return next;
+ });
+ if (moreAgents.length > 0) void loadMoreFor(moreAgents);
+ };
+
+ /**
+ * Expanded group body shared by both modes: user rows (display-capped; "More" reveals and
+ * loads further pages) + the collapsed-by-default subagent / scheduled / archived subgroups
+ * (keyed by the group key; rendered uncapped over loaded data — they are collapsed by
+ * default and only ever hold what the pages brought in).
+ */
+ const renderGroupBody = (
+ groupKey: string,
+ parts: SessionPartition,
+ withAgentHint: boolean,
+ /** Agents contributing to this group that still have unfetched server pages. */
+ moreAgents: string[],
) => {
const archivedOpen = openArchived.has(groupKey);
+ const cap = groupCaps.get(groupKey) ?? SIDEBAR_PAGE_SIZE;
+ const shownActive = parts.active.slice(0, cap);
+ // "More" while hidden loaded rows exist OR any contributing Agent has server-side pages
+ // left; a fetched page can also land rows in the folders below, so one click may grow
+ // the visible list by fewer than a full page — the row simply stays until exhausted.
+ const hasMore = parts.active.length > cap || moreAgents.length > 0;
+ const empty =
+ parts.active.length === 0 &&
+ parts.subagent.length === 0 &&
+ parts.schedule.length === 0 &&
+ parts.archived.length === 0;
return (
<>
- {activeList.length === 0 && archivedList.length === 0 ? (
+ {empty ? (
{S.chat.noSessions}
) : (
- renderRows(activeList, withAgentHint)
+ renderRows(shownActive, withAgentHint)
)}
- {/* Archived group (collapsed by default) */}
- {archivedList.length > 0 && (
+ {/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */}
+ {hasMore && (
+
+ )}
+
+ {/* Per-origin folders (collapsed by default, above Archived): subagent first — spawned
+ from the conversations at hand — then scheduled background runs. */}
+ {renderSourceFolder(groupKey, "subagent", parts.subagent, withAgentHint)}
+ {renderSourceFolder(groupKey, "schedule", parts.schedule, withAgentHint)}
+
+ {/* Archived group (collapsed by default; archived wins over the per-origin folders) */}
+ {parts.archived.length > 0 && (