@workerdeck/server 0.16.0 → 0.17.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/README.md CHANGED
@@ -208,6 +208,16 @@ OAuth, never reads or forwards tokens — see the repo README's
208
208
  VFS only when *not* restoring, and dispose per-session resources via `onClose`. Every one is a
209
209
  runtime-only failure. `createProviderRunner()` does all four; reach for the raw hook only when it
210
210
  genuinely doesn't fit.
211
+ - **`parking.persistLive` is how a *provider* session survives a restart, and it needs a durable
212
+ store to mean anything.** Claude and codex go dormant — remembered by engine session id and
213
+ resumed from the engine's own store — which a provider session cannot do, so its record carries
214
+ the state itself, written through after every turn. With the default in-memory store the option
215
+ does nothing and says nothing. It is off by default: a library must not start writing sessions'
216
+ transcripts to disk because someone upgraded, and the record holds the whole transcript in
217
+ plaintext.
218
+ - **A restored session is refreshed in place, not consumed.** A park's record *is* the session and
219
+ is deleted on wake; a live or dormant one is a way back the session still needs next time. If you
220
+ implement a `SessionStore`, do not "tidy up" a record on read.
211
221
  - **One origin is not a convenience.** A browser cannot put an `Authorization` header on a
212
222
  WebSocket upgrade, so a cookie is the only credential a tab can present on an attach, and a
213
223
  cookie is per-origin. That is what `fallback` is for — an app served from the gateway's own port.
package/build/index.d.mts CHANGED
@@ -48,6 +48,11 @@ type SessionNotificationOptions = {
48
48
  onNotification?: (notification: SessionNotification) => void; /** Delivery attempts per notification (exponential backoff). Default 3. */
49
49
  attempts?: number; /** Initial backoff between attempts. Default 500ms. */
50
50
  retryDelayMs?: number;
51
+ /** Gateway wiring, not a host option: the serve-time `SessionInfo` decoration
52
+ * (project identity today), so a webhook or push consumer reads the same
53
+ * record every REST caller does. The assembly supplies it; identity when
54
+ * absent. */
55
+ decorateInfo?: (info: SessionInfo) => SessionInfo;
51
56
  };
52
57
  /**
53
58
  * Turns session events into the handful of notifications a human away from the
@@ -111,20 +116,41 @@ declare class SessionRegistry {
111
116
  //#endregion
112
117
  //#region src/services/session-store.d.ts
113
118
  /**
114
- * A session with its live runner torn down, waiting on deferred executions.
119
+ * A session captured whole: its wire-visible info, the config to rebuild the
120
+ * runner, the engine's snapshot, and what it is waiting for.
115
121
  *
116
- * Everything needed to bring it back: the wire-visible info (so it still lists and
117
- * reads over REST while parked), the config to rebuild the runner, the engine's
118
- * snapshot, and what it is waiting for.
122
+ * Two things are stored in this one shape, and the discriminator is `kind`:
123
+ *
124
+ * - **`parked`** — the live runner is torn down and the session is waiting on
125
+ * deferred executions. The record *is* the session, so waking consumes it.
126
+ * - **`live`** — the runner is up and this is a copy taken after a turn, so the
127
+ * session survives a restart (`persistLive`). It is a way back that the
128
+ * session still needs the *next* time the process dies, so waking **refreshes
129
+ * it in place** and only `session_closed` removes it.
130
+ *
131
+ * That difference is the whole reason these are two kinds rather than one, and
132
+ * it is a correctness difference, not bookkeeping: consuming a live record on
133
+ * wake opens a window from the attach to the next turn in which the session
134
+ * exists nowhere durable. A user who opens a session, reads it and types nothing
135
+ * would lose it to a redeploy — silently, which is the failure class worth
136
+ * spending a discriminator on.
137
+ *
138
+ * They share the shape so a store, and an older server, need no new code: every
139
+ * other branch (rebuild from `config` + `snapshot`, serve `snapshot.vfs`, arm
140
+ * `executions`, subscribe past `snapshot.seq`) is already right for both.
119
141
  */
120
142
  type ParkedSessionRecord = {
121
- /** Absent on records written before dormant sessions existed — those are all parked. */kind?: 'parked';
122
- id: string; /** Session info as of the park, with `status: 'parked'`. */
143
+ /** Absent on records written before dormant sessions existed — those are all parked. */kind?: 'parked' | 'live';
144
+ id: string;
145
+ /** Session info as of the write: `parked` for a park, `idle` for a live copy —
146
+ * never `running`, which would come back as a spinner over no process. */
123
147
  info: SessionInfo;
124
148
  profile?: string; /** The config the session was created with (profile defaults already applied). */
125
149
  config: SessionRunnerConfig;
126
150
  snapshot: RunnerSnapshot;
127
- executions: ParkedExecution[];
151
+ /** Empty for a live record: an idle session is waiting on nothing, so there is
152
+ * nothing for `hydrate` to arm a watchdog for. */
153
+ executions: ParkedExecution[]; /** When it was written. Named for the park that came first. */
128
154
  parkedAt: number;
129
155
  };
130
156
  /**
@@ -246,6 +272,24 @@ type SessionParkOptions = {
246
272
  /** Wait this long after the last client detaches before parking, so a reconnect
247
273
  * (a wifi blip, a page reload) doesn't cost a teardown. Default 2000. */
248
274
  parkDelayMs?: number;
275
+ /**
276
+ * Keep a live session's snapshot written through to the store, so it survives
277
+ * a gateway restart. **Off by default**: a library must not start writing a
278
+ * session's whole transcript to disk because someone upgraded.
279
+ *
280
+ * This is the restart story for the engine that cannot go dormant. Dormancy
281
+ * works by remembering an *engine* session id to resume from, which the
282
+ * provider engine does not have — there is no store behind it, the history
283
+ * lives in the runner. What it has instead is `snapshot()`, so the record
284
+ * carries the state itself and rehydration is the ordinary `restore` path.
285
+ * Between the two, every engine survives a restart.
286
+ *
287
+ * Written at the end of a turn, never on a shutdown hook — a `kill -9`, an OOM
288
+ * or a pulled power cable run no hook, and that is precisely the case this
289
+ * exists for. Turn-end is also a natural rate limit: one write per turn, not
290
+ * one per token.
291
+ */
292
+ persistLive?: boolean;
249
293
  /** Grace given at {@link SessionParkManager.hydrate} to an execution whose
250
294
  * deadline passed while the server was down. Its result could not have been
251
295
  * delivered during the outage, so failing it the instant the process is back
@@ -676,6 +720,22 @@ type WorkerServerOptions = {
676
720
  /** Grace given on boot to an execution whose deadline passed while the server
677
721
  * was down (durable stores only — nothing else survives a restart). Default 60000. */
678
722
  expiredGraceMs?: number;
723
+ /**
724
+ * Keep live `provider` sessions written through to the store after each
725
+ * turn, so they survive a gateway restart. **Off by default** — this writes
726
+ * a session's whole transcript to `store` and a library must not start doing
727
+ * that because someone upgraded.
728
+ *
729
+ * It is the restart story for the one engine dormancy cannot cover: claude
730
+ * and codex are remembered by *engine session id* and resumed from their own
731
+ * on-disk store, which a provider session does not have. Pair it with a
732
+ * durable `store` — with the default in-memory one it does nothing a park
733
+ * did not already do.
734
+ *
735
+ * The record is rebuilt lazily, on first attach, exactly like a dormant one;
736
+ * a boot with fifty remembered sessions spawns nothing.
737
+ */
738
+ persistLive?: boolean;
679
739
  /** Park/remember/resume failures — storage or engine-assembly problems, not
680
740
  * session errors. 'remember' is the write that lets a live session survive a
681
741
  * restart; losing one costs that session its way back and nothing else. */
package/build/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { createServer } from "node:http";
2
2
  import { WebSocketServer } from "ws";
3
3
  import { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, createEngineSession, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
4
4
  import { JobQueue } from "@workerdeck/queue";
5
- import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, supportsPermissionMode } from "@workerdeck/protocol";
5
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, imagePartRef, supportsPermissionMode } from "@workerdeck/protocol";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
@@ -214,6 +214,18 @@ function parseSessionRoute(basePath, url) {
214
214
  produced: true,
215
215
  producedFileId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
216
216
  };
217
+ if (parts.length === 3 && parts[1] === "project" && parts[2] === "icon") return {
218
+ id: decodeURIComponent(parts[0]),
219
+ projectIcon: true
220
+ };
221
+ if (parts.length === 4 && parts[1] === "events" && parts[3] === "result") {
222
+ const seq = Number(parts[2]);
223
+ if (!Number.isInteger(seq) || seq < 0) return null;
224
+ return {
225
+ id: decodeURIComponent(parts[0]),
226
+ resultSeq: seq
227
+ };
228
+ }
217
229
  if (parts.length <= 3 && parts[1] === "mcp") return {
218
230
  id: decodeURIComponent(parts[0]),
219
231
  mcp: true,
@@ -335,7 +347,9 @@ function invalidRequest(requested) {
335
347
  /** Both sides are realpath output, so this is a pure lexical question — but a
336
348
  * bare prefix check gets the boundary wrong (`/x/app` would swallow
337
349
  * `/x/application`). `relative` answers it exactly: inside iff the walk from
338
- * root to candidate is empty or never has to leave through `..`. */
350
+ * root to candidate is empty or never has to leave through `..`. Exported for
351
+ * the project-icon resolver (`project-info.ts`), which makes the same claim
352
+ * against a project root; both callers must hand it realpath output only. */
339
353
  function contained(rootCanonical, candidate) {
340
354
  const rel = relative(rootCanonical, candidate);
341
355
  return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
@@ -1116,6 +1130,8 @@ function withinRoots(sessions, roots, limit, offset = 0) {
1116
1130
  }
1117
1131
  //#endregion
1118
1132
  //#region src/services/session-store.ts
1133
+ /** A live record is refreshed in place on wake; a park is consumed by it. */
1134
+ const isLiveRecord = (record) => record.kind === "live";
1119
1135
  const isDormant = (record) => record.kind === "dormant";
1120
1136
  /** Single-process, no persistence: parks survive a client disconnect, not a restart. */
1121
1137
  var MemorySessionStore = class {
@@ -1462,12 +1478,295 @@ async function handleProducedFiles(ctx, req, res, sessionId, fileId) {
1462
1478
  });
1463
1479
  }
1464
1480
  //#endregion
1481
+ //#region src/services/project-info.ts
1482
+ /**
1483
+ * Project identity discovery: the `.workerdeck.json` ancestor walk behind
1484
+ * `SessionInfo.project`, and the read side of `GET /sessions/:id/project/icon`.
1485
+ *
1486
+ * The gateway resolves this — not each client — because the file lives on the
1487
+ * gateway's filesystem, which a phone or a remote browser cannot see. It is
1488
+ * stamped onto `SessionInfo` at **serve time** (`withProject`), never persisted:
1489
+ * a copy captured into a parking record would replay a stale name forever,
1490
+ * where a serve-time read picks up an edited file on every session at once
1491
+ * within the TTL. That is the profile tracker's 0%-after-reset placement
1492
+ * argument, applied to a filesystem fact instead of a clock.
1493
+ *
1494
+ * Every failure degrades to "no project" and never to an error: a session must
1495
+ * not fail, or even warn, because of a display declaration. A malformed,
1496
+ * oversized, or symlinked `.workerdeck.json` is *skipped and the walk
1497
+ * continues* — a broken file in `packages/ui` must not shadow the repo root's
1498
+ * valid one — and inside a valid file each field degrades on its own (junk
1499
+ * name → the root's basename, junk icon → no icon).
1500
+ *
1501
+ * The icon is the security surface, because its path comes out of a config
1502
+ * file the *agent* can write (the session cwd is the agent's working tree).
1503
+ * The rules are `host-files.ts`'s, not `cwdAllowed`'s (docs/GOTCHAS.md §Host
1504
+ * filesystem): the declared path is resolved against the project root and then
1505
+ * realpath'd **whole**, containment is decided on the canonical result only
1506
+ * (so `"icon": "../../../../etc/key.png"` and a planted `icon.png → ~/.ssh/…`
1507
+ * symlink both fail the same check), the open goes through `readContained`
1508
+ * (O_NOFOLLOW, fstat-before-io), and the media type comes from the *declared*
1509
+ * extension — png and svg only, by decision. A refused icon is
1510
+ * indistinguishable on the wire and on the route from a never-declared one:
1511
+ * `icon` absent, the route 404s. The one disclosure this feature accepts is
1512
+ * inherent to it: the walk reads ancestors of a vetted cwd, so a project file
1513
+ * an operator placed *above* their roots (`~/.workerdeck.json`) applies to
1514
+ * everything under it — nearest-wins from the cwd, exactly git's own
1515
+ * discovery, and the file is a display declaration by definition.
1516
+ *
1517
+ * Cache: per exact cwd string, TTL'd, with negative results cached at the same
1518
+ * price — `GET /sessions` polls at 1.2s while anything is working and the hit
1519
+ * path must be a Map lookup, never a walk. Keyed by cwd rather than by root
1520
+ * because the root is not known until the walk has run. Bounded by sweeping
1521
+ * expired entries once the map outgrows any plausible live session count.
1522
+ */
1523
+ const PROJECT_FILE = ".workerdeck.json";
1524
+ /** A config file, not a document — anything bigger is skipped as malformed. */
1525
+ const MAX_PROJECT_FILE_BYTES = 64 * 1024;
1526
+ /** Display name clip — a list row's width, not a document's. */
1527
+ const MAX_NAME_CHARS = 80;
1528
+ /** lucide's naming: lowercase kebab-case. Shape-only — the gateway has no icon
1529
+ * catalog and must not grow one; an unknown-but-well-formed name ships and the
1530
+ * client falls back (a stale row, never withheld state). */
1531
+ const GLYPH_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
1532
+ const DEFAULT_TTL_MS = 3e4;
1533
+ /** Sweep threshold: above this many cached cwds, expired entries are evicted
1534
+ * on the next resolve so dead sessions' keys do not accumulate forever. */
1535
+ const SWEEP_ABOVE = 256;
1536
+ var ProjectInfoService = class {
1537
+ #ttlMs;
1538
+ #byCwd = /* @__PURE__ */ new Map();
1539
+ constructor(options = {}) {
1540
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1541
+ }
1542
+ /**
1543
+ * The serve-time decoration: `info` with `project` stamped on, or the same
1544
+ * object untouched when there is nothing to add — the common case on a list
1545
+ * poll, so no allocation for it (replaySlice's same-object rule).
1546
+ */
1547
+ withProject(info) {
1548
+ if (!info.cwd) return info;
1549
+ const project = this.#resolve(info.cwd).project;
1550
+ return project ? {
1551
+ ...info,
1552
+ project
1553
+ } : info;
1554
+ }
1555
+ /** The icon route's read side: the canonical, contained icon file for this
1556
+ * session's cwd — resolved from the gateway's own cache, never from anything
1557
+ * the client named. Undefined = no project, no icon, or an icon refused. */
1558
+ iconFor(cwd) {
1559
+ if (!cwd) return void 0;
1560
+ return this.#resolve(cwd).icon;
1561
+ }
1562
+ #resolve(cwd) {
1563
+ const now = Date.now();
1564
+ const held = this.#byCwd.get(cwd);
1565
+ if (held && held.expiresAt > now) return held;
1566
+ if (this.#byCwd.size > SWEEP_ABOVE) {
1567
+ for (const [key, entry] of this.#byCwd) if (entry.expiresAt <= now) this.#byCwd.delete(key);
1568
+ }
1569
+ const fresh = {
1570
+ ...discover(cwd),
1571
+ expiresAt: now + this.#ttlMs
1572
+ };
1573
+ this.#byCwd.set(cwd, fresh);
1574
+ return fresh;
1575
+ }
1576
+ };
1577
+ /** The ancestor walk: realpath the cwd (a lexical walk over `/tmp/x` would
1578
+ * miss the file at `/private/tmp/x`, and canonicalizing here is what makes
1579
+ * `root` — the grouping key — spell identically for every cwd inside one
1580
+ * project), then nearest `.workerdeck.json` wins, to the filesystem root. */
1581
+ function discover(cwd) {
1582
+ if (!isAbsolute(cwd) || cwd.includes("\0")) return {};
1583
+ let dir;
1584
+ try {
1585
+ dir = realpathSync(cwd);
1586
+ } catch {
1587
+ return {};
1588
+ }
1589
+ for (;;) {
1590
+ const found = tryLoad(join(dir, PROJECT_FILE), dir);
1591
+ if (found) return found;
1592
+ const parent = dirname(dir);
1593
+ if (parent === dir) return {};
1594
+ dir = parent;
1595
+ }
1596
+ }
1597
+ /** One directory's verdict: a project record, or undefined to keep walking —
1598
+ * which is the same answer for "absent" and for every malformed shape. */
1599
+ function tryLoad(file, root) {
1600
+ let stat;
1601
+ try {
1602
+ stat = lstatSync(file);
1603
+ } catch {
1604
+ return;
1605
+ }
1606
+ if (!stat.isFile() || stat.size > MAX_PROJECT_FILE_BYTES) return void 0;
1607
+ const read = readContained(file);
1608
+ if (!read.ok) return void 0;
1609
+ let parsed;
1610
+ try {
1611
+ parsed = JSON.parse(read.data.toString("utf8"));
1612
+ } catch {
1613
+ return;
1614
+ }
1615
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1616
+ const raw = parsed;
1617
+ const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, MAX_NAME_CHARS) : basename(root);
1618
+ const icon = classifyIcon(raw.icon, root);
1619
+ return {
1620
+ project: {
1621
+ name,
1622
+ root,
1623
+ ...icon ? { icon: icon.wire } : {}
1624
+ },
1625
+ ...icon?.resolved ? { icon: icon.resolved } : {}
1626
+ };
1627
+ }
1628
+ /**
1629
+ * The one-string icon rule (documented on protocol's `ProjectInfo`): ends in
1630
+ * `.png`/`.svg` → repo-relative image path, else lucide-shaped glyph name,
1631
+ * else ignored. Total and collision-free — a glyph name contains no dot.
1632
+ */
1633
+ function classifyIcon(value, root) {
1634
+ if (typeof value !== "string") return void 0;
1635
+ const declared = value.trim();
1636
+ if (!declared || declared.includes("\0") || declared.length > 512) return void 0;
1637
+ const lower = declared.toLowerCase();
1638
+ const mediaType = lower.endsWith(".png") ? "image/png" : lower.endsWith(".svg") ? "image/svg+xml" : void 0;
1639
+ if (!mediaType) {
1640
+ if (!GLYPH_RE.test(declared) || declared.length > 64) return void 0;
1641
+ return { wire: {
1642
+ type: "glyph",
1643
+ name: declared
1644
+ } };
1645
+ }
1646
+ if (isAbsolute(declared) || declared.includes("\\")) return void 0;
1647
+ let canonical;
1648
+ try {
1649
+ canonical = realpathSync(resolve(root, declared));
1650
+ } catch {
1651
+ return;
1652
+ }
1653
+ if (!contained(root, canonical)) return void 0;
1654
+ let stat;
1655
+ try {
1656
+ stat = lstatSync(canonical);
1657
+ } catch {
1658
+ return;
1659
+ }
1660
+ if (!stat.isFile() || stat.size === 0 || stat.size > 524288) return void 0;
1661
+ const read = readContained(canonical);
1662
+ if (!read.ok || read.data.length > 524288) return void 0;
1663
+ const hash = createHash("sha256").update(read.data).digest("hex");
1664
+ return {
1665
+ wire: {
1666
+ type: "image",
1667
+ mediaType,
1668
+ hash
1669
+ },
1670
+ resolved: {
1671
+ path: canonical,
1672
+ mediaType,
1673
+ hash
1674
+ }
1675
+ };
1676
+ }
1677
+ //#endregion
1678
+ //#region src/routes/project-icon.ts
1679
+ function handleProjectIcon(projects, req, res, cwd) {
1680
+ if (req.method !== "GET") {
1681
+ json(res, 405, { error: "method not allowed" });
1682
+ return;
1683
+ }
1684
+ const icon = projects.iconFor(cwd);
1685
+ if (!icon) {
1686
+ json(res, 404, { error: "no project icon" });
1687
+ return;
1688
+ }
1689
+ const etag = `"${icon.hash}"`;
1690
+ if (req.headers["if-none-match"] === etag) {
1691
+ res.writeHead(304, { etag });
1692
+ res.end();
1693
+ return;
1694
+ }
1695
+ const read = readContained(icon.path);
1696
+ if (!read.ok || read.data.length > 524288) {
1697
+ json(res, 404, { error: "no project icon" });
1698
+ return;
1699
+ }
1700
+ res.writeHead(200, {
1701
+ "content-type": icon.mediaType,
1702
+ "content-length": read.data.length,
1703
+ etag,
1704
+ "cache-control": "private, max-age=300",
1705
+ "content-disposition": "attachment; filename=\"project-icon\"",
1706
+ "x-content-type-options": "nosniff"
1707
+ });
1708
+ res.end(read.data);
1709
+ }
1710
+ //#endregion
1711
+ //#region src/routes/tool-results.ts
1712
+ function handleToolResult(req, res, lookup, seq) {
1713
+ if (req.method !== "GET") {
1714
+ json(res, 405, { error: "method not allowed" });
1715
+ return;
1716
+ }
1717
+ const url = new URL(req.url ?? "/", "http://internal");
1718
+ const toolUseId = url.searchParams.get("toolUseId");
1719
+ if (!toolUseId) {
1720
+ json(res, 400, { error: "toolUseId is required" });
1721
+ return;
1722
+ }
1723
+ if (!lookup) {
1724
+ json(res, 501, { error: "engine does not serve stored events" });
1725
+ return;
1726
+ }
1727
+ const event = lookup(seq);
1728
+ if (!event || event.type !== "user_message" || !Array.isArray(event.message.content)) {
1729
+ json(res, 404, { error: "no such event" });
1730
+ return;
1731
+ }
1732
+ const block = event.message.content.find((candidate) => candidate.type === "tool_result" && candidate.tool_use_id === toolUseId);
1733
+ if (!block) {
1734
+ json(res, 404, { error: "no such tool result in that event" });
1735
+ return;
1736
+ }
1737
+ const partParam = url.searchParams.get("part");
1738
+ if (partParam !== null) {
1739
+ const index = Number(partParam);
1740
+ const parts = block.content;
1741
+ const part = Number.isInteger(index) && Array.isArray(parts) ? parts[index] : void 0;
1742
+ const ref = part ? imagePartRef(part, index) : void 0;
1743
+ if (!ref) {
1744
+ json(res, 404, { error: "no such image part in that tool result" });
1745
+ return;
1746
+ }
1747
+ const source = part.source;
1748
+ const bytes = Buffer.from(source?.data ?? "", "base64");
1749
+ res.writeHead(200, {
1750
+ "content-type": ref.media_type,
1751
+ "content-length": String(bytes.length)
1752
+ });
1753
+ res.end(bytes);
1754
+ return;
1755
+ }
1756
+ json(res, 200, {
1757
+ seq,
1758
+ toolUseId,
1759
+ content: url.searchParams.get("imageRefs") === "1" && Array.isArray(block.content) ? block.content.map((part, index) => imagePartRef(part, index) ?? part) : block.content ?? "",
1760
+ isError: block.is_error === true
1761
+ });
1762
+ }
1763
+ //#endregion
1465
1764
  //#region src/routes/sessions.ts
1466
1765
  async function handleSessions(ctx, req, res, route, auth) {
1467
- const { attachmentStore, auth: authSvc, availability, bridge, factory, parking, producedFiles, registry } = ctx;
1766
+ const { attachmentStore, auth: authSvc, availability, bridge, factory, parking, producedFiles, projects, registry } = ctx;
1468
1767
  if (!route.id) {
1469
1768
  if (req.method === "GET") {
1470
- json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()].filter((session) => authSvc.canSee(auth, session)) });
1769
+ json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()].filter((session) => authSvc.canSee(auth, session)).map((session) => projects.withProject(session)) });
1471
1770
  return;
1472
1771
  }
1473
1772
  if (req.method === "POST") {
@@ -1506,7 +1805,7 @@ async function handleSessions(ctx, req, res, route, auth) {
1506
1805
  body.profile = resolved.profile?.name;
1507
1806
  const runner = await factory.createRunner(factory.buildRunnerConfig(body));
1508
1807
  factory.watchAuthSource(runner);
1509
- json(res, 201, { session: runner.info() });
1808
+ json(res, 201, { session: projects.withProject(runner.info()) });
1510
1809
  return;
1511
1810
  }
1512
1811
  json(res, 405, { error: "method not allowed" });
@@ -1574,6 +1873,15 @@ async function handleSessions(ctx, req, res, route, auth) {
1574
1873
  await handleProducedFiles(ctx, req, res, route.id, route.producedFileId);
1575
1874
  return;
1576
1875
  }
1876
+ if (route.projectIcon) {
1877
+ handleProjectIcon(projects, req, res, (runner?.info() ?? parked.info).cwd);
1878
+ return;
1879
+ }
1880
+ if (route.resultSeq !== void 0) {
1881
+ const snapshot = parked && !isDormant(parked) ? parked.snapshot.events : void 0;
1882
+ handleToolResult(req, res, runner?.eventAt?.bind(runner) ?? (snapshot && ((seq) => snapshot.find((event) => event.seq === seq))), route.resultSeq);
1883
+ return;
1884
+ }
1577
1885
  if (route.permissionId) {
1578
1886
  if (req.method !== "POST") {
1579
1887
  json(res, 405, { error: "method not allowed" });
@@ -1596,7 +1904,7 @@ async function handleSessions(ctx, req, res, route, auth) {
1596
1904
  return;
1597
1905
  }
1598
1906
  if (req.method === "GET") {
1599
- json(res, 200, { session: runner?.info() ?? parked.info });
1907
+ json(res, 200, { session: projects.withProject(runner?.info() ?? parked.info) });
1600
1908
  return;
1601
1909
  }
1602
1910
  if (req.method === "PATCH") {
@@ -1614,7 +1922,7 @@ async function handleSessions(ctx, req, res, route, auth) {
1614
1922
  runner.setTitle(title || void 0);
1615
1923
  parking.touch(runner);
1616
1924
  }
1617
- json(res, 200, { session: runner.info() });
1925
+ json(res, 200, { session: projects.withProject(runner.info()) });
1618
1926
  return;
1619
1927
  }
1620
1928
  if (req.method === "DELETE") {
@@ -1623,10 +1931,10 @@ async function handleSessions(ctx, req, res, route, auth) {
1623
1931
  await parking.discard(route.id);
1624
1932
  attachmentStore.drop(route.id);
1625
1933
  producedFiles.drop(route.id);
1626
- json(res, 200, { session: runner?.info() ?? {
1934
+ json(res, 200, { session: projects.withProject(runner?.info() ?? {
1627
1935
  ...parked.info,
1628
1936
  status: "closed"
1629
- } });
1937
+ }) });
1630
1938
  return;
1631
1939
  }
1632
1940
  json(res, 405, { error: "method not allowed" });
@@ -1637,19 +1945,25 @@ function attachClient(ctx, ws, runner, req) {
1637
1945
  const { bridge, parking } = ctx;
1638
1946
  const url = new URL(req.url ?? "/", "http://internal");
1639
1947
  const afterSeq = Number(url.searchParams.get("afterSeq") ?? "0") || 0;
1948
+ const truncateResults = url.searchParams.get("truncateResults") === "1";
1949
+ const imageRefs = url.searchParams.get("imageRefs") === "1";
1640
1950
  const send = (frame) => {
1641
1951
  if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
1642
1952
  };
1643
1953
  send({
1644
1954
  type: "attached",
1645
1955
  protocolVersion: PROTOCOL_VERSION,
1646
- session: runner.info(),
1956
+ session: ctx.projects.withProject(runner.info()),
1647
1957
  replayingFrom: afterSeq
1648
1958
  });
1649
1959
  const unsubscribe = runner.subscribe((event) => send({
1650
1960
  type: "event",
1651
1961
  event
1652
- }), afterSeq, { coalesceReplay: true });
1962
+ }), afterSeq, {
1963
+ coalesceReplay: true,
1964
+ truncateResults,
1965
+ imageRefs
1966
+ });
1653
1967
  const detachBridge = bridge.attach(runner.id, send);
1654
1968
  ws.on("message", (data) => {
1655
1969
  let frame;
@@ -2241,7 +2555,7 @@ var SessionNotifier = class {
2241
2555
  const notification = {
2242
2556
  ...body,
2243
2557
  sessionId: runner.id,
2244
- session: runner.info(),
2558
+ session: (this.#options.decorateInfo ?? ((info) => info))(runner.info()),
2245
2559
  seq,
2246
2560
  ts
2247
2561
  };
@@ -2353,6 +2667,7 @@ var SessionParkManager = class {
2353
2667
  */
2354
2668
  touch(runner) {
2355
2669
  this.#rememberDormant(runner);
2670
+ this.#persistLive(runner);
2356
2671
  }
2357
2672
  /**
2358
2673
  * Adopt the store's contents (a durable store after a restart): re-index the
@@ -2396,6 +2711,13 @@ var SessionParkManager = class {
2396
2711
  if (event.status === "parked") this.#park(runner);
2397
2712
  else this.#rememberDormant(runner);
2398
2713
  return;
2714
+ case "turn_result":
2715
+ this.#persistLive(runner);
2716
+ return;
2717
+ case "permission_mode_changed":
2718
+ case "model_changed":
2719
+ this.#persistLive(runner);
2720
+ return;
2399
2721
  case "system_init":
2400
2722
  this.#rememberDormant(runner);
2401
2723
  return;
@@ -2534,6 +2856,62 @@ var SessionParkManager = class {
2534
2856
  });
2535
2857
  }
2536
2858
  }
2859
+ /**
2860
+ * Write a live session's snapshot through to the store, so a restart can
2861
+ * rebuild it. The counterpart to {@link #rememberDormant} for the engine that
2862
+ * has no engine-side session to resume from — same discipline, different
2863
+ * mechanism: that one remembers *where the transcript is*, this one carries it.
2864
+ *
2865
+ * The gates are the same four, plus the option and the engine's ability. The
2866
+ * `registry.get(runner.id) !== runner` check is doing the same work it does
2867
+ * there: a runner that has been evicted (parked, or replaced by a rebuild)
2868
+ * finds itself a stranger here and writes nothing, so a late event cannot
2869
+ * overwrite a park with a stale live record.
2870
+ *
2871
+ * **This must not run synchronously inside the event listener**, and that is
2872
+ * easy to lose. `turn_result` is emitted from inside the turn, *before* the
2873
+ * `finally` that clears the runner's abort controller — so a `snapshot()`
2874
+ * called straight from the listener would see a turn in flight and refuse,
2875
+ * every single time, silently. `#queue`'s microtask hop is what puts the call
2876
+ * after it. A refactor that "simplifies" this into a direct call produces a
2877
+ * write-through that never writes and nothing that says so.
2878
+ */
2879
+ async #persistLive(runner) {
2880
+ if (this.#closed || !this.#options.persistLive || !runner.snapshot) return;
2881
+ const config = this.#configs.get(runner.id);
2882
+ if (!config) return;
2883
+ if (this.#options.registry.get(runner.id) !== runner) return;
2884
+ try {
2885
+ await this.#queue(runner.id, async () => {
2886
+ if (this.#closed || this.#options.registry.get(runner.id) !== runner) return;
2887
+ const snapshot = runner.snapshot?.();
2888
+ if (!snapshot) return;
2889
+ const info = runner.info();
2890
+ const record = {
2891
+ kind: "live",
2892
+ id: runner.id,
2893
+ info: {
2894
+ ...info,
2895
+ status: "idle"
2896
+ },
2897
+ profile: info.profile,
2898
+ config: {
2899
+ ...config,
2900
+ meta: info.meta
2901
+ },
2902
+ snapshot,
2903
+ executions: snapshot.parked,
2904
+ parkedAt: Date.now()
2905
+ };
2906
+ await this.#options.store.save(record);
2907
+ });
2908
+ } catch (error) {
2909
+ this.#options.onError?.(error, {
2910
+ sessionId: runner.id,
2911
+ phase: "remember"
2912
+ });
2913
+ }
2914
+ }
2537
2915
  async #park(runner) {
2538
2916
  if (this.#closed || !runner.park) return;
2539
2917
  const id = runner.id;
@@ -2609,7 +2987,7 @@ var SessionParkManager = class {
2609
2987
  this.remember(id, record.config);
2610
2988
  this.watch(runner, isDormant(record) ? 0 : record.snapshot.seq);
2611
2989
  this.#options.onResumed?.(id, runner);
2612
- if (!isDormant(record)) await this.#queue(id, () => this.#options.store.delete(id));
2990
+ if (!isDormant(record) && !isLiveRecord(record)) await this.#queue(id, () => this.#options.store.delete(id));
2613
2991
  runner.start();
2614
2992
  return runner;
2615
2993
  }
@@ -3348,7 +3726,11 @@ function createWorkerServer(options = {}) {
3348
3726
  adapterFor,
3349
3727
  sessionEnvFor: factory.sessionEnvFor
3350
3728
  });
3351
- const notifier = new SessionNotifier(options.notifications ?? {});
3729
+ const projects = new ProjectInfoService();
3730
+ const notifier = new SessionNotifier({
3731
+ ...options.notifications,
3732
+ decorateInfo: (info) => projects.withProject(info)
3733
+ });
3352
3734
  const producedFiles = new ProducedFileStore();
3353
3735
  const registry = new SessionRegistry({ onRegister: (runner) => {
3354
3736
  notifier.watch(runner);
@@ -3374,6 +3756,7 @@ function createWorkerServer(options = {}) {
3374
3756
  store: options.parking?.store ?? new MemorySessionStore(),
3375
3757
  parkDelayMs: options.parking?.parkDelayMs,
3376
3758
  expiredGraceMs: options.parking?.expiredGraceMs,
3759
+ persistLive: options.parking?.persistLive,
3377
3760
  onError: options.parking?.onError,
3378
3761
  rebuild: (record) => isDormant(record) ? factory.buildRunner(factory.buildRunnerConfig({
3379
3762
  ...record.config,
@@ -3445,6 +3828,7 @@ function createWorkerServer(options = {}) {
3445
3828
  registry,
3446
3829
  parking,
3447
3830
  bridge,
3831
+ projects,
3448
3832
  queue,
3449
3833
  attachmentStore,
3450
3834
  producedFiles,