@prismshadow/penguin-server 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -55,8 +55,11 @@ CREATE TABLE IF NOT EXISTS sessions (
55
55
  approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
56
56
  title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
57
57
  archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
58
+ client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web
59
+ has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration)
58
60
  created_at TEXT NOT NULL
59
- ); -- session origin is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts)
61
+ ); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row)
62
+ CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC);
60
63
  CREATE TABLE IF NOT EXISTS usage_records (
61
64
  id INTEGER PRIMARY KEY AUTOINCREMENT,
62
65
  ts TEXT NOT NULL,
@@ -133,8 +136,15 @@ function openDatabase(dbPath) {
133
136
  db.exec("PRAGMA journal_mode = WAL;");
134
137
  db.exec("PRAGMA foreign_keys = ON;");
135
138
  db.exec(SCHEMA_SQL);
139
+ ensureColumn(db, "sessions", "client", "TEXT");
140
+ ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
136
141
  return db;
137
142
  }
143
+ function ensureColumn(db, table, column, ddl) {
144
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all();
145
+ if (cols.some((c) => c.name === column)) return;
146
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
147
+ }
138
148
 
139
149
  // src/db/repos/agents.ts
140
150
  var AgentsRepo = class {
@@ -615,6 +625,8 @@ function mapRow4(r) {
615
625
  approvalMode: r.approval_mode,
616
626
  title: r.title ?? null,
617
627
  archivedAt: r.archived_at ?? null,
628
+ client: r.client ?? null,
629
+ hasTrace: r.has_trace === 1,
618
630
  createdAt: r.created_at
619
631
  };
620
632
  }
@@ -625,8 +637,8 @@ var SessionsRepo = class {
625
637
  db;
626
638
  insert(row) {
627
639
  this.db.prepare(
628
- `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
629
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
640
+ `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
641
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
630
642
  ).run(
631
643
  row.sessionId,
632
644
  row.projectId,
@@ -636,14 +648,16 @@ var SessionsRepo = class {
636
648
  row.workspace,
637
649
  row.approvalMode,
638
650
  row.title,
651
+ row.client ?? null,
652
+ row.hasTrace ? 1 : 0,
639
653
  row.createdAt
640
654
  );
641
655
  }
642
656
  /** Idempotent insert: used when Trace directory discovery backfills a row (concurrent listing discovering the same Session no longer triggers a UNIQUE violation). */
643
657
  insertOrIgnore(row) {
644
658
  this.db.prepare(
645
- `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, created_at)
646
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
659
+ `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
660
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
647
661
  ).run(
648
662
  row.sessionId,
649
663
  row.projectId,
@@ -653,15 +667,30 @@ var SessionsRepo = class {
653
667
  row.workspace,
654
668
  row.approvalMode,
655
669
  row.title,
670
+ row.client ?? null,
671
+ row.hasTrace ? 1 : 0,
656
672
  row.createdAt
657
673
  );
658
674
  }
675
+ /** Flip the has_trace cache once a Trace record exists (task start / discovery hydration); idempotent. */
676
+ markHasTrace(sessionId) {
677
+ this.db.prepare("UPDATE sessions SET has_trace = 1 WHERE session_id = ?").run(sessionId);
678
+ }
659
679
  findById(sessionId) {
660
680
  const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
661
681
  return r ? mapRow4(r) : null;
662
682
  }
663
- listByAgent(projectId, agentId) {
664
- const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?").all(projectId, agentId);
683
+ /**
684
+ * An Agent's rows, newest first (the list order the sidebar shows; served by
685
+ * idx_sessions_agent_created). `webOnly` keeps only web-created rows — NULL counts as
686
+ * web (legacy rows predate the column and the user chose to grandfather them as visible).
687
+ */
688
+ listByAgent(projectId, agentId, opts = {}) {
689
+ const filter = opts.webOnly ? " AND (client IS NULL OR client = 'web')" : "";
690
+ const rows = this.db.prepare(
691
+ `SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?${filter}
692
+ ORDER BY created_at DESC, session_id DESC`
693
+ ).all(projectId, agentId);
665
694
  return rows.map(mapRow4);
666
695
  }
667
696
  listByProject(projectId) {
@@ -2200,24 +2229,8 @@ async function readItem(deps2, projectId, agentId, name) {
2200
2229
  // src/http/routes/benchmarks.ts
2201
2230
  import { Hono as Hono10 } from "hono";
2202
2231
  var TEXT_PREVIEW_BYTES = 256 * 1024;
2203
- function benchmarksRoutes(deps2) {
2204
- const app2 = new Hono10();
2205
- app2.get("/", async (c) => {
2206
- const projectId = requireValidId(c, "projectId");
2207
- const agentId = requireValidId(c, "agentId");
2208
- deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2209
- await deps2.agentConfigService.requireExists(projectId, agentId);
2210
- return c.json(await deps2.benchmarks.list(projectId, agentId));
2211
- });
2212
- app2.get("/:benchmarkId/cases", async (c) => {
2213
- const projectId = requireValidId(c, "projectId");
2214
- const agentId = requireValidId(c, "agentId");
2215
- const benchmarkId = requireValidId(c, "benchmarkId");
2216
- deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2217
- await deps2.agentConfigService.requireExists(projectId, agentId);
2218
- return c.json(await deps2.benchmarks.listCases(projectId, agentId, benchmarkId));
2219
- });
2220
- app2.get("/:benchmarkId/cases/:caseId/files", async (c) => {
2232
+ function listCaseFiles(deps2, material) {
2233
+ return async (c) => {
2221
2234
  const projectId = requireValidId(c, "projectId");
2222
2235
  const agentId = requireValidId(c, "agentId");
2223
2236
  const benchmarkId = requireValidId(c, "benchmarkId");
@@ -2230,11 +2243,14 @@ function benchmarksRoutes(deps2) {
2230
2243
  agentId,
2231
2244
  benchmarkId,
2232
2245
  caseId,
2233
- c.req.query("path") ?? ""
2246
+ c.req.query("path") ?? "",
2247
+ material
2234
2248
  )
2235
2249
  );
2236
- });
2237
- app2.get("/:benchmarkId/cases/:caseId/files/content", async (c) => {
2250
+ };
2251
+ }
2252
+ function readCaseFile(deps2, material) {
2253
+ return async (c) => {
2238
2254
  const projectId = requireValidId(c, "projectId");
2239
2255
  const agentId = requireValidId(c, "agentId");
2240
2256
  const benchmarkId = requireValidId(c, "benchmarkId");
@@ -2249,6 +2265,7 @@ function benchmarksRoutes(deps2) {
2249
2265
  benchmarkId,
2250
2266
  caseId,
2251
2267
  c.req.query("path") ?? "",
2268
+ material,
2252
2269
  boundedPreview ? { maxBytes: TEXT_PREVIEW_BYTES } : void 0
2253
2270
  );
2254
2271
  return new Response(new Uint8Array(data), {
@@ -2260,7 +2277,29 @@ function benchmarksRoutes(deps2) {
2260
2277
  ...truncated ? { "X-Content-Truncated": "1" } : {}
2261
2278
  }
2262
2279
  });
2280
+ };
2281
+ }
2282
+ function benchmarksRoutes(deps2) {
2283
+ const app2 = new Hono10();
2284
+ app2.get("/", async (c) => {
2285
+ const projectId = requireValidId(c, "projectId");
2286
+ const agentId = requireValidId(c, "agentId");
2287
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2288
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2289
+ return c.json(await deps2.benchmarks.list(projectId, agentId));
2263
2290
  });
2291
+ app2.get("/:benchmarkId/cases", async (c) => {
2292
+ const projectId = requireValidId(c, "projectId");
2293
+ const agentId = requireValidId(c, "agentId");
2294
+ const benchmarkId = requireValidId(c, "benchmarkId");
2295
+ deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
2296
+ await deps2.agentConfigService.requireExists(projectId, agentId);
2297
+ return c.json(await deps2.benchmarks.listCases(projectId, agentId, benchmarkId));
2298
+ });
2299
+ app2.get("/:benchmarkId/cases/:caseId/files", listCaseFiles(deps2, "statement"));
2300
+ app2.get("/:benchmarkId/cases/:caseId/files/content", readCaseFile(deps2, "statement"));
2301
+ app2.get("/:benchmarkId/cases/:caseId/rubric/files", listCaseFiles(deps2, "rubric"));
2302
+ app2.get("/:benchmarkId/cases/:caseId/rubric/files/content", readCaseFile(deps2, "rubric"));
2264
2303
  return app2;
2265
2304
  }
2266
2305
 
@@ -3005,7 +3044,11 @@ var WorkspaceFilesService = class {
3005
3044
  import fs7 from "fs/promises";
3006
3045
  import path6 from "path";
3007
3046
  import { randomBytes as randomBytes4 } from "crypto";
3008
- import { appendAttachmentLines, attachedFileLine } from "@prismshadow/penguin-core";
3047
+ import {
3048
+ appendAttachmentLines,
3049
+ attachedFileLine,
3050
+ modelVisiblePath
3051
+ } from "@prismshadow/penguin-core";
3009
3052
  var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
3010
3053
  var MAX_ATTACHMENT_COUNT = 20;
3011
3054
  var MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
@@ -3029,21 +3072,21 @@ function truncateBytes(value, maxBytes) {
3029
3072
  return out;
3030
3073
  }
3031
3074
  var MAX_NAME_ATTEMPTS = 16;
3032
- function parseAttachmentPart(part, index) {
3075
+ function parseAttachmentPart(part, index, field = "input") {
3033
3076
  const fileName = part.fileName;
3034
3077
  if (typeof fileName !== "string" || fileName.length === 0 || fileName.includes("/") || fileName.includes("\\") || fileName.includes("..") || fileName.includes("\0")) {
3035
3078
  throw badRequest(
3036
- `input[${index}].fileName must be a non-empty file name without path separators or "..".`
3079
+ `${field}[${index}].fileName must be a non-empty file name without path separators or "..".`
3037
3080
  );
3038
3081
  }
3039
3082
  const dataUrl = part.dataUrl;
3040
3083
  const match = typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
3041
3084
  if (!match) {
3042
- throw badRequest(`input[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
3085
+ throw badRequest(`${field}[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
3043
3086
  }
3044
3087
  const bytes = Buffer.from(match[1], "base64");
3045
3088
  if (bytes.length === 0) {
3046
- throw badRequest(`input[${index}].dataUrl decodes to an empty file.`);
3089
+ throw badRequest(`${field}[${index}].dataUrl decodes to an empty file.`);
3047
3090
  }
3048
3091
  if (bytes.length > MAX_ATTACHMENT_BYTES) {
3049
3092
  throw new HttpError(
@@ -3125,7 +3168,13 @@ async function attachFilesToInput(messages, attachments, root, sessionId) {
3125
3168
  await removeAttachments(written);
3126
3169
  throw err;
3127
3170
  }
3128
- return { input: appendAttachmentLines(messages, written.map(attachedFileLine)), written };
3171
+ return {
3172
+ input: appendAttachmentLines(
3173
+ messages,
3174
+ written.map((filePath) => attachedFileLine(modelVisiblePath(filePath)))
3175
+ ),
3176
+ written
3177
+ };
3129
3178
  }
3130
3179
 
3131
3180
  // src/http/routes/sessions.ts
@@ -3202,6 +3251,20 @@ function parseSteerImages(body) {
3202
3251
  if (!Array.isArray(images)) throw badRequest("images must be an array.");
3203
3252
  return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
3204
3253
  }
3254
+ function parseSteerFiles(body) {
3255
+ const files = body.files;
3256
+ if (files === void 0) return [];
3257
+ if (!Array.isArray(files)) throw badRequest("files must be an array.");
3258
+ const attachments = [];
3259
+ files.forEach((item, i) => {
3260
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
3261
+ throw badRequest(`files[${i}] must be an object.`);
3262
+ }
3263
+ attachments.push(parseAttachmentPart(item, i, "files"));
3264
+ assertAttachmentBudget(attachments);
3265
+ });
3266
+ return attachments;
3267
+ }
3205
3268
  function parseGoalField(body) {
3206
3269
  const goal = body.goal;
3207
3270
  if (goal === void 0) return null;
@@ -3228,13 +3291,16 @@ function agentSessionsRoutes(deps2) {
3228
3291
  }
3229
3292
  const rawCounts = c.req.query("counts");
3230
3293
  if (rawCounts !== void 0 && rawCounts !== "1") throw badRequest("counts only accepts 1.");
3294
+ const rawCli = c.req.query("cli");
3295
+ if (rawCli !== void 0 && rawCli !== "1") throw badRequest("cli only accepts 1.");
3231
3296
  const { sessions, counts, workspaceCounts } = await deps2.sessionService.listSessions(
3232
3297
  projectId,
3233
3298
  agentId,
3234
3299
  {
3235
3300
  ...paging ? { paging } : {},
3236
3301
  ...rawCategory !== void 0 ? { category: rawCategory } : {},
3237
- ...rawCounts !== void 0 ? { withCounts: true } : {}
3302
+ ...rawCounts !== void 0 ? { withCounts: true } : {},
3303
+ ...rawCli !== void 0 ? { includeCli: true } : {}
3238
3304
  }
3239
3305
  );
3240
3306
  return c.json({
@@ -3433,11 +3499,15 @@ function sessionsRoutes(deps2) {
3433
3499
  app2.get("/:sessionId/stream", (c) => {
3434
3500
  const row = resolveSession(c);
3435
3501
  const channel = deps2.channels.get(row.sessionId);
3502
+ const pendingSteering = deps2.manager.pendingSteeringOf(row.sessionId);
3436
3503
  const initialEvents = [
3437
3504
  {
3438
3505
  type: "task_state",
3439
3506
  state: deps2.manager.statusOf(row.sessionId),
3440
- queued: deps2.manager.pendingFollowUpCount(row.sessionId)
3507
+ queued: deps2.manager.pendingFollowUpCount(row.sessionId),
3508
+ // Undelivered steering rides the snapshot too, so the composer's "steering queued"
3509
+ // hint (and what it says) survives a reload.
3510
+ ...pendingSteering.length > 0 ? { pendingSteering } : {}
3441
3511
  },
3442
3512
  ...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
3443
3513
  type: "approval_request",
@@ -3493,13 +3563,26 @@ function sessionsRoutes(deps2) {
3493
3563
  const body = await readJson(c);
3494
3564
  const text = typeof body.text === "string" ? body.text.trim() : "";
3495
3565
  const images = parseSteerImages(body);
3496
- if (!text && images.length === 0) {
3497
- throw badRequest("text or images must carry the steering message.");
3566
+ const files = parseSteerFiles(body);
3567
+ if (!text && images.length === 0 && files.length === 0) {
3568
+ throw badRequest("text, images or files must carry the steering message.");
3569
+ }
3570
+ const { input, written } = await attachFilesToInput(
3571
+ [...text ? [userText(text)] : [], ...images.map((url) => imageUrlMessage(url))],
3572
+ files,
3573
+ scratchpadDir(deps2.config.root, row.projectId, row.agentId),
3574
+ row.sessionId
3575
+ );
3576
+ try {
3577
+ deps2.manager.steer(row.sessionId, input, {
3578
+ text,
3579
+ images: images.length,
3580
+ files: files.length
3581
+ });
3582
+ } catch (err) {
3583
+ await removeAttachments(written);
3584
+ throw err;
3498
3585
  }
3499
- deps2.manager.steer(row.sessionId, [
3500
- ...text ? [userText(text)] : [],
3501
- ...images.map((url) => imageUrlMessage(url))
3502
- ]);
3503
3586
  return c.body(null, 202);
3504
3587
  });
3505
3588
  app2.get("/:sessionId/goal", (c) => {
@@ -3962,6 +4045,7 @@ import {
3962
4045
  goalTokenDelta,
3963
4046
  isGoalRoundInput,
3964
4047
  isSessionMeta as isSessionMeta2,
4048
+ parseUserSteeringText,
3965
4049
  stripLeadingMarkerBlocks,
3966
4050
  tracesDir
3967
4051
  } from "@prismshadow/penguin-core";
@@ -4492,6 +4576,11 @@ var EARLY_TITLE_BODY_CHARS = 1e3;
4492
4576
  function agentKey(projectId, agentId) {
4493
4577
  return `${projectId}\0${agentId}`;
4494
4578
  }
4579
+ function isDeliveredSteering(msg) {
4580
+ const p = msg.payload;
4581
+ if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "user") return false;
4582
+ return typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
4583
+ }
4495
4584
  function runSubagentCall(msg) {
4496
4585
  const p = msg.payload;
4497
4586
  if (msg.type !== "model_msg" || p.type !== "tool_call" || p.name !== "run_subagent") return null;
@@ -4564,6 +4653,10 @@ var SessionManager = class {
4564
4653
  pendingFollowUpCount(sessionId) {
4565
4654
  return this.entries.get(sessionId)?.followUps.length ?? 0;
4566
4655
  }
4656
+ /** Steering messages queued but not yet delivered to the model (display mirror; see RuntimeEntry.pendingSteering). */
4657
+ pendingSteeringOf(sessionId) {
4658
+ return this.entries.get(sessionId)?.pendingSteering ?? [];
4659
+ }
4567
4660
  /**
4568
4661
  * Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
4569
4662
  * streaming fragment, carrying the full accumulated content so far (see live-tail.ts).
@@ -4596,6 +4689,7 @@ var SessionManager = class {
4596
4689
  running: null,
4597
4690
  generation: this.generationOf(row.projectId, row.agentId),
4598
4691
  followUps: [],
4692
+ pendingSteering: [],
4599
4693
  lastActivityMs: Date.now()
4600
4694
  });
4601
4695
  }
@@ -4889,8 +4983,13 @@ var SessionManager = class {
4889
4983
  * publishes). 409 when the Session isn't running a Task (idle / compacting / not loaded)
4890
4984
  * or the run finished in the race window — the caller falls back to submitting a normal
4891
4985
  * task, which carries the same text and images.
4986
+ *
4987
+ * `info` is the queued message's display summary: mirrored on the entry and broadcast via
4988
+ * `task_state` (and the SSE subscribe snapshot) until the delivered `[user_steering]`
4989
+ * message is observed on the stream — that is what keeps the composer's "steering queued"
4990
+ * hint, content included, alive across reloads.
4892
4991
  */
4893
- steer(sessionId, input) {
4992
+ steer(sessionId, input, info) {
4894
4993
  const entry = this.entries.get(sessionId);
4895
4994
  if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
4896
4995
  throw new HttpError(
@@ -4899,7 +4998,9 @@ var SessionManager = class {
4899
4998
  "This Session has no Task in progress; send the message as a new task instead."
4900
4999
  );
4901
5000
  }
5001
+ entry.pendingSteering.push(info);
4902
5002
  entry.lastActivityMs = Date.now();
5003
+ this.publishState(entry, entry.status);
4903
5004
  }
4904
5005
  /**
4905
5006
  * "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
@@ -5114,6 +5215,7 @@ var SessionManager = class {
5114
5215
  running: null,
5115
5216
  generation,
5116
5217
  followUps: [],
5218
+ pendingSteering: [],
5117
5219
  lastActivityMs: Date.now()
5118
5220
  };
5119
5221
  this.entries.set(currentId, entry);
@@ -5126,6 +5228,7 @@ var SessionManager = class {
5126
5228
  * a title): it collects model text for automatic title generation.
5127
5229
  */
5128
5230
  async drive(entry, gen, titleSource) {
5231
+ this.deps.sessions.markHasTrace(entry.sessionId);
5129
5232
  let earlyTitleFired = false;
5130
5233
  let mainBodyChars = 0;
5131
5234
  const ctx = {
@@ -5151,6 +5254,10 @@ var SessionManager = class {
5151
5254
  if (denied) subagentPrompts.delete(denied);
5152
5255
  const settled = settledToolCallId(msg);
5153
5256
  if (settled) subagentPrompts.delete(settled);
5257
+ if (entry.pendingSteering.length > 0 && isDeliveredSteering(msg)) {
5258
+ entry.pendingSteering.shift();
5259
+ this.publishState(entry, entry.status);
5260
+ }
5154
5261
  } else if (isSessionMeta2(msg)) {
5155
5262
  try {
5156
5263
  const child = this.registerChildSession(entry, msg, children);
@@ -5213,6 +5320,7 @@ var SessionManager = class {
5213
5320
  entry.status = "idle";
5214
5321
  entry.abort = null;
5215
5322
  entry.running = null;
5323
+ entry.pendingSteering = [];
5216
5324
  entry.lastActivityMs = Date.now();
5217
5325
  this.publishState(entry, "idle");
5218
5326
  if (titleSource && titleSource.userExcerpt.trim()) {
@@ -5271,6 +5379,8 @@ var SessionManager = class {
5271
5379
  // inserted with defaults (matches the convention for Sessions discovered by the CLI).
5272
5380
  approvalMode: "allow-all",
5273
5381
  title: null,
5382
+ // Spawned by this server's run (client NULL = web); its Trace exists by construction.
5383
+ hasTrace: true,
5274
5384
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
5275
5385
  });
5276
5386
  this.publishEvent(entry, {
@@ -5291,7 +5401,12 @@ var SessionManager = class {
5291
5401
  return child;
5292
5402
  }
5293
5403
  publishState(entry, state) {
5294
- this.publishEvent(entry, { type: "task_state", state, queued: entry.followUps.length });
5404
+ this.publishEvent(entry, {
5405
+ type: "task_state",
5406
+ state,
5407
+ queued: entry.followUps.length,
5408
+ ...entry.pendingSteering.length > 0 ? { pendingSteering: entry.pendingSteering } : {}
5409
+ });
5295
5410
  }
5296
5411
  publishEvent(entry, event) {
5297
5412
  this.deps.channels.get(entry.sessionId).publish(event, "server_event");
@@ -6888,7 +7003,13 @@ var BenchmarkService = class {
6888
7003
  for (const entry of entries.filter((item) => item.isDirectory() && item.name.startsWith("CASE-")).sort((a, b) => a.name.localeCompare(b.name))) {
6889
7004
  const fallback = { id: entry.name, title: entry.name };
6890
7005
  try {
6891
- const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, entry.name);
7006
+ const statementDir = await this.caseMaterialRoot(
7007
+ projectId,
7008
+ agentId,
7009
+ benchmarkId,
7010
+ entry.name,
7011
+ "statement"
7012
+ );
6892
7013
  const realReadme = await fs13.realpath(path12.join(statementDir, "README.md"));
6893
7014
  if (!isWithin(statementDir, realReadme)) throw new Error("README escapes Statement");
6894
7015
  cases.push({
@@ -6901,30 +7022,42 @@ var BenchmarkService = class {
6901
7022
  }
6902
7023
  return { cases };
6903
7024
  }
6904
- async listCaseFiles(projectId, agentId, benchmarkId, caseId, rel) {
6905
- const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, caseId);
6906
- return this.workspaceFiles.list(statementDir, rel);
7025
+ async listCaseFiles(projectId, agentId, benchmarkId, caseId, rel, material) {
7026
+ const materialRoot = await this.caseMaterialRoot(
7027
+ projectId,
7028
+ agentId,
7029
+ benchmarkId,
7030
+ caseId,
7031
+ material
7032
+ );
7033
+ return this.workspaceFiles.list(materialRoot, rel);
6907
7034
  }
6908
- async readCaseFile(projectId, agentId, benchmarkId, caseId, rel, options) {
6909
- const statementDir = await this.statementRoot(projectId, agentId, benchmarkId, caseId);
6910
- return this.workspaceFiles.read(statementDir, rel, options);
7035
+ async readCaseFile(projectId, agentId, benchmarkId, caseId, rel, material, options) {
7036
+ const materialRoot = await this.caseMaterialRoot(
7037
+ projectId,
7038
+ agentId,
7039
+ benchmarkId,
7040
+ caseId,
7041
+ material
7042
+ );
7043
+ return this.workspaceFiles.read(materialRoot, rel, options);
6911
7044
  }
6912
- async statementRoot(projectId, agentId, benchmarkId, caseId) {
7045
+ async caseMaterialRoot(projectId, agentId, benchmarkId, caseId, material) {
6913
7046
  const benchDir = path12.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
6914
7047
  const caseDir = path12.join(benchDir, caseId);
6915
- const statementDir = path12.join(caseDir, "statement");
7048
+ const materialRoot = path12.join(caseDir, material);
6916
7049
  try {
6917
- const [realBenchDir, realCaseDir, realStatementDir] = await Promise.all([
7050
+ const [realBenchDir, realCaseDir, realMaterialRoot] = await Promise.all([
6918
7051
  fs13.realpath(benchDir),
6919
7052
  fs13.realpath(caseDir),
6920
- fs13.realpath(statementDir)
7053
+ fs13.realpath(materialRoot)
6921
7054
  ]);
6922
- if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realStatementDir) !== realCaseDir || path12.basename(realStatementDir) !== "statement") {
6923
- throw new Error("Statement path is not canonical");
7055
+ if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realMaterialRoot) !== realCaseDir || path12.basename(realMaterialRoot) !== material) {
7056
+ throw new Error("Case material is not canonical");
6924
7057
  }
6925
- return realStatementDir;
7058
+ return realMaterialRoot;
6926
7059
  } catch {
6927
- throw new HttpError(404, "not_found", "Public Case Statement does not exist.");
7060
+ throw new HttpError(404, "not_found", `Case ${material} does not exist.`);
6928
7061
  }
6929
7062
  }
6930
7063
  async readBenchmark(benchDir, id) {
@@ -7524,11 +7657,19 @@ var SessionService = class {
7524
7657
  return await this.sourceOf(row, hasTrace, traces) ?? "active";
7525
7658
  }
7526
7659
  /**
7527
- * List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
7528
- * `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
7529
- * more"); slicing happens before toInfo, so per-request source derivation (lazy
7530
- * Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
7531
- * the whole directory — the union and global ordering need every id.
7660
+ * List, sorted by createdAt descending. The default serves **web sessions straight from
7661
+ * the DB** (`client` web/NULL rows) with no Trace directory scanning — the answer to
7662
+ * many-session sidebar reloads re-walking the filesystem on every request (#139). One
7663
+ * lazy discovery walk still runs for a list call that contains rows this process has not
7664
+ * classified yet (no in-process source entry): it supplies the Trace locations for the
7665
+ * one-time head reads and backfills the `has_trace` cache; once every row is classified,
7666
+ * list calls touch only the DB. `includeCli` widens the list to DB ∪ Trace directory
7667
+ * discovery (adopting unmanaged CLI Traces as `client: "cli"` rows), which inherently
7668
+ * scans — that path is opt-in via the "show CLI sessions" preference.
7669
+ *
7670
+ * Optional `paging` returns just that slice (the sidebar pages with limit+1 to detect
7671
+ * "has more"); slicing happens before toInfo, so per-request source derivation (lazy
7672
+ * Trace-head reads) stays bounded by the page size.
7532
7673
  *
7533
7674
  * `category` filters to one sidebar bucket **before** paging, so offset/limit page
7534
7675
  * within the category. Filtering needs each walked row's category (a possible
@@ -7539,20 +7680,32 @@ var SessionService = class {
7539
7680
  * folders (and a workspace group can know its own share) without loading them.
7540
7681
  */
7541
7682
  async listSessions(projectId, agentId, opts = {}) {
7542
- const { paging, category, withCounts } = opts;
7543
- const traces = await this.discoverTraces(projectId, agentId);
7683
+ const { paging, category, withCounts, includeCli } = opts;
7544
7684
  const rows = new Map(
7545
- this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r])
7685
+ this.deps.sessions.listByAgent(projectId, agentId, { webOnly: !includeCli }).map((r) => [r.sessionId, r])
7546
7686
  );
7547
- for (const [sessionId, location] of traces) {
7548
- if (rows.has(sessionId)) continue;
7549
- const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
7550
- if (discovered) rows.set(sessionId, discovered);
7687
+ let traces;
7688
+ if (includeCli) {
7689
+ traces = await this.discoverTraces(projectId, agentId);
7690
+ for (const [sessionId, location] of traces) {
7691
+ if (rows.has(sessionId)) continue;
7692
+ const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location);
7693
+ if (discovered) rows.set(sessionId, discovered);
7694
+ }
7695
+ } else if ([...rows.values()].some((r) => this.deps.sources.get(r.sessionId) === void 0)) {
7696
+ traces = await this.discoverTraces(projectId, agentId);
7697
+ for (const row of rows.values()) {
7698
+ if (!row.hasTrace && traces.has(row.sessionId)) {
7699
+ row.hasTrace = true;
7700
+ this.deps.sessions.markHasTrace(row.sessionId);
7701
+ }
7702
+ }
7551
7703
  }
7552
7704
  const sorted = [...rows.values()].sort(
7553
7705
  (a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId)
7554
7706
  );
7555
- const toPage = (page) => Promise.all(page.map((row) => this.toInfo(row, traces.has(row.sessionId), traces)));
7707
+ const rowHasTrace = (row) => traces ? traces.has(row.sessionId) : row.hasTrace === true;
7708
+ const toPage = (page) => Promise.all(page.map((row) => this.toInfo(row, rowHasTrace(row), traces)));
7556
7709
  if (category === void 0 && !withCounts) {
7557
7710
  return {
7558
7711
  sessions: await toPage(
@@ -7566,7 +7719,7 @@ var SessionService = class {
7566
7719
  const matched = [];
7567
7720
  for (const row of sorted) {
7568
7721
  if (!withCounts && matched.length >= want) break;
7569
- const cat = await this.categoryOf(row, traces.has(row.sessionId), traces);
7722
+ const cat = await this.categoryOf(row, rowHasTrace(row), traces);
7570
7723
  counts[cat] += 1;
7571
7724
  if (withCounts) {
7572
7725
  const ws = workspaceCounts[row.workspace] ??= {
@@ -7686,6 +7839,9 @@ var SessionService = class {
7686
7839
  workspace: session.workspaceDir,
7687
7840
  approvalMode: args.approvalMode ?? "allow-all",
7688
7841
  title: null,
7842
+ // Everything created through this service is the Web App's (schedule runs included);
7843
+ // "cli" is stamped only by Trace adoption. NULL means a legacy row, treated as web.
7844
+ client: "web",
7689
7845
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
7690
7846
  };
7691
7847
  this.deps.sessions.insert(row);
@@ -7758,6 +7914,10 @@ var SessionService = class {
7758
7914
  // The approval mode for an unmanaged Session (started via the CLI) isn't in the Trace, so it's backfilled with the default value.
7759
7915
  approvalMode: "allow-all",
7760
7916
  title: null,
7917
+ // Adopted = a Trace this server never created, i.e. the CLI's: the default list
7918
+ // (web-only) excludes these rows; the "show CLI sessions" preference includes them.
7919
+ client: "cli",
7920
+ hasTrace: true,
7761
7921
  createdAt: sessionIdCreatedAt(sessionId) ?? meta.timestamp
7762
7922
  };
7763
7923
  this.deps.sessions.insertOrIgnore(row);
@@ -7792,7 +7952,7 @@ import {
7792
7952
  agentsDir as agentsDir3,
7793
7953
  isSessionMeta as isSessionMeta5,
7794
7954
  parseTraceLines as parseTraceLines2,
7795
- parseUserSteeringText,
7955
+ parseUserSteeringText as parseUserSteeringText2,
7796
7956
  readTraceTolerant as readTraceTolerant2,
7797
7957
  tracesDir as tracesDir3
7798
7958
  } from "@prismshadow/penguin-core";
@@ -7988,7 +8148,7 @@ var TraceService = class {
7988
8148
  const msg = messages[mi];
7989
8149
  const p = msg.payload;
7990
8150
  const hasOrigin = msg.origin !== void 0 && msg.origin.length > 0;
7991
- const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText(p.text) !== null;
8151
+ const isSteeringText = !hasOrigin && msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string" && parseUserSteeringText2(p.text) !== null;
7992
8152
  if (isSteeringText) continuation = true;
7993
8153
  const isImage = !hasOrigin && msg.type === "model_msg" && p.type === "image_url";
7994
8154
  if (!hasOrigin && !isSteeringText && !(isImage && steeringImages)) steeringImages = false;