@jmfederico/pi-web 1.202606.3 → 1.202606.5

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 (84) hide show
  1. package/README.md +37 -13
  2. package/dist/cli.js +6 -4
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-iYSv2-dg.js → CodeViewer-Bv8l_MFi.js} +1 -1
  5. package/dist/client/assets/{TerminalPanel-BpFW6j27.js → TerminalPanel-BZKsaE9s.js} +12 -8
  6. package/dist/client/assets/{index-D4dbiKXF.js → index-C9oPLpm7.js} +399 -201
  7. package/dist/client/assets/{vendor-editor-core-B4Sq6exx.js → vendor-editor-core-Bc1A7SO7.js} +4 -4
  8. package/dist/client/assets/{vendor-editor-languages-DznYbTkJ.js → vendor-editor-languages-D0CnB2js.js} +3 -3
  9. package/dist/client/index.html +3 -3
  10. package/dist/config.js +91 -3
  11. package/dist/config.js.map +1 -1
  12. package/dist/pi-web-plugins/updates/pi-web-plugin.js +52 -56
  13. package/dist/pi-web-plugins/updates/updatesLogic.js +65 -0
  14. package/dist/pi-web-plugins/workspace-tasks/tasksPanelElement.js +1 -5
  15. package/dist/server/app.js +15 -11
  16. package/dist/server/app.js.map +1 -1
  17. package/dist/server/configRoutes.js +42 -0
  18. package/dist/server/configRoutes.js.map +1 -1
  19. package/dist/server/index.js +2 -2
  20. package/dist/server/index.js.map +1 -1
  21. package/dist/server/piWebStatus.js +15 -6
  22. package/dist/server/piWebStatus.js.map +1 -1
  23. package/dist/server/sessiond.js +17 -2
  24. package/dist/server/sessiond.js.map +1 -1
  25. package/dist/server/sessions/attachmentService.js +61 -0
  26. package/dist/server/sessions/attachmentService.js.map +1 -0
  27. package/dist/server/sessions/piSessionManagerGateway.js +2 -2
  28. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  29. package/dist/server/sessions/piSessionService.js +294 -20
  30. package/dist/server/sessions/piSessionService.js.map +1 -1
  31. package/dist/server/sessions/sessionCommandService.js +5 -1
  32. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  33. package/dist/server/sessions/sessionRoutes.js +28 -4
  34. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  35. package/dist/server/sessions/spawnSessionTool.js +36 -0
  36. package/dist/server/sessions/spawnSessionTool.js.map +1 -0
  37. package/dist/server/sessions/spawnSubsessionTool.js +166 -0
  38. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -0
  39. package/dist/server/sessions/spawnTargetResolver.js +36 -0
  40. package/dist/server/sessions/spawnTargetResolver.js.map +1 -0
  41. package/dist/server/sessions/subsessionTranscript.js +235 -0
  42. package/dist/server/sessions/subsessionTranscript.js.map +1 -0
  43. package/dist/server/workspaceExplorerRoutes.js +19 -4
  44. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  45. package/dist/server/workspaces/effectivePathAccess.js +27 -0
  46. package/dist/server/workspaces/effectivePathAccess.js.map +1 -0
  47. package/dist/server/workspaces/fileContentService.js +6 -6
  48. package/dist/server/workspaces/fileContentService.js.map +1 -1
  49. package/dist/server/workspaces/fileSuggestions.js +311 -23
  50. package/dist/server/workspaces/fileSuggestions.js.map +1 -1
  51. package/dist/server/workspaces/fileTreeService.js +16 -7
  52. package/dist/server/workspaces/fileTreeService.js.map +1 -1
  53. package/dist/server/workspaces/imagePreviewService.js +5 -5
  54. package/dist/server/workspaces/imagePreviewService.js.map +1 -1
  55. package/dist/server/workspaces/pathAccessPolicy.js +90 -0
  56. package/dist/server/workspaces/pathAccessPolicy.js.map +1 -0
  57. package/dist/server/workspaces/pathSafety.js +0 -15
  58. package/dist/server/workspaces/pathSafety.js.map +1 -1
  59. package/dist/server/workspaces/projectPiWebConfig.js +56 -0
  60. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -0
  61. package/dist/shared/apiTypes.d.ts +55 -3
  62. package/dist/shared/apiTypes.js +3 -0
  63. package/dist/shared/apiTypes.js.map +1 -1
  64. package/dist/shared/capabilities.js +5 -2
  65. package/dist/shared/capabilities.js.map +1 -1
  66. package/dist/shared/federatedRoutes.js +3 -0
  67. package/dist/shared/federatedRoutes.js.map +1 -1
  68. package/dist/shared/piWebStatusParsing.js +0 -15
  69. package/dist/shared/piWebStatusParsing.js.map +1 -1
  70. package/dist/shared/promptAttachments.js +73 -0
  71. package/dist/shared/promptAttachments.js.map +1 -0
  72. package/dist/shared/thinkingLevels.d.ts +27 -0
  73. package/dist/shared/thinkingLevels.js +31 -0
  74. package/dist/shared/thinkingLevels.js.map +1 -0
  75. package/dist/shared/workspaceDeletion.js +1 -1
  76. package/dist/shared/workspaceDeletion.js.map +1 -1
  77. package/docs/assets/pi-web-desktop.png +0 -0
  78. package/docs/assets/pi-web-mobile.png +0 -0
  79. package/docs/assets/pi-web-tablet.png +0 -0
  80. package/docs/config.md +166 -0
  81. package/docs/plugins.md +3 -3
  82. package/package.json +27 -14
  83. package/docs/assets/pi-web-demo.gif +0 -0
  84. package/docs/assets/pi-web-demo.webm +0 -0
@@ -8,10 +8,21 @@ import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sess
8
8
  import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
9
9
  import { computeEditPreview } from "./editPreview.js";
10
10
  import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
11
+ import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
12
+ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
11
13
  import { cwdPathsEqual } from "../workingDirectory.js";
14
+ import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
15
+ import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
16
+ import { buildTranscriptView } from "./subsessionTranscript.js";
17
+ const noopLogger = { info() { } };
12
18
  function noop() {
13
19
  // Intentionally empty default unsubscribe callback.
14
20
  }
21
+ function spawnTargetError(decision) {
22
+ if (decision.reason === "not-registered")
23
+ return new Error("Spawning session is not in a registered project");
24
+ return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
25
+ }
15
26
  function authLossWarningKey(sessionId, provider, modelId) {
16
27
  return `${sessionId}:${provider}/${modelId}`;
17
28
  }
@@ -41,10 +52,14 @@ function defaultCreateAgentRuntime(createRuntime, options) {
41
52
  throw new Error("Default runtime creation requires an SDK SessionManager");
42
53
  return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
43
54
  }
44
- function createDefaultRuntimeFactory(authStorage, modelRegistry) {
55
+ function createDefaultRuntimeFactory(authStorage, modelRegistry, spawn, subsessions) {
45
56
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
46
57
  const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
47
- const customTools = [createPiWebEditToolDefinition(cwd)];
58
+ const customTools = [
59
+ createPiWebEditToolDefinition(cwd),
60
+ ...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
61
+ ...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
62
+ ];
48
63
  const options = sessionStartEvent === undefined
49
64
  ? { services, sessionManager, customTools }
50
65
  : { services, sessionManager, sessionStartEvent, customTools };
@@ -81,15 +96,35 @@ export class PiSessionService {
81
96
  this.compactionPromptQueues = new Map();
82
97
  this.compactionDrainTimers = new Map();
83
98
  this.authLossWarnings = new Set();
99
+ /** Tracked subsession id -> the parent session id that spawned it. */
100
+ this.subsessionParents = new Map();
101
+ /** Parent session id -> the set of tracked subsession ids it spawned. */
102
+ this.subsessionChildren = new Map();
103
+ /**
104
+ * Tracked subsession id -> whether a completion notification is armed.
105
+ * Armed when the child starts working; firing on completion disarms it so a
106
+ * child that works again (and stops again) notifies the parent each time.
107
+ */
108
+ this.subsessionNotifyArmed = new Map();
84
109
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
85
110
  this.agentDir = deps.agentDir ?? getAgentDir();
86
111
  this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
87
112
  this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
88
- this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
113
+ this.spawnTargets = deps.spawnTargets;
114
+ this.logger = deps.logger ?? noopLogger;
115
+ // Subsessions are a beta capability gated behind their own flag, and they
116
+ // also require the spawn capability (they share its project-scope resolver).
117
+ const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
118
+ this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
119
+ spawn: (input) => this.spawnSubsession(input),
120
+ list: (parentSessionId) => this.listSubsessions(parentSessionId),
121
+ check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId),
122
+ read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query),
123
+ });
89
124
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
90
125
  this.workspaceActivity = deps.workspaceActivity;
91
126
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
92
- this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text), events, {
127
+ this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }), events, {
93
128
  onCompactionStart: (session) => {
94
129
  this.publishActivity(session, "compacting", "active");
95
130
  this.publishStatus(session);
@@ -111,6 +146,9 @@ export class PiSessionService {
111
146
  this.activities.clear();
112
147
  this.compactionPromptQueues.clear();
113
148
  this.authLossWarnings.clear();
149
+ this.subsessionParents.clear();
150
+ this.subsessionChildren.clear();
151
+ this.subsessionNotifyArmed.clear();
114
152
  await Promise.all(activeSessions.map(async (active) => {
115
153
  active.unsubscribe();
116
154
  this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
@@ -133,10 +171,10 @@ export class PiSessionService {
133
171
  .filter(isDefined);
134
172
  return [...unarchivedSessions, ...archivedSessions];
135
173
  }
136
- async start(cwd) {
137
- const active = await this.create(this.sessionManager.create(cwd), cwd);
174
+ async start(cwd, parentSession) {
175
+ const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
138
176
  const { session } = active.runtime;
139
- return {
177
+ const created = {
140
178
  id: session.sessionId,
141
179
  path: session.sessionFile ?? "",
142
180
  cwd,
@@ -144,8 +182,153 @@ export class PiSessionService {
144
182
  modified: new Date().toISOString(),
145
183
  messageCount: session.messages.length,
146
184
  firstMessage: "",
185
+ // Include the parent so listeners can nest the new session in the tree
186
+ // immediately, instead of showing it flat until the next reload.
187
+ ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
188
+ };
189
+ // Broadcast so other clients (and the spawning agent's UI) can add the new
190
+ // session to their list without a manual reload.
191
+ this.events.publishGlobal({ type: "session.created", session: created });
192
+ return created;
193
+ }
194
+ /**
195
+ * Start a new session on behalf of a LLM and deliver an initial prompt to it.
196
+ * The target cwd is constrained to a workspace of the same registered project
197
+ * as the spawning session so the new session is visible in the web UI.
198
+ */
199
+ async spawnSession(input) {
200
+ if (this.spawnTargets === undefined)
201
+ throw new Error("Spawning sessions is disabled");
202
+ const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
203
+ if (!decision.allowed)
204
+ throw spawnTargetError(decision);
205
+ const created = await this.start(decision.cwd);
206
+ await this.prompt(created.id, input.prompt);
207
+ this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
208
+ return { sessionId: created.id, cwd: decision.cwd };
209
+ }
210
+ /**
211
+ * Start a *tracked* child session on behalf of a LLM. Identical to
212
+ * {@link spawnSession} in how the target cwd is resolved, but the child
213
+ * records its parent (so it shows in the session tree) and is registered so
214
+ * the parent is notified when it stops working and can inspect it later.
215
+ */
216
+ async spawnSubsession(input) {
217
+ if (this.spawnTargets === undefined)
218
+ throw new Error("Spawning sessions is disabled");
219
+ const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
220
+ if (!decision.allowed)
221
+ throw spawnTargetError(decision);
222
+ const created = await this.start(decision.cwd, input.parentSessionFile);
223
+ this.registerSubsession(input.parentSessionId, created.id);
224
+ await this.prompt(created.id, input.prompt);
225
+ this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
226
+ return { sessionId: created.id, cwd: decision.cwd };
227
+ }
228
+ /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
229
+ async listSubsessions(parentSessionId) {
230
+ const childIds = this.subsessionChildren.get(parentSessionId);
231
+ if (childIds === undefined)
232
+ return [];
233
+ return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
234
+ }
235
+ /** Status and final result of a subsession, scoped to the caller's children. */
236
+ async checkSubsession(parentSessionId, sessionId) {
237
+ const session = await this.openSubsession(parentSessionId, sessionId);
238
+ const messages = historyMessages(session);
239
+ return {
240
+ sessionId,
241
+ cwd: session.sessionManager.getCwd(),
242
+ status: await this.subsessionStatus(session),
243
+ finalText: finalAssistantText(messages),
244
+ messageCount: messages.length,
147
245
  };
148
246
  }
247
+ /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
248
+ async readSubsession(parentSessionId, sessionId, query) {
249
+ const session = await this.openSubsession(parentSessionId, sessionId);
250
+ const view = buildTranscriptView(historyMessages(session), query);
251
+ return {
252
+ sessionId,
253
+ cwd: session.sessionManager.getCwd(),
254
+ status: await this.subsessionStatus(session),
255
+ ...view,
256
+ };
257
+ }
258
+ /** Open a session after verifying it is one of the caller's tracked children. */
259
+ async openSubsession(parentSessionId, sessionId) {
260
+ if (this.subsessionParents.get(sessionId) !== parentSessionId) {
261
+ throw new Error(`Session ${sessionId} is not one of your subsessions`);
262
+ }
263
+ return this.getOrOpen(sessionId);
264
+ }
265
+ registerSubsession(parentSessionId, childSessionId) {
266
+ this.subsessionParents.set(childSessionId, parentSessionId);
267
+ const children = this.subsessionChildren.get(parentSessionId) ?? new Set();
268
+ children.add(childSessionId);
269
+ this.subsessionChildren.set(parentSessionId, children);
270
+ this.subsessionNotifyArmed.set(childSessionId, false);
271
+ }
272
+ async subsessionSummaryFields(childSessionId) {
273
+ const active = this.active.get(childSessionId);
274
+ if (active !== undefined) {
275
+ return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) };
276
+ }
277
+ const archived = await this.archiveStore.get(childSessionId);
278
+ if (archived !== undefined)
279
+ return { cwd: archived.cwd, status: "archived" };
280
+ return { cwd: "", status: "unknown" };
281
+ }
282
+ async subsessionStatus(session) {
283
+ if (await this.archiveStore.isArchived(session.sessionId))
284
+ return "archived";
285
+ if (this.hasActiveWork(session))
286
+ return "working";
287
+ if (this.activities.get(session.sessionId)?.phase === "error")
288
+ return "error";
289
+ return "idle";
290
+ }
291
+ /**
292
+ * Drive parent notifications from a tracked child's status. Arms a pending
293
+ * notification while the child is working, and when it stops fires a single
294
+ * follow-up message to the parent via {@link prompt} (which queues if the
295
+ * parent is busy and delivers immediately when it is idle).
296
+ */
297
+ updateSubsessionTracking(session) {
298
+ const childId = session.sessionId;
299
+ const parentId = this.subsessionParents.get(childId);
300
+ if (parentId === undefined)
301
+ return;
302
+ if (this.hasActiveWork(session)) {
303
+ this.subsessionNotifyArmed.set(childId, true);
304
+ return;
305
+ }
306
+ if (this.subsessionNotifyArmed.get(childId) !== true)
307
+ return;
308
+ this.subsessionNotifyArmed.set(childId, false);
309
+ const status = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
310
+ const finalText = finalAssistantText(historyMessages(session));
311
+ const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
312
+ const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`;
313
+ void this.notifyParentOfSubsession(parentId, childId, text);
314
+ }
315
+ /**
316
+ * Deliver a subsession-completion notice to the parent as a system-authored
317
+ * custom message rather than a user message, so it is not attributed to the
318
+ * human in the transcript. It still wakes an idle parent (`triggerTurn`) and
319
+ * queues behind in-flight work (`deliverAs: "followUp"`), preserving the
320
+ * established "queue if busy, send and act if idle" behavior.
321
+ */
322
+ async notifyParentOfSubsession(parentId, childId, text) {
323
+ try {
324
+ const session = await this.getOrOpen(parentId);
325
+ await session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { triggerTurn: true, deliverAs: "followUp" });
326
+ this.publishStatus(session);
327
+ }
328
+ catch (error) {
329
+ this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
330
+ }
331
+ }
149
332
  async messages(ref, page) {
150
333
  const session = await this.getOrOpen(ref);
151
334
  return pageMessagesAtSafeBoundary(historyMessages(session), page);
@@ -194,7 +377,13 @@ export class PiSessionService {
194
377
  async setThinkingLevel(ref, level) {
195
378
  await this.assertWritable(ref);
196
379
  const session = await this.getOrOpen(ref);
197
- session.setThinkingLevel(level);
380
+ // pi owns the valid set; validate against the session's live levels rather
381
+ // than a hardcoded union so this stays correct if pi changes the set.
382
+ const available = session.getAvailableThinkingLevels();
383
+ const match = available.find((candidate) => candidate === level);
384
+ if (match === undefined)
385
+ throw new Error(`Invalid thinking level: ${level}`);
386
+ session.setThinkingLevel(match);
198
387
  this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
199
388
  this.publishStatus(session);
200
389
  return this.statusFromSession(session);
@@ -223,30 +412,38 @@ export class PiSessionService {
223
412
  }
224
413
  return commands.sort((a, b) => a.name.localeCompare(b.name));
225
414
  }
226
- async prompt(ref, text, streamingBehavior) {
415
+ async prompt(ref, text, streamingBehavior, attachments, options) {
227
416
  const promptText = requirePromptText(text);
417
+ // Command-forwarded prompts (e.g. /skill:*) are expanded by the agent, which
418
+ // streams the canonical message back. The client doesn't render the raw
419
+ // command text, so the server must not echo it either, or it would show up
420
+ // as a transient line that vanishes on reload.
421
+ const echoUserMessage = options?.echoUserMessage !== false;
228
422
  const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
423
+ const parsedAttachments = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
424
+ const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
229
425
  await this.assertWritable(ref);
230
426
  const session = await this.getOrOpen(ref);
231
427
  this.maybeGenerateSessionName(session, promptText);
232
428
  const isQueued = session.isStreaming || session.isCompacting;
233
429
  const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
234
- if (isQueued && this.hasQueuedMessageText(session, promptText)) {
430
+ if (isQueued && images.length === 0 && this.hasQueuedMessageText(session, promptText)) {
235
431
  this.publishActivity(session, "duplicate queued message ignored", "active");
236
432
  this.publishStatus(session);
237
433
  return;
238
434
  }
239
435
  if (session.isCompacting) {
240
- this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp");
436
+ this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
241
437
  return;
242
438
  }
243
- void this.submitPrompt(session, promptText, behavior);
439
+ void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
244
440
  }
245
- submitPrompt(session, text, behavior) {
441
+ submitPrompt(session, text, behavior, images = [], echoUserMessage = true) {
246
442
  this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
247
- if (behavior === undefined)
248
- this.events.publish(session.sessionId, { type: "message.append", message: userTextMessage(text) });
249
- const promptPromise = session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error) => {
443
+ if (behavior === undefined && echoUserMessage)
444
+ this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
445
+ const promptOptions = buildPromptOptions(behavior, images);
446
+ const promptPromise = session.prompt(text, promptOptions).catch((error) => {
250
447
  const message = error instanceof Error ? error.message : String(error);
251
448
  this.publishActivity(session, "error", "error", message);
252
449
  this.events.publish(session.sessionId, { type: "session.error", message });
@@ -254,13 +451,21 @@ export class PiSessionService {
254
451
  void promptPromise;
255
452
  return promptPromise;
256
453
  }
257
- enqueuePromptDuringCompaction(session, text, kind) {
454
+ enqueuePromptDuringCompaction(session, text, kind, images = [], echoUserMessage = true) {
258
455
  const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
259
- queue.push({ kind, text });
456
+ queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
260
457
  this.compactionPromptQueues.set(session.sessionId, queue);
261
458
  this.publishActivity(session, "message queued during compaction", "active");
262
459
  this.publishStatus(session);
263
460
  }
461
+ async saveAttachments(ref, attachments, folder) {
462
+ const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
463
+ if (parsed.length === 0)
464
+ return [];
465
+ await this.assertWritable(ref);
466
+ const active = await this.getActive(ref);
467
+ return saveAttachmentsToWorkspace(active.runtime.cwd, parsed, folder === undefined ? {} : { folder });
468
+ }
264
469
  async shell(ref, text) {
265
470
  await this.assertWritable(ref);
266
471
  const active = await this.getActive(ref);
@@ -352,6 +557,15 @@ export class PiSessionService {
352
557
  await this.ensureArchivedRecordMoved(record);
353
558
  await this.archiveStore.deleteArchived(record.sessionId);
354
559
  }
560
+ async reload(ref) {
561
+ await this.assertWritable(ref);
562
+ const session = await this.getOrOpen(ref);
563
+ if (this.hasActiveWork(session))
564
+ throw new Error("Stop current session activity before reloading");
565
+ await this.closeActive(session.sessionId);
566
+ const reopened = await this.getActive(ref);
567
+ this.publishStatus(reopened.runtime.session);
568
+ }
355
569
  async detachParent(ref) {
356
570
  const session = await this.getOrOpen(ref);
357
571
  const sessionFile = session.sessionFile;
@@ -472,6 +686,10 @@ export class PiSessionService {
472
686
  this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
473
687
  this.clearAuthLossWarningsForSession(sessionId);
474
688
  this.clearCompactionPromptQueue(sessionId);
689
+ // Disarm subsession notification before teardown so the abort below cannot
690
+ // emit a "stopped working" event that notifies the parent (e.g. on archive).
691
+ // The parent/children link is kept so the parent can still see the child.
692
+ this.subsessionNotifyArmed.delete(sessionId);
475
693
  clearSessionQueue(active.runtime.session);
476
694
  active.unsubscribe();
477
695
  try {
@@ -562,6 +780,7 @@ export class PiSessionService {
562
780
  if (eventType === "agent_start" || eventType === "agent_end")
563
781
  this.scheduleCompactionQueueDrain(session.sessionId);
564
782
  this.publishStatus(session);
783
+ this.updateSubsessionTracking(session);
565
784
  });
566
785
  this.active.set(session.sessionId, active);
567
786
  }
@@ -589,14 +808,14 @@ export class PiSessionService {
589
808
  return;
590
809
  this.publishStatus(session);
591
810
  for (const prompt of queued)
592
- void this.submitPrompt(session, prompt.text, prompt.kind);
811
+ void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images, prompt.echoUserMessage ?? true);
593
812
  return;
594
813
  }
595
814
  const prompt = this.shiftCompactionPrompt(sessionId);
596
815
  if (prompt === undefined)
597
816
  return;
598
817
  this.publishStatus(session);
599
- const submitted = this.submitPrompt(session, prompt.text, undefined);
818
+ const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images, prompt.echoUserMessage ?? true);
600
819
  void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
601
820
  }
602
821
  takeCompactionPromptQueue(sessionId) {
@@ -691,6 +910,10 @@ export class PiSessionService {
691
910
  publishHeartbeats() {
692
911
  for (const active of this.active.values()) {
693
912
  const { session } = active.runtime;
913
+ // Re-evaluate subsession completion here too: agent_end can arrive while
914
+ // the session still reports active work transiently, so the event-driven
915
+ // latch may not fire. The heartbeat re-checks once the session settles.
916
+ this.updateSubsessionTracking(session);
694
917
  const activity = this.activities.get(session.sessionId);
695
918
  if (!this.hasActiveWork(session)) {
696
919
  if (activity?.phase === "active")
@@ -998,6 +1221,28 @@ function queuedMessagesFromSession(session, extraQueuedMessages = []) {
998
1221
  function userTextMessage(text) {
999
1222
  return { role: "user", content: text };
1000
1223
  }
1224
+ /**
1225
+ * Build the optimistic user message echoed to clients. When images are present
1226
+ * we mirror pi's content-array shape (`[{type:"text"}, {type:"image"}, ...]`) so
1227
+ * the local echo matches what pi persists in the session branch.
1228
+ */
1229
+ function userMessage(text, images) {
1230
+ if (images.length === 0)
1231
+ return userTextMessage(text);
1232
+ const content = [];
1233
+ if (text !== "")
1234
+ content.push({ type: "text", text });
1235
+ content.push(...images);
1236
+ return { role: "user", content };
1237
+ }
1238
+ function buildPromptOptions(behavior, images) {
1239
+ const options = {};
1240
+ if (behavior !== undefined)
1241
+ options.streamingBehavior = behavior;
1242
+ if (images.length > 0)
1243
+ options.images = images;
1244
+ return Object.keys(options).length > 0 ? options : undefined;
1245
+ }
1001
1246
  function stringValue(value) {
1002
1247
  return typeof value === "string" ? value : "";
1003
1248
  }
@@ -1017,6 +1262,35 @@ function historyMessages(session) {
1017
1262
  }
1018
1263
  return messages;
1019
1264
  }
1265
+ /** customType marking a parent-facing subsession-completion notice. */
1266
+ const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
1267
+ const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;
1268
+ function truncateForNotification(text) {
1269
+ if (text.length <= SUBSESSION_NOTIFICATION_PREVIEW_CHARS)
1270
+ return text;
1271
+ return `${text.slice(0, SUBSESSION_NOTIFICATION_PREVIEW_CHARS)}…`;
1272
+ }
1273
+ /** Most recent assistant text from a history message list, or "" if none. */
1274
+ function finalAssistantText(messages) {
1275
+ for (let i = messages.length - 1; i >= 0; i--) {
1276
+ const message = messages[i];
1277
+ if (!isRecord(message) || message["role"] !== "assistant")
1278
+ continue;
1279
+ const content = message["content"];
1280
+ if (typeof content === "string")
1281
+ return content;
1282
+ if (!Array.isArray(content))
1283
+ continue;
1284
+ const texts = [];
1285
+ for (const part of content) {
1286
+ if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string")
1287
+ texts.push(part["text"]);
1288
+ }
1289
+ if (texts.length > 0)
1290
+ return texts.join("\n").trim();
1291
+ }
1292
+ return "";
1293
+ }
1020
1294
  function toClientEvent(event) {
1021
1295
  const eventType = getString(event, "type");
1022
1296
  const assistantMessageEvent = getProperty(event, "assistantMessageEvent");