pi-forge 0.0.0 → 1.1.4

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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -4
  3. package/bin/pi-forge.mjs +37 -0
  4. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js +34 -0
  5. package/dist/client/assets/CodeMirrorEditor-BqaaP1EE.js.map +1 -0
  6. package/dist/client/assets/index-B-529kgJ.css +32 -0
  7. package/dist/client/assets/index-BzKzxXFs.js +392 -0
  8. package/dist/client/assets/index-BzKzxXFs.js.map +1 -0
  9. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js +3 -0
  10. package/dist/client/assets/workbox-window.prod.es5-BBnX5xw4.js.map +1 -0
  11. package/dist/client/icons/icon-192.png +0 -0
  12. package/dist/client/icons/icon-512.png +0 -0
  13. package/dist/client/icons/icon-maskable-512.png +0 -0
  14. package/dist/client/icons/icon.svg +9 -0
  15. package/dist/client/index.html +24 -0
  16. package/dist/client/manifest.webmanifest +1 -0
  17. package/dist/client/offline.html +142 -0
  18. package/dist/client/sw.js +3 -0
  19. package/dist/client/sw.js.map +1 -0
  20. package/dist/client/workbox-6d7155ed.js +3 -0
  21. package/dist/client/workbox-6d7155ed.js.map +1 -0
  22. package/dist/server/agent-resource-loader.js +126 -0
  23. package/dist/server/agent-resource-loader.js.map +1 -0
  24. package/dist/server/attachment-converters.js +96 -0
  25. package/dist/server/attachment-converters.js.map +1 -0
  26. package/dist/server/auth.js +209 -0
  27. package/dist/server/auth.js.map +1 -0
  28. package/dist/server/compaction-history.js +106 -0
  29. package/dist/server/compaction-history.js.map +1 -0
  30. package/dist/server/concurrency.js +49 -0
  31. package/dist/server/concurrency.js.map +1 -0
  32. package/dist/server/config-export.js +220 -0
  33. package/dist/server/config-export.js.map +1 -0
  34. package/dist/server/config-manager.js +528 -0
  35. package/dist/server/config-manager.js.map +1 -0
  36. package/dist/server/config.js +326 -0
  37. package/dist/server/config.js.map +1 -0
  38. package/dist/server/conversion-worker.mjs +90 -0
  39. package/dist/server/diagnostics.js +137 -0
  40. package/dist/server/diagnostics.js.map +1 -0
  41. package/dist/server/extensions-discovery.js +147 -0
  42. package/dist/server/extensions-discovery.js.map +1 -0
  43. package/dist/server/file-manager.js +734 -0
  44. package/dist/server/file-manager.js.map +1 -0
  45. package/dist/server/file-references.js +215 -0
  46. package/dist/server/file-references.js.map +1 -0
  47. package/dist/server/file-searcher.js +385 -0
  48. package/dist/server/file-searcher.js.map +1 -0
  49. package/dist/server/git-runner.js +684 -0
  50. package/dist/server/git-runner.js.map +1 -0
  51. package/dist/server/index.js +468 -0
  52. package/dist/server/index.js.map +1 -0
  53. package/dist/server/mcp/config.js +133 -0
  54. package/dist/server/mcp/config.js.map +1 -0
  55. package/dist/server/mcp/manager.js +351 -0
  56. package/dist/server/mcp/manager.js.map +1 -0
  57. package/dist/server/mcp/tool-bridge.js +173 -0
  58. package/dist/server/mcp/tool-bridge.js.map +1 -0
  59. package/dist/server/project-manager.js +301 -0
  60. package/dist/server/project-manager.js.map +1 -0
  61. package/dist/server/pty-manager.js +354 -0
  62. package/dist/server/pty-manager.js.map +1 -0
  63. package/dist/server/routes/_schemas.js +73 -0
  64. package/dist/server/routes/_schemas.js.map +1 -0
  65. package/dist/server/routes/auth.js +164 -0
  66. package/dist/server/routes/auth.js.map +1 -0
  67. package/dist/server/routes/config.js +1163 -0
  68. package/dist/server/routes/config.js.map +1 -0
  69. package/dist/server/routes/control.js +464 -0
  70. package/dist/server/routes/control.js.map +1 -0
  71. package/dist/server/routes/exec.js +217 -0
  72. package/dist/server/routes/exec.js.map +1 -0
  73. package/dist/server/routes/files.js +847 -0
  74. package/dist/server/routes/files.js.map +1 -0
  75. package/dist/server/routes/git.js +837 -0
  76. package/dist/server/routes/git.js.map +1 -0
  77. package/dist/server/routes/health.js +97 -0
  78. package/dist/server/routes/health.js.map +1 -0
  79. package/dist/server/routes/mcp.js +300 -0
  80. package/dist/server/routes/mcp.js.map +1 -0
  81. package/dist/server/routes/projects.js +259 -0
  82. package/dist/server/routes/projects.js.map +1 -0
  83. package/dist/server/routes/prompt.js +496 -0
  84. package/dist/server/routes/prompt.js.map +1 -0
  85. package/dist/server/routes/sessions.js +783 -0
  86. package/dist/server/routes/sessions.js.map +1 -0
  87. package/dist/server/routes/stream.js +69 -0
  88. package/dist/server/routes/stream.js.map +1 -0
  89. package/dist/server/routes/terminal.js +335 -0
  90. package/dist/server/routes/terminal.js.map +1 -0
  91. package/dist/server/session-registry.js +1197 -0
  92. package/dist/server/session-registry.js.map +1 -0
  93. package/dist/server/skill-overrides.js +151 -0
  94. package/dist/server/skill-overrides.js.map +1 -0
  95. package/dist/server/skills-export.js +257 -0
  96. package/dist/server/skills-export.js.map +1 -0
  97. package/dist/server/sse-bridge.js +220 -0
  98. package/dist/server/sse-bridge.js.map +1 -0
  99. package/dist/server/tool-overrides.js +277 -0
  100. package/dist/server/tool-overrides.js.map +1 -0
  101. package/dist/server/turn-diff-builder.js +280 -0
  102. package/dist/server/turn-diff-builder.js.map +1 -0
  103. package/package.json +53 -12
@@ -0,0 +1,1197 @@
1
+ import { mkdir, readdir, rm } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { createAgentSession, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
4
+ import { buildForgeResourceLoader } from "./agent-resource-loader.js";
5
+ import { config } from "./config.js";
6
+ import { makeDedupe, makeLock } from "./concurrency.js";
7
+ import { effectiveSkillsForProject } from "./config-manager.js";
8
+ import { readProjects } from "./project-manager.js";
9
+ import { filterEnabledTools, readToolOverrides } from "./tool-overrides.js";
10
+ import { discoverExtensionResources } from "./extensions-discovery.js";
11
+ import { customToolsForProject as mcpCustomToolsForProject, ensureProjectLoaded as mcpEnsureProjectLoaded, isGloballyEnabled as mcpIsGloballyEnabled, } from "./mcp/manager.js";
12
+ export class SessionNotFoundError extends Error {
13
+ constructor(id) {
14
+ super(`session not found: ${id}`);
15
+ this.name = "SessionNotFoundError";
16
+ }
17
+ }
18
+ /**
19
+ * Thrown by `forkSession` and `navigateTree` route helpers when an entryId
20
+ * doesn't resolve to a real entry on the session tree. Typed so routes can
21
+ * map it to a stable 400 response (instead of leaking the raw SDK message).
22
+ */
23
+ export class EntryNotFoundError extends Error {
24
+ constructor(id) {
25
+ super(`entry not found: ${id}`);
26
+ this.name = "EntryNotFoundError";
27
+ }
28
+ }
29
+ const registry = new Map();
30
+ /**
31
+ * Built-in pi tools we activate on every session. Pi's SDK ships
32
+ * seven `read | bash | edit | write | grep | find | ls` (see
33
+ * `node_modules/@mariozechner/pi-coding-agent/dist/core/tools/index.d.ts`),
34
+ * but only the first four are activated when `tools` is left
35
+ * undefined. We enable all seven so the agent gets first-class
36
+ * filesystem-read affordances (grep / find / ls) instead of
37
+ * shelling out via bash for every directory listing or content
38
+ * search — same UX the pi TUI ships with.
39
+ *
40
+ * Passing `tools: [...]` to `createAgentSession` ALSO filters
41
+ * customTools (MCP) by name (see agent-session.js
42
+ * `_refreshToolRegistry`), so each callsite below extends this
43
+ * list with the names of its MCP customTools before passing it
44
+ * through. Without that union, enabling the read-only set would
45
+ * silently disable MCP.
46
+ */
47
+ export const BUILTIN_TOOL_NAMES = [
48
+ "read",
49
+ "bash",
50
+ "edit",
51
+ "write",
52
+ "grep",
53
+ "find",
54
+ "ls",
55
+ ];
56
+ /**
57
+ * Build the `tools` allowlist passed to `createAgentSession` for this
58
+ * session, applying both global and per-project overrides from
59
+ * `${FORGE_DATA_DIR}/tool-overrides.json`. Allow-by-default: a
60
+ * tool is enabled unless either the global disabled set OR the
61
+ * project's tri-state override says otherwise (project explicit
62
+ * enable / disable wins; absent = inherit global).
63
+ *
64
+ * The overrides file is read FRESH per session create (not cached)
65
+ * so toggling a tool in Settings takes effect on the next new
66
+ * session without a server restart. Live sessions keep the tool
67
+ * list they were created with — same caveat as every settings
68
+ * change today.
69
+ */
70
+ async function buildToolsAllowlist(customTools, projectId, workspacePath) {
71
+ const overrides = await readToolOverrides();
72
+ // Pi extensions register tools programmatically — those names are
73
+ // invisible to BUILTIN_TOOL_NAMES and to `customTools` (which only
74
+ // covers our MCP shim). Without enumerating them here, the
75
+ // strict-allowlist semantics in the SDK's `_refreshToolRegistry`
76
+ // would silently drop every extension-contributed tool. See
77
+ // packages/server/src/extensions-discovery.ts for the discovery
78
+ // contract.
79
+ const extensionResources = await discoverExtensionResources(workspacePath);
80
+ const candidates = [
81
+ ...BUILTIN_TOOL_NAMES.map((name) => ({ family: "builtin", name })),
82
+ ...customTools.map((t) => ({ family: "mcp", name: t.name })),
83
+ ...extensionResources.tools.map((t) => ({ family: "extension", name: t.name })),
84
+ ];
85
+ return filterEnabledTools(overrides, projectId, candidates);
86
+ }
87
+ /** Match the project-manager UUID shape; defends against ad-hoc project IDs. */
88
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
89
+ /** Per-project session directory: ${SESSION_DIR}/<projectId>/. */
90
+ function sessionDirFor(projectId) {
91
+ if (projectId.length === 0 ||
92
+ projectId.includes("/") ||
93
+ projectId.includes("\\") ||
94
+ projectId === ".." ||
95
+ projectId.startsWith(".")) {
96
+ throw new Error(`session-registry: refusing path-traversal projectId: ${projectId}`);
97
+ }
98
+ // Test rigs use synthetic projectIds (e.g. `proj-<base36>`); accept those too,
99
+ // but ensure the value can't escape the session dir. UUIDs from project-manager
100
+ // satisfy UUID_RE; everything else must be a simple alphanumeric+dash token.
101
+ if (!UUID_RE.test(projectId) && !/^[A-Za-z0-9_-]+$/.test(projectId)) {
102
+ throw new Error(`session-registry: invalid projectId shape: ${projectId}`);
103
+ }
104
+ return join(config.sessionDir, projectId);
105
+ }
106
+ async function ensureSessionDir(projectId) {
107
+ const dir = sessionDirFor(projectId);
108
+ await mkdir(dir, { recursive: true });
109
+ return dir;
110
+ }
111
+ /**
112
+ * Wire a registry-owned subscription onto a live session. Updates
113
+ * lastActivityAt on every event and fans out to all currently connected
114
+ * clients. Each client's send() is wrapped so a misbehaving client cannot
115
+ * kill the whole fan-out — it gets dropped from the set instead.
116
+ *
117
+ * Note on Set mutation during iteration: ECMAScript explicitly defines
118
+ * `for...of` over a Set as safe under deletes (the iterator advances past
119
+ * removed entries without revisiting them). No copy needed.
120
+ */
121
+ function logAgentEvent(level, payload) {
122
+ // Bypass pino entirely — write directly to stderr. Pino's redact
123
+ // config + log-level filtering can drop these messages on operators
124
+ // who only set LOG_LEVEL=warn, and the SDK error path is exactly
125
+ // the surface that can't afford to be invisible. JSON-line format
126
+ // so `docker logs | jq` still works.
127
+ process.stderr.write(`${JSON.stringify({ level, time: new Date().toISOString(), ...payload })}\n`);
128
+ }
129
+ function makeSubscribeHandler(live) {
130
+ const verbose = process.env.DEBUG_AGENT_EVENTS === "1";
131
+ return live.session.subscribe((event) => {
132
+ live.lastActivityAt = new Date();
133
+ if (event.type === "agent_start") {
134
+ // Capture BEFORE the SDK appends turn messages, so the index points
135
+ // at the first message of the new turn (the user prompt or the
136
+ // steered/follow-up entry).
137
+ live.lastAgentStartIndex = live.session.messages.length;
138
+ }
139
+ // Surface SDK-level provider errors to stderr. The pi SDK swallows
140
+ // upstream HTTP failures into events rather than throwing — so a 401
141
+ // from a bad apiKey, a network reset, an invalid endpoint, etc.
142
+ // surface only via these events and are otherwise invisible to
143
+ // operators. The TUI renders this directly in chat; the pi-forge
144
+ // did not, leaving "no response" as the only signal.
145
+ //
146
+ // We hook every event the SDK emits when something goes wrong,
147
+ // because the failure path varies by provider and stage:
148
+ // - openai-completions catches → message_end with stopReason="error"
149
+ // - retryable errors → auto_retry_start (with errorMessage)
150
+ // - retry exhaustion → auto_retry_end with success=false
151
+ // - agent_end always fires; live.session.errorMessage is the
152
+ // authoritative "what just happened" field per the SDK types.
153
+ const e = event;
154
+ if (verbose) {
155
+ logAgentEvent("info", {
156
+ msg: "agent_event",
157
+ sessionId: live.sessionId,
158
+ type: e.type,
159
+ });
160
+ }
161
+ if (e.type === "message_end") {
162
+ const msg = e.message;
163
+ if (msg?.role === "assistant" &&
164
+ (msg.stopReason === "error" || msg.stopReason === "aborted")) {
165
+ const modelInfo = typeof msg.model === "object" ? msg.model : undefined;
166
+ logAgentEvent("warn", {
167
+ msg: "agent turn ended with error stopReason",
168
+ sessionId: live.sessionId,
169
+ projectId: live.projectId,
170
+ stopReason: msg.stopReason,
171
+ errorMessage: msg.errorMessage,
172
+ provider: msg.provider ?? modelInfo?.provider,
173
+ modelId: msg.modelId ?? modelInfo?.id,
174
+ });
175
+ }
176
+ }
177
+ if (e.type === "auto_retry_start") {
178
+ logAgentEvent("warn", {
179
+ msg: "SDK auto-retrying after provider error",
180
+ sessionId: live.sessionId,
181
+ attempt: e.attempt,
182
+ maxAttempts: e.maxAttempts,
183
+ delayMs: e.delayMs,
184
+ errorMessage: e.errorMessage,
185
+ });
186
+ }
187
+ if (e.type === "auto_retry_end" && e.success === false) {
188
+ logAgentEvent("warn", {
189
+ msg: "SDK auto-retry exhausted",
190
+ sessionId: live.sessionId,
191
+ attempt: e.attempt,
192
+ finalError: e.finalError,
193
+ });
194
+ }
195
+ // Enrich `agent_end` with the session's authoritative
196
+ // errorMessage BEFORE fan-out. The SDK's native `agent_end` event
197
+ // carries no error field — the failure detail lives on
198
+ // `live.session.errorMessage` (per the SDK type). Without this
199
+ // enrichment, a context-overflow / 401 / 5xx ends up emitting an
200
+ // `agent_end` with no detail, the chat UI hides its spinner with
201
+ // no error banner, and the user sees an empty assistant message.
202
+ let outboundEvent = event;
203
+ if (e.type === "agent_end") {
204
+ const errMsg = live.session.errorMessage;
205
+ if (errMsg !== undefined && errMsg !== "") {
206
+ logAgentEvent("warn", {
207
+ msg: "agent_end with session.errorMessage",
208
+ sessionId: live.sessionId,
209
+ errorMessage: errMsg,
210
+ });
211
+ // Forward a merged event that includes the error detail. Cast
212
+ // through unknown — the SDK's union doesn't declare an
213
+ // errorMessage field on agent_end (it expects callers to read
214
+ // session.errorMessage themselves), but the wire shape is what
215
+ // the browser consumes and it tolerates the extra field.
216
+ outboundEvent = {
217
+ ...event,
218
+ errorMessage: errMsg,
219
+ };
220
+ }
221
+ else if (verbose) {
222
+ logAgentEvent("info", {
223
+ msg: "agent_end (no error)",
224
+ sessionId: live.sessionId,
225
+ });
226
+ }
227
+ }
228
+ for (const client of live.clients) {
229
+ try {
230
+ client.send(outboundEvent);
231
+ }
232
+ catch {
233
+ // Drop the client on send failure — Phase 5's SSE adapter will
234
+ // also call disposeClient on its socket close hook.
235
+ live.clients.delete(client);
236
+ }
237
+ }
238
+ });
239
+ }
240
+ export async function createSession(projectId, workspacePath) {
241
+ const dir = await ensureSessionDir(projectId);
242
+ const sessionManager = SessionManager.create(workspacePath, dir);
243
+ // No model is passed — validation happens at prompt() time. This means a
244
+ // session can be created without any LLM credentials configured, which is
245
+ // important for the Phase 4 test to run in CI without secrets.
246
+ //
247
+ // agentDir IS passed: without it, the SDK falls back to ~/.pi/agent and
248
+ // ignores PI_CONFIG_DIR entirely, breaking auth.json/models.json wiring
249
+ // for Phase 6's prompt route.
250
+ const customTools = await resolveMcpCustomTools(projectId, workspacePath);
251
+ const settingsManager = await buildSessionSettingsManager(workspacePath, projectId);
252
+ const resourceLoader = await buildForgeResourceLoader(workspacePath, config.piConfigDir, settingsManager, projectId);
253
+ const { session } = await createAgentSession({
254
+ cwd: workspacePath,
255
+ sessionManager,
256
+ settingsManager,
257
+ resourceLoader,
258
+ agentDir: config.piConfigDir,
259
+ customTools,
260
+ tools: await buildToolsAllowlist(customTools, projectId, workspacePath),
261
+ });
262
+ const now = new Date();
263
+ // Build the LiveSession in two passes so unsubscribe is the real handle by
264
+ // the time the object is observable elsewhere — kills the M3 race window
265
+ // (where a synchronous concurrent dispose could see the no-op unsubscribe).
266
+ const live = {
267
+ session,
268
+ sessionId: session.sessionId,
269
+ projectId,
270
+ workspacePath,
271
+ clients: new Set(),
272
+ createdAt: now,
273
+ lastActivityAt: now,
274
+ lastAgentStartIndex: undefined,
275
+ unsubscribe: () => undefined,
276
+ };
277
+ live.unsubscribe = makeSubscribeHandler(live);
278
+ registry.set(live.sessionId, live);
279
+ // Set a meaningful default name on the new session so the sidebar
280
+ // doesn't show every fresh-create as the indistinguishable
281
+ // "session abc1234" fallback. Pattern: "New session" with a numeric
282
+ // suffix to disambiguate against existing siblings in this project.
283
+ // Best-effort — the session is fully usable regardless. The user
284
+ // can rename via the sidebar's inline rename at any time.
285
+ try {
286
+ const siblings = await listSessionsForProject(projectId, workspacePath);
287
+ const existingNames = new Set(siblings
288
+ .filter((s) => s.sessionId !== live.sessionId)
289
+ .map((s) => s.name)
290
+ .filter((n) => typeof n === "string"));
291
+ let candidate = "New session";
292
+ let n = 2;
293
+ while (existingNames.has(candidate)) {
294
+ candidate = `New session (${n})`;
295
+ n += 1;
296
+ }
297
+ session.setSessionName(candidate);
298
+ }
299
+ catch {
300
+ // Naming failure is non-fatal; leave the SDK default and let the
301
+ // sidebar fall back to "session <id>" if needed.
302
+ }
303
+ return live;
304
+ }
305
+ export function getSession(sessionId) {
306
+ return registry.get(sessionId);
307
+ }
308
+ /**
309
+ * Return the live sessions, optionally filtered by project. Order is the
310
+ * registry's Map insertion order — caller is responsible for sorting if a
311
+ * particular order is wanted. Use `listSessionsForProject` if you want a
312
+ * recency-sorted unified view across live and disk.
313
+ */
314
+ export function listSessions(projectId) {
315
+ const all = Array.from(registry.values());
316
+ return projectId === undefined ? all : all.filter((s) => s.projectId === projectId);
317
+ }
318
+ /**
319
+ * Update lastActivityAt on a live session. Routes should call this when a
320
+ * user "views" a session (opens the panel) so the sidebar's recency ordering
321
+ * reflects view activity, not just events from the agent loop. No-op if the
322
+ * session isn't live.
323
+ */
324
+ export function touchSession(sessionId) {
325
+ const live = registry.get(sessionId);
326
+ if (live !== undefined)
327
+ live.lastActivityAt = new Date();
328
+ }
329
+ /**
330
+ * In-flight dedupe for concurrent resumeSession calls on the same id.
331
+ * Without this, two near-simultaneous SSE connects (or the three concurrent
332
+ * resumes triggered by the client opening a session — /messages, /tree,
333
+ * /context) each call createAgentSession and end up creating two
334
+ * AgentSession instances backing the same JSONL file. The second
335
+ * registry.set() wins, leaking the first session and any clients that
336
+ * landed on it; both then write to the same file concurrently.
337
+ */
338
+ const resumeInflight = makeDedupe();
339
+ /**
340
+ * Sessions that were just disposed and should NOT be re-resumed for a
341
+ * brief grace window. Without this, a polling SSE client (e.g. a stale
342
+ * tab still trying to reconnect) can win the race against
343
+ * `deleteColdSession`'s "is it live?" check by re-resuming the session
344
+ * between the dispose and the file unlink — leaving the user's UI
345
+ * showing "Failed to delete" while the session keeps consuming tokens.
346
+ *
347
+ * Maps sessionId → setTimeout handle so we can clear the tombstone if
348
+ * the session legitimately needs to come back (e.g. a different code
349
+ * path explicitly resumes after dispose, which is rare).
350
+ */
351
+ const TOMBSTONE_MS = 1500;
352
+ const disposeTombstones = new Map();
353
+ export class SessionTombstonedError extends Error {
354
+ constructor(sessionId) {
355
+ super(`session ${sessionId} was just disposed`);
356
+ this.name = "SessionTombstonedError";
357
+ }
358
+ }
359
+ const forkLocks = new Map();
360
+ function getForkLock(sessionId) {
361
+ let lock = forkLocks.get(sessionId);
362
+ if (lock === undefined) {
363
+ lock = makeLock();
364
+ forkLocks.set(sessionId, lock);
365
+ }
366
+ return lock;
367
+ }
368
+ /**
369
+ * Resume a session from disk into the registry. If `sessionId` is already
370
+ * live, returns the existing LiveSession unchanged. Otherwise locates the
371
+ * .jsonl file via SessionManager.list, opens it, and wires it into the
372
+ * registry. Throws SessionNotFoundError if the file isn't on disk.
373
+ *
374
+ * Concurrent calls for the same sessionId share a single in-flight
375
+ * AgentSession creation — see resumeInflight.
376
+ */
377
+ export async function resumeSession(sessionId, projectId, workspacePath) {
378
+ const existing = registry.get(sessionId);
379
+ if (existing)
380
+ return existing;
381
+ // Tombstone check: a session that was just disposed should not be
382
+ // re-resumed by a polling client racing against the operator's delete.
383
+ if (disposeTombstones.has(sessionId)) {
384
+ throw new SessionTombstonedError(sessionId);
385
+ }
386
+ return resumeInflight(sessionId, async () => {
387
+ // Re-check after lock acquisition: another resume may have raced
388
+ // ahead and populated the registry while we were queued.
389
+ const raced = registry.get(sessionId);
390
+ if (raced)
391
+ return raced;
392
+ const dir = sessionDirFor(projectId);
393
+ // Use our own discovery (not SessionManager.list directly) so
394
+ // pi-subagents child sessions, which live one level deeper at
395
+ // `<dir>/<parentId>/<runId>/<childId>.jsonl`, are also resolvable
396
+ // by id. Top-level sessions are returned alongside children.
397
+ const discovered = await discoverSessionsOnDisk(projectId, workspacePath);
398
+ const match = discovered.find((s) => s.sessionId === sessionId);
399
+ if (match === undefined) {
400
+ // Diagnostic log so a missing-session resume failure is
401
+ // explicit in stderr (the client just sees a 404 SSE
402
+ // disconnect, which doesn't tell us WHICH discovery missed).
403
+ process.stderr.write(JSON.stringify({
404
+ level: "warn",
405
+ time: new Date().toISOString(),
406
+ msg: "resume-session-not-found",
407
+ projectId,
408
+ sessionId,
409
+ discoveredIds: discovered.map((s) => s.sessionId),
410
+ }) + "\n");
411
+ throw new SessionNotFoundError(sessionId);
412
+ }
413
+ process.stderr.write(JSON.stringify({
414
+ level: "info",
415
+ time: new Date().toISOString(),
416
+ msg: "resume-session-found",
417
+ projectId,
418
+ sessionId,
419
+ path: match.path,
420
+ parentSessionId: match.parentSessionId,
421
+ }) + "\n");
422
+ // For child sessions, hand SessionManager.open the *child's* run
423
+ // dir as the sessionDir so any subsequent file operations the SDK
424
+ // performs land alongside the existing JSONL rather than in the
425
+ // project's top-level dir. For top-level sessions, the run dir
426
+ // collapses to the project session dir.
427
+ const childSessionDir = match.parentSessionId !== undefined ? join(match.path, "..") : dir;
428
+ const sessionManager = SessionManager.open(match.path, childSessionDir, workspacePath);
429
+ const customTools = await resolveMcpCustomTools(projectId, workspacePath);
430
+ const settingsManager = await buildSessionSettingsManager(workspacePath, projectId);
431
+ const resourceLoader = await buildForgeResourceLoader(workspacePath, config.piConfigDir, settingsManager, projectId);
432
+ const { session } = await createAgentSession({
433
+ cwd: workspacePath,
434
+ sessionManager,
435
+ settingsManager,
436
+ resourceLoader,
437
+ agentDir: config.piConfigDir,
438
+ customTools,
439
+ tools: await buildToolsAllowlist(customTools, projectId, workspacePath),
440
+ });
441
+ const now = new Date();
442
+ const live = {
443
+ session,
444
+ sessionId: session.sessionId,
445
+ projectId,
446
+ workspacePath,
447
+ clients: new Set(),
448
+ createdAt: match.createdAt,
449
+ lastActivityAt: now,
450
+ lastAgentStartIndex: undefined,
451
+ unsubscribe: () => undefined,
452
+ };
453
+ live.unsubscribe = makeSubscribeHandler(live);
454
+ registry.set(live.sessionId, live);
455
+ return live;
456
+ });
457
+ }
458
+ /**
459
+ * Delete a cold (on-disk-only) session's JSONL file from disk. Refuses
460
+ * if the session is currently live in the registry — the caller should
461
+ * dispose first. Returns:
462
+ * - "deleted" when the file was found and removed.
463
+ * - "live" when the session is in the registry (caller must dispose
464
+ * first; we don't auto-dispose because that would race the SSE
465
+ * clients with no chance to close cleanly).
466
+ * - "not_found" when no project owns a session with that id on disk.
467
+ */
468
+ export async function deleteColdSession(sessionId) {
469
+ if (registry.has(sessionId))
470
+ return "live";
471
+ const projects = await readProjects();
472
+ for (const project of projects) {
473
+ let infos;
474
+ try {
475
+ // Use our discovery (includes child sessions) so deleting a
476
+ // pi-subagents child by id also works.
477
+ infos = await discoverSessionsOnDisk(project.id, project.path);
478
+ }
479
+ catch {
480
+ // Project's session dir errored out (perms, missing, malformed
481
+ // JSONL). Skip this project and try the next one — the cold
482
+ // session may be in another project's dir. (findSessionLocation
483
+ // logs the same case via stderr; this caller doesn't because
484
+ // deleteColdSession's outer surface already reports
485
+ // not_found vs deleted clearly.)
486
+ continue;
487
+ }
488
+ const match = infos.find((s) => s.sessionId === sessionId);
489
+ if (match !== undefined) {
490
+ try {
491
+ await rm(match.path, { force: true });
492
+ }
493
+ catch (err) {
494
+ // ENOENT (vanished mid-flight) is fine — collapse to
495
+ // "deleted" since the file is now gone, which is what the
496
+ // caller asked for. Any other error (permissions, IO) is a
497
+ // real failure and should NOT silently look like
498
+ // "not_found" to the operator. Surface via thrown so the
499
+ // route can map to 500.
500
+ const code = err.code;
501
+ if (code === "ENOENT")
502
+ return "deleted";
503
+ throw err;
504
+ }
505
+ // Cascade-delete the pi-subagents sibling directory if this was
506
+ // a top-level parent session. The plugin's
507
+ // `getSubagentSessionRoot(parentSessionFile)` lays children at
508
+ // `<dirname(parentFile)>/<basename(parentFile, ".jsonl")>/...`,
509
+ // so we mirror that path and `rm -rf` it. Without this, deleting
510
+ // a parent leaves its child sub-agent JSONLs behind as
511
+ // sidebar orphans.
512
+ //
513
+ // Skipped for sub-agent CHILDREN — they don't have a sibling
514
+ // dir of their own (they live UNDER one). The project-scoped
515
+ // `subagent-artifacts/` dir is intentionally untouched: it's
516
+ // shared across every parent session in the project, not
517
+ // per-session, so blowing it away on single-session delete
518
+ // would clobber unrelated sessions' artifacts.
519
+ if (match.parentSessionId === undefined) {
520
+ const stem = basename(match.path, ".jsonl");
521
+ const siblingDir = join(dirname(match.path), stem);
522
+ // Dispose any LIVE children before removing their JSONLs.
523
+ // `discoverSessionsOnDisk` populated `infos` with every child
524
+ // under this parent (their `parentSessionId` matches our
525
+ // `sessionId`). If a child was opened in the UI it now has a
526
+ // LiveSession entry in the registry; rm-ing its JSONL out from
527
+ // under it leaves a zombie session pointing at a deleted file
528
+ // and any SSE clients attached to it keep firing events that
529
+ // can't be persisted. Dispose in parallel — `disposeSession`
530
+ // awaits a per-session abort with a 5-second ceiling, so a
531
+ // sequential loop on N live children would block the delete
532
+ // request for up to 5N seconds.
533
+ const liveChildIds = infos
534
+ .filter((s) => s.parentSessionId === sessionId && registry.has(s.sessionId))
535
+ .map((s) => s.sessionId);
536
+ if (liveChildIds.length > 0) {
537
+ await Promise.all(liveChildIds.map((id) => disposeSession(id)));
538
+ }
539
+ // force: true makes ENOENT silent; recursive: true clears the
540
+ // run/runId/run-N tree the plugin nests under there. Failures
541
+ // for any other reason (perms, EBUSY) are swallowed — the
542
+ // primary delete already succeeded and the user-facing op is
543
+ // "session is gone."
544
+ await rm(siblingDir, { recursive: true, force: true }).catch(() => undefined);
545
+ }
546
+ return "deleted";
547
+ }
548
+ }
549
+ return "not_found";
550
+ }
551
+ export async function disposeSession(sessionId) {
552
+ const live = registry.get(sessionId);
553
+ if (live === undefined)
554
+ return false;
555
+ // Abort any in-flight prompt FIRST so the SDK's LLM call can stop
556
+ // cleanly before we tear down. Without this, a prompt that was
557
+ // mid-LLM-call when the session is deleted continues server-side
558
+ // (still racking up tokens) and the eventual response either drops
559
+ // silently or throws inside the SDK trying to write to the
560
+ // disposed SessionManager. Best-effort: if abort itself rejects,
561
+ // log and fall through to dispose.
562
+ //
563
+ // Bounded race: a hung SDK abort would otherwise block the dispose
564
+ // forever, which means `disposeAllSessions` (the shutdown path)
565
+ // hangs the server on `docker compose down` until SIGKILL. 5s is
566
+ // well above any reasonable abort latency; the dispose path below
567
+ // still runs after the race resolves.
568
+ try {
569
+ const ABORT_TIMEOUT_MS = 5_000;
570
+ await Promise.race([
571
+ live.session.abort(),
572
+ new Promise((resolve) => setTimeout(resolve, ABORT_TIMEOUT_MS).unref()),
573
+ ]);
574
+ }
575
+ catch (err) {
576
+ // SDK doesn't currently throw from abort, but defend against
577
+ // future versions. The dispose path below still runs.
578
+ void err;
579
+ }
580
+ // Always delete from the registry regardless of whether teardown throws,
581
+ // so a misbehaving SDK update can't leak entries.
582
+ try {
583
+ try {
584
+ // session.dispose() also clears all listeners internally (verified at
585
+ // agent-session.js); calling unsubscribe first is defensive in case a
586
+ // future SDK rev decouples the two.
587
+ live.unsubscribe();
588
+ }
589
+ catch {
590
+ // ignore
591
+ }
592
+ for (const client of live.clients) {
593
+ try {
594
+ client.close();
595
+ }
596
+ catch {
597
+ // ignore
598
+ }
599
+ }
600
+ live.clients.clear();
601
+ try {
602
+ live.session.dispose();
603
+ }
604
+ catch {
605
+ // ignore — SDK doesn't currently throw, but H2-defensive
606
+ }
607
+ }
608
+ finally {
609
+ registry.delete(sessionId);
610
+ // Tombstone the id so a polling SSE client can't re-resume the
611
+ // session before deleteColdSession's file unlink runs. The
612
+ // tombstone clears itself after TOMBSTONE_MS — long enough for
613
+ // the typical hard-delete path (DELETE handler runs dispose then
614
+ // immediately unlink), short enough that an explicit user action
615
+ // a few seconds later can re-open the session normally.
616
+ const existing = disposeTombstones.get(sessionId);
617
+ if (existing !== undefined)
618
+ clearTimeout(existing);
619
+ disposeTombstones.set(sessionId, setTimeout(() => {
620
+ disposeTombstones.delete(sessionId);
621
+ }, TOMBSTONE_MS).unref());
622
+ }
623
+ return true;
624
+ }
625
+ /**
626
+ * Scan the project's session dir on disk WITHOUT loading sessions into the
627
+ * registry. Used by the sidebar list. Backed by the SDK's SessionManager.list
628
+ * which parses each file's first-line header and a few message previews.
629
+ *
630
+ * In addition to the project's own top-level JSONLs, this also scans one
631
+ * level deeper for **pi-subagents child sessions**. The plugin's
632
+ * `getSubagentSessionRoot` helper names the child dir after the parent
633
+ * file's full basename (timestamp + id), so we look up the basename
634
+ * against the top-level session list to recover the actual parent
635
+ * sessionId — without that mapping the child would inherit the dir name
636
+ * verbatim and grouping in the sidebar would silently fail.
637
+ *
638
+ * Returns an empty array (not throws) when the per-project dir doesn't exist
639
+ * yet — e.g. a project that has never had a session.
640
+ */
641
+ export async function discoverSessionsOnDisk(projectId, workspacePath) {
642
+ const dir = sessionDirFor(projectId);
643
+ // SDK's list() guards `existsSync(dir)` and returns [] for missing dirs,
644
+ // so we don't need an outer ENOENT catch.
645
+ const infos = await SessionManager.list(workspacePath, dir);
646
+ const out = infos.map((info) => {
647
+ const ds = {
648
+ sessionId: info.id,
649
+ path: info.path,
650
+ cwd: info.cwd,
651
+ createdAt: info.created,
652
+ modifiedAt: info.modified,
653
+ messageCount: info.messageCount,
654
+ firstMessage: info.firstMessage,
655
+ };
656
+ if (info.name !== undefined)
657
+ ds.name = info.name;
658
+ return ds;
659
+ });
660
+ // Build a basename → sessionId map from the top-level scan so the
661
+ // child-discovery pass can resolve dir names like
662
+ // `2026-05-07T12-34-56-000Z_abc123` back to the parent's actual
663
+ // sessionId `abc123`. Without this, child grouping in the SessionList
664
+ // never matches because the dir name and the parent's sessionId differ.
665
+ const basenameToParentId = new Map();
666
+ for (const info of infos) {
667
+ const base = basenameNoExt(info.path);
668
+ if (base !== undefined)
669
+ basenameToParentId.set(base, info.id);
670
+ }
671
+ const children = await discoverSubagentChildSessions(workspacePath, dir, basenameToParentId);
672
+ for (const child of children)
673
+ out.push(child);
674
+ // Diagnostic log when sub-agent discovery fires — keep this so
675
+ // future reports of "children aren't grouped" can be triaged from
676
+ // server stderr alone (no client-side debugging needed). One line
677
+ // per call, JSON-shaped for log shippers.
678
+ if (children.length > 0 || basenameToParentId.size > 0) {
679
+ process.stderr.write(JSON.stringify({
680
+ level: "info",
681
+ time: new Date().toISOString(),
682
+ msg: "subagent-discovery",
683
+ projectId,
684
+ topLevelSessions: infos.length,
685
+ basenameMapSize: basenameToParentId.size,
686
+ childrenFound: children.length,
687
+ children: children.map((c) => ({
688
+ childId: c.sessionId,
689
+ parentSessionId: c.parentSessionId,
690
+ runId: c.runId,
691
+ path: c.path,
692
+ })),
693
+ }) + "\n");
694
+ }
695
+ return out;
696
+ }
697
+ /** `/path/to/2026-05-07_abc.jsonl` → `2026-05-07_abc`; undefined for any non-jsonl. */
698
+ function basenameNoExt(filePath) {
699
+ const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
700
+ const base = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath;
701
+ if (!base.endsWith(".jsonl"))
702
+ return undefined;
703
+ return base.slice(0, -".jsonl".length);
704
+ }
705
+ /**
706
+ * Walk under each `<projectId>/<parentBasename>/` to surface
707
+ * pi-subagents child sessions, regardless of how deep the plugin
708
+ * nests them.
709
+ *
710
+ * The plugin's `getSubagentSessionRoot(parentSessionFile)` always
711
+ * returns `<dirname(parentSessionFile)>/<basename(parentSessionFile, ".jsonl")>`
712
+ * — a directory NAMED after the parent's full basename. WHAT goes
713
+ * underneath varies by plugin run mode; observed in the wild:
714
+ *
715
+ * - <basename>/<child>.jsonl (flat — rare)
716
+ * - <basename>/<runId>/<child>.jsonl (single-mode)
717
+ * - <basename>/<runId>/run-<N>/session.jsonl (parallel/chain)
718
+ *
719
+ * Rather than enumerating every layout, we recursively walk under
720
+ * `<basename>/` (capped at depth 4 for safety) and treat any
721
+ * directory containing `.jsonl` files as a candidate sessions dir.
722
+ * `runId` is reconstructed from the path segments between
723
+ * `<basename>` and the JSONL's containing dir.
724
+ *
725
+ * `basenameToParentId` maps the basename dir back to the parent's
726
+ * actual sessionId (since the dir name includes the timestamp prefix,
727
+ * NOT the bare sessionId). Without this mapping the sidebar grouping
728
+ * silently fails because the dir name and sessionId never compare equal.
729
+ *
730
+ * Errors from individual subdirs are swallowed — a corrupted child
731
+ * session must not block the rest of the sidebar listing.
732
+ */
733
+ async function discoverSubagentChildSessions(workspacePath, dir, basenameToParentId) {
734
+ const out = [];
735
+ let topEntries;
736
+ try {
737
+ const direntList = await readdir(dir, { withFileTypes: true });
738
+ topEntries = direntList.map((d) => ({ name: d.name, isDirectory: d.isDirectory() }));
739
+ }
740
+ catch {
741
+ // dir missing or unreadable — caller already handles the empty case.
742
+ return out;
743
+ }
744
+ for (const top of topEntries) {
745
+ if (!top.isDirectory)
746
+ continue;
747
+ const dirName = top.name;
748
+ // Skip well-known sibling dirs the plugin creates at the same
749
+ // level for unrelated reasons (artifacts, etc.) — these aren't
750
+ // parent-named child roots.
751
+ if (dirName === "subagent-artifacts")
752
+ continue;
753
+ const parentSessionId = basenameToParentId.get(dirName) ?? dirName;
754
+ const parentDir = join(dir, dirName);
755
+ // Recursively find every dir containing .jsonl files under
756
+ // <parentDir>, capped at depth 4 (the deepest layout observed
757
+ // is <basename>/<runId>/run-N/session.jsonl, which is depth 3 —
758
+ // depth 4 leaves headroom for one more level the plugin might
759
+ // add in future versions). Depth cap also protects against
760
+ // symlink loops without needing a visited-set.
761
+ const sessionDirs = await collectJsonlDirs(parentDir, 0, 4);
762
+ for (const sd of sessionDirs) {
763
+ let infos;
764
+ try {
765
+ infos = await SessionManager.list(workspacePath, sd);
766
+ }
767
+ catch {
768
+ continue;
769
+ }
770
+ // Reconstruct runId from path segments between parentDir and sd.
771
+ // Single segment → that's the runId. Multiple segments
772
+ // (e.g. <runId>/run-0) → join with '/' so the sidebar can show
773
+ // the full run identity in its title attribute.
774
+ const rel = sd.slice(parentDir.length).replace(/^[/\\]+/, "");
775
+ const runId = rel.length > 0 ? rel : undefined;
776
+ for (const info of infos) {
777
+ const ds = {
778
+ sessionId: info.id,
779
+ path: info.path,
780
+ cwd: info.cwd,
781
+ createdAt: info.created,
782
+ modifiedAt: info.modified,
783
+ messageCount: info.messageCount,
784
+ firstMessage: info.firstMessage,
785
+ parentSessionId,
786
+ };
787
+ if (info.name !== undefined)
788
+ ds.name = info.name;
789
+ if (runId !== undefined)
790
+ ds.runId = runId;
791
+ out.push(ds);
792
+ }
793
+ }
794
+ }
795
+ return out;
796
+ }
797
+ /**
798
+ * Recursively find every directory under `root` (inclusive) that
799
+ * contains at least one `.jsonl` file. Bounded by `maxDepth` to
800
+ * cap the worst case and defend against symlink loops.
801
+ */
802
+ async function collectJsonlDirs(root, depth, maxDepth) {
803
+ if (depth > maxDepth)
804
+ return [];
805
+ let entries;
806
+ try {
807
+ const list = await readdir(root, { withFileTypes: true });
808
+ entries = list.map((d) => ({
809
+ name: d.name,
810
+ isDirectory: d.isDirectory(),
811
+ isFile: d.isFile(),
812
+ }));
813
+ }
814
+ catch {
815
+ return [];
816
+ }
817
+ const out = [];
818
+ if (entries.some((e) => e.isFile && e.name.endsWith(".jsonl")))
819
+ out.push(root);
820
+ for (const e of entries) {
821
+ if (!e.isDirectory)
822
+ continue;
823
+ const child = join(root, e.name);
824
+ out.push(...(await collectJsonlDirs(child, depth + 1, maxDepth)));
825
+ }
826
+ return out;
827
+ }
828
+ /**
829
+ * Unified, recency-sorted view of sessions for a project: merges live
830
+ * registry entries with on-disk discovery, dedupes by sessionId.
831
+ *
832
+ * Field precedence when a session appears in both live and disk:
833
+ * - `lastActivityAt`, `createdAt`, `name`, `isLive` — LIVE wins (freshest).
834
+ * - `messageCount`, `firstMessage` — DISK wins. The SDK's
835
+ * `SessionInfo.messageCount` counts user-visible messages; the live
836
+ * session's `messages.length` includes BashExecutionMessage and other
837
+ * internal types, so the two would disagree. Disk values are the ones
838
+ * the sidebar should display.
839
+ *
840
+ * For a live-only session that hasn't flushed to disk yet (no assistant
841
+ * message), `firstMessage` is `""` and `messageCount` falls back to
842
+ * `session.messages.length`.
843
+ *
844
+ * This is the canonical surface for the Phase 6 sidebar list — call sites
845
+ * should not implement their own merge.
846
+ */
847
+ export async function listSessionsForProject(projectId, workspacePath) {
848
+ const live = listSessions(projectId);
849
+ const liveById = new Map(live.map((l) => [
850
+ l.sessionId,
851
+ {
852
+ sessionId: l.sessionId,
853
+ projectId: l.projectId,
854
+ isLive: true,
855
+ name: l.session.sessionName,
856
+ workspacePath: l.workspacePath,
857
+ lastActivityAt: l.lastActivityAt,
858
+ createdAt: l.createdAt,
859
+ messageCount: l.session.messages.length,
860
+ firstMessage: "",
861
+ },
862
+ ]));
863
+ const disk = await discoverSessionsOnDisk(projectId, workspacePath);
864
+ for (const d of disk) {
865
+ const merged = liveById.get(d.sessionId);
866
+ if (merged !== undefined) {
867
+ // Disk wins for messageCount and firstMessage (see precedence in
868
+ // function doc); everything else stays as the live value. Sub-agent
869
+ // linkage fields are disk-side only — children are typically not
870
+ // live-resident.
871
+ merged.messageCount = d.messageCount;
872
+ merged.firstMessage = d.firstMessage;
873
+ if (d.parentSessionId !== undefined)
874
+ merged.parentSessionId = d.parentSessionId;
875
+ if (d.runId !== undefined)
876
+ merged.runId = d.runId;
877
+ merged.path = d.path;
878
+ continue;
879
+ }
880
+ const u = {
881
+ sessionId: d.sessionId,
882
+ projectId,
883
+ isLive: false,
884
+ name: d.name,
885
+ workspacePath,
886
+ lastActivityAt: d.modifiedAt,
887
+ createdAt: d.createdAt,
888
+ messageCount: d.messageCount,
889
+ firstMessage: d.firstMessage,
890
+ path: d.path,
891
+ };
892
+ if (d.parentSessionId !== undefined)
893
+ u.parentSessionId = d.parentSessionId;
894
+ if (d.runId !== undefined)
895
+ u.runId = d.runId;
896
+ liveById.set(d.sessionId, u);
897
+ }
898
+ return Array.from(liveById.values()).sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
899
+ }
900
+ /**
901
+ * Resolve a sessionId to its (projectId, workspacePath) pair without resuming.
902
+ * Walks every registered project's session dir and matches by id. Returns
903
+ * undefined if the session is not on disk.
904
+ *
905
+ * Used by routes that need to attach to a session known only by id (e.g. the
906
+ * SSE stream route auto-resume path). Single-tenant + small project counts
907
+ * means this is fast in practice; if the project count ever explodes we'd
908
+ * cache a sessionId → location index, but not today.
909
+ */
910
+ export async function findSessionLocation(sessionId) {
911
+ const live = registry.get(sessionId);
912
+ if (live !== undefined) {
913
+ return { projectId: live.projectId, workspacePath: live.workspacePath };
914
+ }
915
+ const projects = await readProjects();
916
+ for (const project of projects) {
917
+ let discovered;
918
+ try {
919
+ // discoverSessionsOnDisk includes pi-subagents child sessions, so
920
+ // a child's UUID resolves to its parent project the same as a
921
+ // top-level session.
922
+ discovered = await discoverSessionsOnDisk(project.id, project.path);
923
+ }
924
+ catch (err) {
925
+ // Don't fail the whole search just because one project's session
926
+ // dir is corrupted, but DO log so the operator can see when a
927
+ // project's storage went bad — the previous silent skip meant
928
+ // a permissions/JSONL issue could persist undetected.
929
+ process.stderr.write(JSON.stringify({
930
+ level: "warn",
931
+ msg: "findSessionLocation: skipping project due to discoverSessionsOnDisk error",
932
+ projectId: project.id,
933
+ err: err instanceof Error ? err.message : String(err),
934
+ }) + "\n");
935
+ continue;
936
+ }
937
+ if (discovered.some((s) => s.sessionId === sessionId)) {
938
+ return { projectId: project.id, workspacePath: project.path };
939
+ }
940
+ }
941
+ return undefined;
942
+ }
943
+ /**
944
+ * Resume a session by id alone — looks up its project via findSessionLocation,
945
+ * then delegates to resumeSession. Convenience wrapper for routes that don't
946
+ * receive projectId in the URL (the stream route specifically).
947
+ */
948
+ export async function resumeSessionById(sessionId) {
949
+ const existing = registry.get(sessionId);
950
+ if (existing)
951
+ return existing;
952
+ const loc = await findSessionLocation(sessionId);
953
+ if (loc === undefined)
954
+ throw new SessionNotFoundError(sessionId);
955
+ return resumeSession(sessionId, loc.projectId, loc.workspacePath);
956
+ }
957
+ /**
958
+ * Fork a live session from an entry. Calls
959
+ * `sessionManager.createBranchedSession(entryId)` which produces a new
960
+ * .jsonl on disk containing the path-to-leaf, then loads that new file as
961
+ * a fresh LiveSession in the same project.
962
+ *
963
+ * The source session remains live and untouched; callers may dispose it
964
+ * explicitly if the fork supersedes it. Both sessions appear in the
965
+ * registry until disposed.
966
+ *
967
+ * Throws:
968
+ * - SessionNotFoundError — source isn't live
969
+ * - EntryNotFoundError — entryId doesn't resolve on the source tree
970
+ * - Error("fork_failed") — source has no on-disk persistence (in-memory
971
+ * sessions can't be forked because there's no path to branch from)
972
+ */
973
+ export async function forkSession(sessionId, entryId) {
974
+ // Per-source serialisation: see forkLocks comment. Two near-
975
+ // simultaneous forks from the same source would otherwise stomp on
976
+ // each other's `originalSourceFile` snapshot via the SDK's
977
+ // destructive in-place mutation, leaving the source pointing at the
978
+ // wrong file in memory.
979
+ return getForkLock(sessionId)(async () => {
980
+ return forkSessionLocked(sessionId, entryId);
981
+ });
982
+ }
983
+ async function forkSessionLocked(sessionId, entryId) {
984
+ const source = registry.get(sessionId);
985
+ if (source === undefined)
986
+ throw new SessionNotFoundError(sessionId);
987
+ // CRITICAL: capture the source's session file BEFORE calling
988
+ // createBranchedSession. The SDK's implementation MUTATES the
989
+ // source SessionManager in place — it sets `this.sessionId`,
990
+ // `this.sessionFile`, and `this.fileEntries` to the new
991
+ // session's values, so after the call `source.session.sessionManager`
992
+ // points at the fork instead of the original. The original
993
+ // .jsonl file on disk is untouched, but the in-memory source
994
+ // LiveSession is hijacked and would return the fork's messages
995
+ // to anyone subsequently reading from it. We re-open the source
996
+ // from its original file at the end of this function to undo the
997
+ // hijack.
998
+ const originalSourceFile = source.session.sessionManager.getSessionFile();
999
+ let newPath;
1000
+ try {
1001
+ newPath = source.session.sessionManager.createBranchedSession(entryId);
1002
+ }
1003
+ catch (err) {
1004
+ // SDK throws `Error("Entry <id> not found")` when entryId doesn't resolve
1005
+ // to a tree node. Translate to a typed error so the route returns a stable
1006
+ // 400 instead of leaking the raw SDK message.
1007
+ if (err instanceof Error && /entry .* not found/i.test(err.message)) {
1008
+ throw new EntryNotFoundError(entryId);
1009
+ }
1010
+ throw err;
1011
+ }
1012
+ // Return is undefined for in-memory (non-persisted) sessions, which can't
1013
+ // be forked. Map separately from entry-not-found so callers can distinguish.
1014
+ if (newPath === undefined)
1015
+ throw new Error("fork_failed");
1016
+ const dir = sessionDirFor(source.projectId);
1017
+ const sessionManager = SessionManager.open(newPath, dir, source.workspacePath);
1018
+ const customTools = await resolveMcpCustomTools(source.projectId, source.workspacePath);
1019
+ const settingsManager = await buildSessionSettingsManager(source.workspacePath, source.projectId);
1020
+ const resourceLoader = await buildForgeResourceLoader(source.workspacePath, config.piConfigDir, settingsManager, source.projectId);
1021
+ const { session } = await createAgentSession({
1022
+ cwd: source.workspacePath,
1023
+ sessionManager,
1024
+ settingsManager,
1025
+ resourceLoader,
1026
+ agentDir: config.piConfigDir,
1027
+ customTools,
1028
+ tools: await buildToolsAllowlist(customTools, source.projectId, source.workspacePath),
1029
+ });
1030
+ const now = new Date();
1031
+ const live = {
1032
+ session,
1033
+ sessionId: session.sessionId,
1034
+ projectId: source.projectId,
1035
+ workspacePath: source.workspacePath,
1036
+ clients: new Set(),
1037
+ createdAt: now,
1038
+ lastActivityAt: now,
1039
+ lastAgentStartIndex: undefined,
1040
+ unsubscribe: () => undefined,
1041
+ };
1042
+ live.unsubscribe = makeSubscribeHandler(live);
1043
+ registry.set(live.sessionId, live);
1044
+ // Disambiguate the fork's display name from its source. The SDK
1045
+ // copies session_info entries forward when forking, so the new
1046
+ // session has the same `sessionName` as the source — making it
1047
+ // hard to tell them apart in the sidebar. Rename to "<source>
1048
+ // (clone)", or "<source> (clone N)" if other clones already exist
1049
+ // in this project. Plain "(clone)" is used when the source has no
1050
+ // explicit name. Failures are non-fatal (the fork is otherwise
1051
+ // fully usable).
1052
+ try {
1053
+ const sourceName = source.session.sessionName;
1054
+ const baseName = sourceName !== undefined && sourceName.length > 0 ? `${sourceName} (clone)` : "(clone)";
1055
+ const siblings = await listSessionsForProject(source.projectId, source.workspacePath);
1056
+ const existingNames = new Set(siblings
1057
+ .filter((s) => s.sessionId !== live.sessionId)
1058
+ .map((s) => s.name)
1059
+ .filter((n) => typeof n === "string"));
1060
+ let candidate = baseName;
1061
+ let n = 2;
1062
+ while (existingNames.has(candidate)) {
1063
+ candidate = `${baseName} ${n}`;
1064
+ n += 1;
1065
+ }
1066
+ session.setSessionName(candidate);
1067
+ }
1068
+ catch {
1069
+ // Naming is best-effort; the new session still works without it.
1070
+ }
1071
+ // Undo the SDK's in-place mutation on the source LiveSession by
1072
+ // reopening the original .jsonl with a fresh SessionManager +
1073
+ // AgentSession. Without this, the source's sessionId field still
1074
+ // says oldId but its session.sessionManager points at the fork —
1075
+ // every read after fork returns fork data, every write is appended
1076
+ // to the fork's file. The disk side is fine (original file
1077
+ // untouched); only the in-memory state needs the patch.
1078
+ if (originalSourceFile !== undefined) {
1079
+ try {
1080
+ source.unsubscribe();
1081
+ const restoredManager = SessionManager.open(originalSourceFile, dir, source.workspacePath);
1082
+ const restoredCustomTools = await resolveMcpCustomTools(source.projectId, source.workspacePath);
1083
+ const restoredSettingsManager = await buildSessionSettingsManager(source.workspacePath, source.projectId);
1084
+ const restoredResourceLoader = await buildForgeResourceLoader(source.workspacePath, config.piConfigDir, restoredSettingsManager, source.projectId);
1085
+ const { session: restoredSession } = await createAgentSession({
1086
+ cwd: source.workspacePath,
1087
+ sessionManager: restoredManager,
1088
+ settingsManager: restoredSettingsManager,
1089
+ resourceLoader: restoredResourceLoader,
1090
+ agentDir: config.piConfigDir,
1091
+ customTools: restoredCustomTools,
1092
+ tools: await buildToolsAllowlist(restoredCustomTools, source.projectId, source.workspacePath),
1093
+ });
1094
+ // Mutate the existing LiveSession in place rather than
1095
+ // replacing the registry entry — any SSE client holding a
1096
+ // reference would otherwise lose its connection. Same
1097
+ // sessionId, fresh AgentSession underneath.
1098
+ source.session = restoredSession;
1099
+ source.lastActivityAt = new Date();
1100
+ source.lastAgentStartIndex = undefined;
1101
+ source.unsubscribe = makeSubscribeHandler(source);
1102
+ }
1103
+ catch (err) {
1104
+ // Log but don't fail the fork — the new session is fine.
1105
+ // The source is corrupted in memory; surface as a server log
1106
+ // so it shows up in diagnostics.
1107
+ //
1108
+ // Using a structured object on stderr (rather than the prior
1109
+ // bare console.error template string) so log shippers parse
1110
+ // it as a single JSON-shaped event instead of a 2-line garbled
1111
+ // log entry. We don't have access to a fastify request logger
1112
+ // here (forkSession is a registry-level helper), so this is
1113
+ // the best stand-in.
1114
+ process.stderr.write(JSON.stringify({
1115
+ level: "error",
1116
+ msg: "forkSession: failed to restore source session",
1117
+ sessionId,
1118
+ originalSourceFile,
1119
+ err: err instanceof Error ? err.message : String(err),
1120
+ }) + "\n");
1121
+ }
1122
+ }
1123
+ return live;
1124
+ }
1125
+ /** Number of currently-live sessions across all projects. Used by /health. */
1126
+ export function sessionCount() {
1127
+ return registry.size;
1128
+ }
1129
+ /** Test/teardown helper — disposes every live session. */
1130
+ export async function disposeAllSessions() {
1131
+ await Promise.all(Array.from(registry.keys()).map((id) => disposeSession(id).catch(() => {
1132
+ // best-effort during shutdown; never fail the teardown loop
1133
+ })));
1134
+ }
1135
+ /**
1136
+ * Build a SettingsManager whose `getGlobalSettings()` and
1137
+ * `getProjectSettings()` return augmented `skills` patterns reflecting
1138
+ * the pi-forge's per-project overrides.
1139
+ *
1140
+ * Why we don't use `applyOverrides({ skills })`: pi's package-manager
1141
+ * (the thing that auto-discovers and filters skills) reads
1142
+ * `getGlobalSettings()` and `getProjectSettings()` SEPARATELY when
1143
+ * resolving which skills the agent sees. `applyOverrides` only mutates
1144
+ * the merged `this.settings` view — `getGlobalSettings`/`getProjectSettings`
1145
+ * still return the un-merged on-disk values, so any skill patterns we
1146
+ * push through `applyOverrides` are silently ignored by skill loading.
1147
+ *
1148
+ * Why monkey-patching instead of subclassing or Proxy: pi internals
1149
+ * use `instanceof SettingsManager` checks in a few places, which a
1150
+ * Proxy breaks. Subclassing would require reaching into private fields
1151
+ * to hand the constructor what it needs. Direct method substitution on
1152
+ * the instance is the smallest change that survives across SDK
1153
+ * upgrades — both methods are public, both return their backing field
1154
+ * via `structuredClone`, both have stable signatures.
1155
+ *
1156
+ * Patterns get injected into BOTH reads because pi applies global
1157
+ * patterns to the user skills dir and project patterns to the project
1158
+ * skills dir; injecting into one would only filter half the discovery.
1159
+ */
1160
+ async function buildSessionSettingsManager(workspacePath, projectId) {
1161
+ const sm = SettingsManager.create(workspacePath, config.piConfigDir);
1162
+ const patterns = await effectiveSkillsForProject(projectId);
1163
+ if (patterns.length === 0)
1164
+ return sm;
1165
+ const origGlobal = sm.getGlobalSettings.bind(sm);
1166
+ const origProject = sm.getProjectSettings.bind(sm);
1167
+ const merge = (existing) => Array.from(new Set([...(existing ?? []), ...patterns]));
1168
+ sm.getGlobalSettings = () => {
1169
+ const s = origGlobal();
1170
+ return { ...s, skills: merge(s.skills) };
1171
+ };
1172
+ sm.getProjectSettings = () => {
1173
+ const s = origProject();
1174
+ return { ...s, skills: merge(s.skills) };
1175
+ };
1176
+ return sm;
1177
+ }
1178
+ /**
1179
+ * Resolve the `customTools` array passed to `createAgentSession`.
1180
+ *
1181
+ * Returns the union of every connected, enabled MCP server's tools —
1182
+ * global servers (from ${FORGE_DATA_DIR}/mcp.json) plus the
1183
+ * project-scoped servers (from <projectPath>/.mcp.json), with
1184
+ * project entries winning on name collisions.
1185
+ *
1186
+ * Honors the master `disabled` toggle in mcp.json: if MCP is globally
1187
+ * off, returns an empty array regardless of per-server state. Boot-
1188
+ * time `loadGlobal()` is called in index.ts; project-scope is loaded
1189
+ * lazily here on first session-create per project.
1190
+ */
1191
+ async function resolveMcpCustomTools(projectId, workspacePath) {
1192
+ if (!mcpIsGloballyEnabled())
1193
+ return [];
1194
+ await mcpEnsureProjectLoaded(projectId, workspacePath).catch(() => undefined);
1195
+ return mcpCustomToolsForProject(projectId);
1196
+ }
1197
+ //# sourceMappingURL=session-registry.js.map