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,783 @@
1
+ import { getProject, readProjects } from "../project-manager.js";
2
+ import { createSession, deleteColdSession, disposeSession, findSessionLocation, getSession, listSessionsForProject, resumeSessionById, } from "../session-registry.js";
3
+ import { errorSchema, liveSummaryBody, liveSummarySchema } from "./_schemas.js";
4
+ import { buildTurnDiff } from "../turn-diff-builder.js";
5
+ import { buildCompactionHistory } from "../compaction-history.js";
6
+ const unifiedSchema = {
7
+ type: "object",
8
+ required: [
9
+ "sessionId",
10
+ "projectId",
11
+ "isLive",
12
+ "workspacePath",
13
+ "lastActivityAt",
14
+ "createdAt",
15
+ "messageCount",
16
+ "firstMessage",
17
+ ],
18
+ properties: {
19
+ sessionId: { type: "string" },
20
+ projectId: { type: "string" },
21
+ isLive: { type: "boolean" },
22
+ name: { type: "string" },
23
+ workspacePath: { type: "string" },
24
+ lastActivityAt: { type: "string", format: "date-time" },
25
+ createdAt: { type: "string", format: "date-time" },
26
+ messageCount: { type: "integer", minimum: 0 },
27
+ firstMessage: { type: "string" },
28
+ /**
29
+ * Set on pi-subagents child sessions — the id of the session that
30
+ * spawned this sub-agent. Drives the sidebar's parent-row chevron
31
+ * grouping.
32
+ */
33
+ parentSessionId: { type: "string" },
34
+ /** pi-subagents run id when this is a child session. */
35
+ runId: { type: "string" },
36
+ /**
37
+ * Absolute disk path to the session JSONL — used by the
38
+ * SubagentResultCard to resolve a result's `sessionFile` reference
39
+ * back to the canonical sessionId (since pi-subagents writes
40
+ * children as a literal `session.jsonl` filename, not `<uuid>.jsonl`).
41
+ */
42
+ path: { type: "string" },
43
+ },
44
+ };
45
+ function unifiedFromUnified(u) {
46
+ // Fastify's response serializer drops `undefined`-valued keys, but emit a
47
+ // stable shape: convert dates to ISO strings + only include optional
48
+ // fields (`name`, sub-agent linkage) when set.
49
+ const out = {
50
+ sessionId: u.sessionId,
51
+ projectId: u.projectId,
52
+ isLive: u.isLive,
53
+ workspacePath: u.workspacePath,
54
+ lastActivityAt: u.lastActivityAt.toISOString(),
55
+ createdAt: u.createdAt.toISOString(),
56
+ messageCount: u.messageCount,
57
+ firstMessage: u.firstMessage,
58
+ };
59
+ if (u.name !== undefined)
60
+ out.name = u.name;
61
+ if (u.parentSessionId !== undefined)
62
+ out.parentSessionId = u.parentSessionId;
63
+ if (u.runId !== undefined)
64
+ out.runId = u.runId;
65
+ if (u.path !== undefined)
66
+ out.path = u.path;
67
+ return out;
68
+ }
69
+ /**
70
+ * Truncated text preview of a message's content for the session
71
+ * tree. Mirrors the SDK's `_extractUserMessageText` shape: strings
72
+ * pass through; arrays of content blocks join the `text` parts.
73
+ * Returns undefined when the content has no extractable text (e.g.
74
+ * an image-only message), so the caller can omit the field.
75
+ */
76
+ const PREVIEW_MAX_CHARS = 200;
77
+ function previewOfMessageContent(content) {
78
+ let text;
79
+ if (typeof content === "string") {
80
+ text = content;
81
+ }
82
+ else if (Array.isArray(content)) {
83
+ const parts = [];
84
+ for (const c of content) {
85
+ const o = c;
86
+ if (o.type === "text" && typeof o.text === "string")
87
+ parts.push(o.text);
88
+ }
89
+ text = parts.join("\n");
90
+ }
91
+ else {
92
+ return undefined;
93
+ }
94
+ text = text.trim();
95
+ if (text.length === 0)
96
+ return undefined;
97
+ if (text.length <= PREVIEW_MAX_CHARS)
98
+ return text;
99
+ return text.slice(0, PREVIEW_MAX_CHARS - 1) + "…";
100
+ }
101
+ function notFound(reply) {
102
+ return reply.code(404).send({ error: "session_not_found" });
103
+ }
104
+ export const sessionRoutes = async (fastify) => {
105
+ fastify.get("/sessions", {
106
+ schema: {
107
+ description: "List sessions for a project (live and on-disk merged, deduped by " +
108
+ "id, sorted by recency). Without `projectId`, returns sessions from " +
109
+ "every project the pi-forge knows about.",
110
+ tags: ["sessions"],
111
+ querystring: {
112
+ type: "object",
113
+ properties: { projectId: { type: "string" } },
114
+ },
115
+ response: {
116
+ 200: {
117
+ type: "object",
118
+ required: ["sessions"],
119
+ properties: {
120
+ sessions: { type: "array", items: unifiedSchema },
121
+ },
122
+ },
123
+ 404: errorSchema,
124
+ },
125
+ },
126
+ }, async (req, reply) => {
127
+ const projectId = req.query.projectId;
128
+ if (projectId !== undefined) {
129
+ const project = await getProject(projectId);
130
+ if (project === undefined) {
131
+ return reply.code(404).send({ error: "project_not_found" });
132
+ }
133
+ const sessions = await listSessionsForProject(projectId, project.path);
134
+ return { sessions: sessions.map(unifiedFromUnified) };
135
+ }
136
+ // Cross-project: fan out in parallel — each project's listing is
137
+ // independent disk I/O. Use Promise.allSettled so one corrupt
138
+ // project's session dir doesn't take down the whole sidebar; the
139
+ // failure is logged and that project's sessions are skipped.
140
+ const projects = await readProjects();
141
+ const settled = await Promise.all(projects.map(async (p) => {
142
+ try {
143
+ return await listSessionsForProject(p.id, p.path);
144
+ }
145
+ catch (err) {
146
+ req.log.warn({ err: err instanceof Error ? err.message : String(err), projectId: p.id }, "listSessionsForProject failed; skipping project in cross-project listing");
147
+ return [];
148
+ }
149
+ }));
150
+ const all = settled.flat();
151
+ all.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
152
+ return { sessions: all.map(unifiedFromUnified) };
153
+ });
154
+ fastify.post("/sessions", {
155
+ schema: {
156
+ description: "Create a new session in the given project.",
157
+ tags: ["sessions"],
158
+ body: {
159
+ type: "object",
160
+ required: ["projectId"],
161
+ additionalProperties: false,
162
+ properties: { projectId: { type: "string" } },
163
+ },
164
+ response: {
165
+ 201: liveSummarySchema,
166
+ 404: errorSchema,
167
+ },
168
+ },
169
+ }, async (req, reply) => {
170
+ const project = await getProject(req.body.projectId);
171
+ if (project === undefined) {
172
+ return reply.code(404).send({ error: "project_not_found" });
173
+ }
174
+ const live = await createSession(project.id, project.path);
175
+ return reply.code(201).send(liveSummaryBody({
176
+ sessionId: live.sessionId,
177
+ projectId: live.projectId,
178
+ workspacePath: live.workspacePath,
179
+ createdAt: live.createdAt,
180
+ lastActivityAt: live.lastActivityAt,
181
+ name: live.session.sessionName,
182
+ messageCount: live.session.messages.length,
183
+ isStreaming: live.session.isStreaming,
184
+ }));
185
+ });
186
+ fastify.get("/sessions/:id", {
187
+ schema: {
188
+ description: "Get session metadata. Looks up live sessions first, falls back to " +
189
+ "the on-disk index. Does not load the session into memory.",
190
+ tags: ["sessions"],
191
+ params: {
192
+ type: "object",
193
+ required: ["id"],
194
+ properties: { id: { type: "string" } },
195
+ },
196
+ response: {
197
+ 200: liveSummarySchema,
198
+ 404: errorSchema,
199
+ },
200
+ },
201
+ }, async (req, reply) => {
202
+ const live = getSession(req.params.id);
203
+ if (live !== undefined) {
204
+ return liveSummaryBody({
205
+ sessionId: live.sessionId,
206
+ projectId: live.projectId,
207
+ workspacePath: live.workspacePath,
208
+ createdAt: live.createdAt,
209
+ lastActivityAt: live.lastActivityAt,
210
+ name: live.session.sessionName,
211
+ messageCount: live.session.messages.length,
212
+ isStreaming: live.session.isStreaming,
213
+ });
214
+ }
215
+ const loc = await findSessionLocation(req.params.id);
216
+ if (loc === undefined)
217
+ return notFound(reply);
218
+ // On-disk only — pull metadata via the unified merge for this project.
219
+ const list = await listSessionsForProject(loc.projectId, loc.workspacePath);
220
+ const match = list.find((s) => s.sessionId === req.params.id);
221
+ if (match === undefined)
222
+ return notFound(reply);
223
+ return liveSummaryBody({
224
+ sessionId: match.sessionId,
225
+ projectId: match.projectId,
226
+ workspacePath: match.workspacePath,
227
+ createdAt: match.createdAt,
228
+ lastActivityAt: match.lastActivityAt,
229
+ name: match.name,
230
+ messageCount: match.messageCount,
231
+ isStreaming: false,
232
+ isLive: false,
233
+ });
234
+ });
235
+ fastify.get("/sessions/:id/messages", {
236
+ schema: {
237
+ description: "Return the live session's full messages array — the same shape " +
238
+ "the SSE stream sends in its `snapshot` event. Used by the chat " +
239
+ "view to refresh after `agent_end` without reconnecting the SSE. " +
240
+ "404 if the session isn't currently live in the registry.",
241
+ tags: ["sessions"],
242
+ params: {
243
+ type: "object",
244
+ required: ["id"],
245
+ properties: { id: { type: "string" } },
246
+ },
247
+ response: {
248
+ 200: {
249
+ type: "object",
250
+ required: ["messages"],
251
+ properties: {
252
+ messages: {
253
+ type: "array",
254
+ items: { type: "object", additionalProperties: true },
255
+ },
256
+ },
257
+ },
258
+ 404: errorSchema,
259
+ },
260
+ },
261
+ }, async (req, reply) => {
262
+ const live = getSession(req.params.id);
263
+ if (live === undefined)
264
+ return notFound(reply);
265
+ return { messages: live.session.messages };
266
+ });
267
+ // Compaction history. Returns the per-compaction archive that the SDK
268
+ // strips out of `live.session.messages` after each compact() call, so
269
+ // the chat view can render a "compacted N messages → Y tokens" card
270
+ // at each compaction point with the archived messages one click away.
271
+ // Server-side derivation keeps the entry-id arithmetic out of the
272
+ // client. See packages/server/src/compaction-history.ts for the
273
+ // shape contract.
274
+ fastify.get("/sessions/:id/compactions", {
275
+ schema: {
276
+ description: "Per-compaction archive for the live session. Each entry " +
277
+ "carries the SDK-generated summary, the pre-compaction " +
278
+ "token count, and the AgentMessage[] that was archived (no " +
279
+ "longer in the LLM's context window). `insertBeforeIndex` " +
280
+ "tells the client where to splice a card into the post-" +
281
+ "compaction `messages` array. 404 if the session isn't " +
282
+ "currently live.",
283
+ tags: ["sessions"],
284
+ params: {
285
+ type: "object",
286
+ required: ["id"],
287
+ properties: { id: { type: "string" } },
288
+ },
289
+ response: {
290
+ 200: {
291
+ type: "object",
292
+ required: ["compactions"],
293
+ properties: {
294
+ compactions: {
295
+ type: "array",
296
+ items: {
297
+ type: "object",
298
+ required: [
299
+ "id",
300
+ "timestamp",
301
+ "summary",
302
+ "tokensBefore",
303
+ "insertBeforeIndex",
304
+ "archivedMessages",
305
+ ],
306
+ properties: {
307
+ id: { type: "string" },
308
+ timestamp: { type: "string" },
309
+ summary: { type: "string" },
310
+ tokensBefore: { type: "integer", minimum: 0 },
311
+ insertBeforeIndex: { type: "integer", minimum: 0 },
312
+ archivedMessages: {
313
+ type: "array",
314
+ items: { type: "object", additionalProperties: true },
315
+ },
316
+ },
317
+ },
318
+ },
319
+ },
320
+ },
321
+ 404: errorSchema,
322
+ },
323
+ },
324
+ }, async (req, reply) => {
325
+ const live = getSession(req.params.id);
326
+ if (live === undefined)
327
+ return notFound(reply);
328
+ return { compactions: buildCompactionHistory(live.session) };
329
+ });
330
+ fastify.get("/sessions/:id/turn-diff", {
331
+ schema: {
332
+ description: "Aggregate every write/edit tool result from the session's most " +
333
+ "recent turn into one reviewable changeset. Returns " +
334
+ "`{ entries: [{ file, tool, diff, additions, deletions, isPureAddition }] }`. " +
335
+ "Prefers `git diff HEAD -- <path>` for cumulative diffs; falls back " +
336
+ "to a pure-addition diff when the file is untracked or the project " +
337
+ "has no `.git`. 404 if the session isn't currently live.",
338
+ tags: ["sessions"],
339
+ params: {
340
+ type: "object",
341
+ required: ["id"],
342
+ properties: { id: { type: "string" } },
343
+ },
344
+ response: {
345
+ 200: {
346
+ type: "object",
347
+ required: ["entries"],
348
+ properties: {
349
+ entries: {
350
+ type: "array",
351
+ items: {
352
+ type: "object",
353
+ required: ["file", "tool", "diff", "additions", "deletions", "isPureAddition"],
354
+ properties: {
355
+ file: { type: "string" },
356
+ tool: { type: "string", enum: ["write", "edit"] },
357
+ diff: { type: "string" },
358
+ additions: { type: "integer", minimum: 0 },
359
+ deletions: { type: "integer", minimum: 0 },
360
+ isPureAddition: { type: "boolean" },
361
+ },
362
+ },
363
+ },
364
+ },
365
+ },
366
+ 404: errorSchema,
367
+ },
368
+ },
369
+ }, async (req, reply) => {
370
+ const live = getSession(req.params.id);
371
+ if (live === undefined)
372
+ return notFound(reply);
373
+ const entries = await buildTurnDiff(live.session, live.workspacePath, live.lastAgentStartIndex);
374
+ return { entries };
375
+ });
376
+ // Phase 15 — session tree. Returns the full branching history of a
377
+ // session so the client can render a SessionTreePanel and let the
378
+ // user navigate or fork from any prior entry. Cold sessions get
379
+ // lazy-resumed via resumeSessionById so the SDK can read the JSONL
380
+ // and build the tree in memory.
381
+ fastify.get("/sessions/:id/tree", {
382
+ schema: {
383
+ description: "Branching history of the session. Returns every entry on the " +
384
+ "tree (across all branches) plus the current leaf id and the " +
385
+ "set of entry ids on the active branch path. Message entries " +
386
+ "include a truncated `preview` (first 200 chars of the text " +
387
+ "content); other entry types carry just the type + timestamp. " +
388
+ "Lazy-resumes cold sessions on demand so the route works " +
389
+ "without a prior SSE connect.",
390
+ tags: ["sessions"],
391
+ params: {
392
+ type: "object",
393
+ required: ["id"],
394
+ properties: { id: { type: "string" } },
395
+ },
396
+ response: {
397
+ 200: {
398
+ type: "object",
399
+ required: ["leafId", "branchIds", "entries"],
400
+ properties: {
401
+ leafId: { type: ["string", "null"] },
402
+ branchIds: { type: "array", items: { type: "string" } },
403
+ entries: {
404
+ type: "array",
405
+ items: {
406
+ type: "object",
407
+ required: ["id", "parentId", "type", "timestamp"],
408
+ properties: {
409
+ id: { type: "string" },
410
+ parentId: { type: ["string", "null"] },
411
+ type: { type: "string" },
412
+ timestamp: { type: "string" },
413
+ role: { type: "string" },
414
+ preview: { type: "string" },
415
+ label: { type: "string" },
416
+ },
417
+ },
418
+ },
419
+ },
420
+ },
421
+ 404: errorSchema,
422
+ },
423
+ },
424
+ }, async (req, reply) => {
425
+ let live = getSession(req.params.id);
426
+ if (live === undefined) {
427
+ try {
428
+ live = await resumeSessionById(req.params.id);
429
+ }
430
+ catch {
431
+ // SessionNotFoundError, SessionTombstonedError, or SDK
432
+ // resume failure all collapse to 404 here — the tree route
433
+ // doesn't need to distinguish (clients can't act on it
434
+ // differently). The SSE stream route DOES distinguish
435
+ // (it returns 410 on tombstone) since that signals "stop
436
+ // reconnecting" specifically.
437
+ return notFound(reply);
438
+ }
439
+ }
440
+ const sm = live.session.sessionManager;
441
+ const all = sm.getEntries();
442
+ const leafId = sm.getLeafId();
443
+ // The "active branch path" — every entry from the leaf back to
444
+ // the root. Used by the client to dim off-path nodes.
445
+ const branchIds = sm.getBranch().map((e) => e.id);
446
+ const entries = all.map((e) => {
447
+ const out = {
448
+ id: e.id,
449
+ parentId: e.parentId,
450
+ type: e.type,
451
+ timestamp: e.timestamp,
452
+ };
453
+ const label = sm.getLabel(e.id);
454
+ if (label !== undefined)
455
+ out.label = label;
456
+ if (e.type === "message") {
457
+ // BashExecutionMessage and CustomMessage variants share
458
+ // `role` but differ on the content field — narrow via a
459
+ // generic record-shape probe so we don't have to import
460
+ // the union and discriminate. Bash entries fall through
461
+ // with no preview, which is the right outcome (they're a
462
+ // tool invocation, not user-authored prose).
463
+ const m = e.message;
464
+ if (typeof m.role === "string")
465
+ out.role = m.role;
466
+ const preview = previewOfMessageContent(m.content);
467
+ if (preview !== undefined)
468
+ out.preview = preview;
469
+ }
470
+ return out;
471
+ });
472
+ return { leafId, branchIds, entries };
473
+ });
474
+ // Phase 16 — Context & Token Inspector. Returns the messages the
475
+ // agent will send to the LLM, plus a per-turn token + cost
476
+ // breakdown derived from each AssistantMessage.usage. The SDK
477
+ // already populates .usage on every assistant message; we just
478
+ // aggregate. Cold sessions lazy-resume so the route works without
479
+ // a prior SSE connect.
480
+ fastify.get("/sessions/:id/context", {
481
+ schema: {
482
+ description: "Token + message inspector for a session. Returns the full " +
483
+ "AgentMessage[] (the LLM's view, post-compaction), aggregate " +
484
+ "token + cost totals, a per-turn breakdown derived from each " +
485
+ "AssistantMessage.usage, and the SDK's contextUsage (current " +
486
+ "context window utilization). Lazy-resumes cold sessions so " +
487
+ "the route works without a prior SSE connect.",
488
+ tags: ["sessions"],
489
+ params: {
490
+ type: "object",
491
+ required: ["id"],
492
+ properties: { id: { type: "string" } },
493
+ },
494
+ response: {
495
+ 200: {
496
+ type: "object",
497
+ required: [
498
+ "messages",
499
+ "totalInputTokens",
500
+ "totalOutputTokens",
501
+ "totalCacheReadTokens",
502
+ "totalCacheWriteTokens",
503
+ "totalTokens",
504
+ "totalCost",
505
+ "turns",
506
+ "contextUsage",
507
+ ],
508
+ properties: {
509
+ messages: { type: "array", items: { type: "object", additionalProperties: true } },
510
+ totalInputTokens: { type: "integer", minimum: 0 },
511
+ totalOutputTokens: { type: "integer", minimum: 0 },
512
+ totalCacheReadTokens: { type: "integer", minimum: 0 },
513
+ totalCacheWriteTokens: { type: "integer", minimum: 0 },
514
+ totalTokens: { type: "integer", minimum: 0 },
515
+ totalCost: { type: "number", minimum: 0 },
516
+ turns: {
517
+ type: "array",
518
+ items: {
519
+ type: "object",
520
+ required: [
521
+ "index",
522
+ "inputTokens",
523
+ "outputTokens",
524
+ "cacheReadTokens",
525
+ "cacheWriteTokens",
526
+ "totalTokens",
527
+ "cost",
528
+ "model",
529
+ "provider",
530
+ "timestamp",
531
+ ],
532
+ properties: {
533
+ index: { type: "integer", minimum: 0 },
534
+ inputTokens: { type: "integer", minimum: 0 },
535
+ outputTokens: { type: "integer", minimum: 0 },
536
+ cacheReadTokens: { type: "integer", minimum: 0 },
537
+ cacheWriteTokens: { type: "integer", minimum: 0 },
538
+ totalTokens: { type: "integer", minimum: 0 },
539
+ cost: { type: "number", minimum: 0 },
540
+ model: { type: "string" },
541
+ provider: { type: "string" },
542
+ timestamp: { type: "integer", minimum: 0 },
543
+ stopReason: { type: "string" },
544
+ },
545
+ },
546
+ },
547
+ contextUsage: {
548
+ type: "object",
549
+ required: ["contextWindow"],
550
+ properties: {
551
+ // tokens / percent are nullable per the SDK
552
+ // (unknown right after compaction, before next LLM
553
+ // response). JSONSchema 7 doesn't have a clean
554
+ // nullable; using `["integer","null"]` would block
555
+ // Fastify's serializer, so we omit them entirely
556
+ // when null and document the absence here.
557
+ tokens: { type: "integer", minimum: 0 },
558
+ percent: { type: "number", minimum: 0 },
559
+ contextWindow: { type: "integer", minimum: 0 },
560
+ },
561
+ },
562
+ },
563
+ },
564
+ 404: errorSchema,
565
+ },
566
+ },
567
+ }, async (req, reply) => {
568
+ let live = getSession(req.params.id);
569
+ if (live === undefined) {
570
+ try {
571
+ live = await resumeSessionById(req.params.id);
572
+ }
573
+ catch {
574
+ // Same handling as the /tree route above — collapse all
575
+ // resume failures (not_found, tombstoned, SDK throw) to a
576
+ // 404. The /context route's caller can't act on the
577
+ // distinction.
578
+ return notFound(reply);
579
+ }
580
+ }
581
+ const messages = live.session.messages;
582
+ const turns = [];
583
+ let totalInput = 0;
584
+ let totalOutput = 0;
585
+ let totalCacheRead = 0;
586
+ let totalCacheWrite = 0;
587
+ let totalCost = 0;
588
+ messages.forEach((m, index) => {
589
+ // Probe the union via record-shape rather than discriminating
590
+ // the AgentMessage union here — keeps the route decoupled from
591
+ // SDK type internals (same approach used in /tree).
592
+ const obj = m;
593
+ if (obj.role !== "assistant")
594
+ return;
595
+ const u = obj.usage;
596
+ if (u === undefined)
597
+ return;
598
+ const inputT = typeof u.input === "number" ? u.input : 0;
599
+ const outputT = typeof u.output === "number" ? u.output : 0;
600
+ const cacheR = typeof u.cacheRead === "number" ? u.cacheRead : 0;
601
+ const cacheW = typeof u.cacheWrite === "number" ? u.cacheWrite : 0;
602
+ const totalT = typeof u.totalTokens === "number" ? u.totalTokens : inputT + outputT + cacheR + cacheW;
603
+ const cost = typeof u.cost?.total === "number" ? u.cost.total : 0;
604
+ const am = m;
605
+ const turn = {
606
+ index,
607
+ inputTokens: inputT,
608
+ outputTokens: outputT,
609
+ cacheReadTokens: cacheR,
610
+ cacheWriteTokens: cacheW,
611
+ totalTokens: totalT,
612
+ cost,
613
+ model: typeof am.model === "string" ? am.model : "unknown",
614
+ provider: typeof am.provider === "string" ? am.provider : "unknown",
615
+ timestamp: typeof am.timestamp === "number" ? am.timestamp : 0,
616
+ };
617
+ if (typeof am.stopReason === "string")
618
+ turn.stopReason = am.stopReason;
619
+ turns.push(turn);
620
+ totalInput += inputT;
621
+ totalOutput += outputT;
622
+ totalCacheRead += cacheR;
623
+ totalCacheWrite += cacheW;
624
+ totalCost += cost;
625
+ });
626
+ const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
627
+ const cu = live.session.getContextUsage();
628
+ const contextUsage = {
629
+ // contextWindow is the only required field. Fall back to 0
630
+ // when SDK reports undefined; client renders an "unknown"
631
+ // state at the cap.
632
+ contextWindow: cu !== undefined && typeof cu.contextWindow === "number" ? cu.contextWindow : 0,
633
+ };
634
+ if (cu !== undefined && cu.tokens !== null)
635
+ contextUsage.tokens = cu.tokens;
636
+ if (cu !== undefined && cu.percent !== null)
637
+ contextUsage.percent = cu.percent;
638
+ return {
639
+ messages,
640
+ totalInputTokens: totalInput,
641
+ totalOutputTokens: totalOutput,
642
+ totalCacheReadTokens: totalCacheRead,
643
+ totalCacheWriteTokens: totalCacheWrite,
644
+ totalTokens,
645
+ totalCost,
646
+ turns,
647
+ contextUsage,
648
+ };
649
+ });
650
+ fastify.post("/sessions/:id/name", {
651
+ schema: {
652
+ description: "Rename the session. Calls the SDK's `setSessionName` which appends " +
653
+ "a `session_info` entry to the JSONL. The new name is the user-visible " +
654
+ "title in the sidebar; the empty string clears any prior name. Session " +
655
+ "must be live; open the SSE stream first to auto-resume from disk.",
656
+ tags: ["sessions"],
657
+ params: {
658
+ type: "object",
659
+ required: ["id"],
660
+ properties: { id: { type: "string" } },
661
+ },
662
+ body: {
663
+ type: "object",
664
+ required: ["name"],
665
+ additionalProperties: false,
666
+ properties: { name: { type: "string", maxLength: 200 } },
667
+ },
668
+ response: {
669
+ 200: liveSummarySchema,
670
+ 404: errorSchema,
671
+ },
672
+ },
673
+ }, async (req, reply) => {
674
+ const live = getSession(req.params.id);
675
+ if (live === undefined)
676
+ return notFound(reply);
677
+ live.session.setSessionName(req.body.name);
678
+ return liveSummaryBody({
679
+ sessionId: live.sessionId,
680
+ projectId: live.projectId,
681
+ workspacePath: live.workspacePath,
682
+ createdAt: live.createdAt,
683
+ lastActivityAt: live.lastActivityAt,
684
+ name: live.session.sessionName,
685
+ messageCount: live.session.messages.length,
686
+ isStreaming: live.session.isStreaming,
687
+ });
688
+ });
689
+ fastify.delete("/sessions/:id", {
690
+ schema: {
691
+ description: "Dispose the live session AND/OR delete the on-disk JSONL. The " +
692
+ "`hard` query param is the destructive-intent toggle:\n" +
693
+ " - live + no `hard` → dispose, file preserved → 204\n" +
694
+ " - live + `hard=1` → dispose AND delete the JSONL → 204\n" +
695
+ " - cold + `hard=1` → delete the JSONL → 204\n" +
696
+ " - cold + no `hard` → 404 (nothing to dispose; pass `hard=1` " +
697
+ "if you mean to delete the file)\n" +
698
+ " - not found anywhere → 404\n" +
699
+ "The `hard=1`-required-for-cold rule keeps DELETE without `hard` " +
700
+ "non-destructive in every case: programmatic clients hammering " +
701
+ "DELETE in a cleanup loop won't accidentally remove on-disk " +
702
+ "session files.",
703
+ tags: ["sessions"],
704
+ params: {
705
+ type: "object",
706
+ required: ["id"],
707
+ properties: { id: { type: "string" } },
708
+ },
709
+ querystring: {
710
+ type: "object",
711
+ properties: {
712
+ hard: { type: "string", enum: ["0", "1", "true", "false"] },
713
+ },
714
+ },
715
+ response: {
716
+ 204: { type: "null" },
717
+ 404: errorSchema,
718
+ 500: errorSchema,
719
+ },
720
+ },
721
+ }, async (req, reply) => {
722
+ const hard = req.query.hard === "1" || req.query.hard === "true";
723
+ const wasLive = await disposeSession(req.params.id);
724
+ if (wasLive && !hard)
725
+ return reply.code(204).send();
726
+ if (!wasLive && !hard) {
727
+ // Cold session, no destructive intent — 404. The user/client
728
+ // has to opt in via `?hard=1` to delete a cold session's
729
+ // JSONL. Mirrors the live-with-no-hard "non-destructive"
730
+ // semantic in the cold case.
731
+ return notFound(reply);
732
+ }
733
+ // Hard delete (live OR cold). After dispose, the registry no
734
+ // longer has the entry; deleteColdSession's "live" guard
735
+ // doesn't trip on the ordinary case.
736
+ let r;
737
+ try {
738
+ r = await deleteColdSession(req.params.id);
739
+ }
740
+ catch (err) {
741
+ // Real fs failure (permissions, IO) — distinguish from
742
+ // not_found so the operator sees a 500 not a misleading 404.
743
+ req.log.error({ err }, "deleteColdSession failed");
744
+ return reply.code(500).send({ error: "session_delete_failed" });
745
+ }
746
+ if (r === "deleted")
747
+ return reply.code(204).send();
748
+ if (r === "live") {
749
+ // Race: another client resumed the session between our
750
+ // dispose and the cold-delete file lookup. The user asked
751
+ // for hard delete; honor that by retrying once.
752
+ // (As of the tombstone fix in session-registry, this path is
753
+ // very rare — disposeSession sets a 1.5s no-revive window
754
+ // that resumeSession enforces. The retry stays as defense
755
+ // in depth for any non-SSE revival path.)
756
+ const live2 = await disposeSession(req.params.id);
757
+ if (live2) {
758
+ try {
759
+ const r2 = await deleteColdSession(req.params.id);
760
+ if (r2 === "deleted" || r2 === "not_found")
761
+ return reply.code(204).send();
762
+ }
763
+ catch (err) {
764
+ req.log.error({ err }, "deleteColdSession failed on retry");
765
+ return reply.code(500).send({ error: "session_delete_failed" });
766
+ }
767
+ }
768
+ // Couldn't reach a steady state — the resumer keeps winning.
769
+ // Single-tenant + this race is extremely rare; surface as 500
770
+ // rather than silently lying about the outcome.
771
+ return reply.code(500).send({ error: "session_delete_failed" });
772
+ }
773
+ // r === "not_found"
774
+ if (wasLive) {
775
+ // Dispose succeeded but no JSONL on disk — the live session
776
+ // had no persisted entries (nothing was written). Treat as
777
+ // success; the live state IS gone.
778
+ return reply.code(204).send();
779
+ }
780
+ return notFound(reply);
781
+ });
782
+ };
783
+ //# sourceMappingURL=sessions.js.map