@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.
- package/README.md +37 -13
- package/dist/cli.js +6 -4
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/{CodeViewer-iYSv2-dg.js → CodeViewer-Bv8l_MFi.js} +1 -1
- package/dist/client/assets/{TerminalPanel-BpFW6j27.js → TerminalPanel-BZKsaE9s.js} +12 -8
- package/dist/client/assets/{index-D4dbiKXF.js → index-C9oPLpm7.js} +399 -201
- package/dist/client/assets/{vendor-editor-core-B4Sq6exx.js → vendor-editor-core-Bc1A7SO7.js} +4 -4
- package/dist/client/assets/{vendor-editor-languages-DznYbTkJ.js → vendor-editor-languages-D0CnB2js.js} +3 -3
- package/dist/client/index.html +3 -3
- package/dist/config.js +91 -3
- package/dist/config.js.map +1 -1
- package/dist/pi-web-plugins/updates/pi-web-plugin.js +52 -56
- package/dist/pi-web-plugins/updates/updatesLogic.js +65 -0
- package/dist/pi-web-plugins/workspace-tasks/tasksPanelElement.js +1 -5
- package/dist/server/app.js +15 -11
- package/dist/server/app.js.map +1 -1
- package/dist/server/configRoutes.js +42 -0
- package/dist/server/configRoutes.js.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/piWebStatus.js +15 -6
- package/dist/server/piWebStatus.js.map +1 -1
- package/dist/server/sessiond.js +17 -2
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/attachmentService.js +61 -0
- package/dist/server/sessions/attachmentService.js.map +1 -0
- package/dist/server/sessions/piSessionManagerGateway.js +2 -2
- package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
- package/dist/server/sessions/piSessionService.js +294 -20
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionCommandService.js +5 -1
- package/dist/server/sessions/sessionCommandService.js.map +1 -1
- package/dist/server/sessions/sessionRoutes.js +28 -4
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/sessions/spawnSessionTool.js +36 -0
- package/dist/server/sessions/spawnSessionTool.js.map +1 -0
- package/dist/server/sessions/spawnSubsessionTool.js +166 -0
- package/dist/server/sessions/spawnSubsessionTool.js.map +1 -0
- package/dist/server/sessions/spawnTargetResolver.js +36 -0
- package/dist/server/sessions/spawnTargetResolver.js.map +1 -0
- package/dist/server/sessions/subsessionTranscript.js +235 -0
- package/dist/server/sessions/subsessionTranscript.js.map +1 -0
- package/dist/server/workspaceExplorerRoutes.js +19 -4
- package/dist/server/workspaceExplorerRoutes.js.map +1 -1
- package/dist/server/workspaces/effectivePathAccess.js +27 -0
- package/dist/server/workspaces/effectivePathAccess.js.map +1 -0
- package/dist/server/workspaces/fileContentService.js +6 -6
- package/dist/server/workspaces/fileContentService.js.map +1 -1
- package/dist/server/workspaces/fileSuggestions.js +311 -23
- package/dist/server/workspaces/fileSuggestions.js.map +1 -1
- package/dist/server/workspaces/fileTreeService.js +16 -7
- package/dist/server/workspaces/fileTreeService.js.map +1 -1
- package/dist/server/workspaces/imagePreviewService.js +5 -5
- package/dist/server/workspaces/imagePreviewService.js.map +1 -1
- package/dist/server/workspaces/pathAccessPolicy.js +90 -0
- package/dist/server/workspaces/pathAccessPolicy.js.map +1 -0
- package/dist/server/workspaces/pathSafety.js +0 -15
- package/dist/server/workspaces/pathSafety.js.map +1 -1
- package/dist/server/workspaces/projectPiWebConfig.js +56 -0
- package/dist/server/workspaces/projectPiWebConfig.js.map +1 -0
- package/dist/shared/apiTypes.d.ts +55 -3
- package/dist/shared/apiTypes.js +3 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/capabilities.js +5 -2
- package/dist/shared/capabilities.js.map +1 -1
- package/dist/shared/federatedRoutes.js +3 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/dist/shared/piWebStatusParsing.js +0 -15
- package/dist/shared/piWebStatusParsing.js.map +1 -1
- package/dist/shared/promptAttachments.js +73 -0
- package/dist/shared/promptAttachments.js.map +1 -0
- package/dist/shared/thinkingLevels.d.ts +27 -0
- package/dist/shared/thinkingLevels.js +31 -0
- package/dist/shared/thinkingLevels.js.map +1 -0
- package/dist/shared/workspaceDeletion.js +1 -1
- package/dist/shared/workspaceDeletion.js.map +1 -1
- package/docs/assets/pi-web-desktop.png +0 -0
- package/docs/assets/pi-web-mobile.png +0 -0
- package/docs/assets/pi-web-tablet.png +0 -0
- package/docs/config.md +166 -0
- package/docs/plugins.md +3 -3
- package/package.json +27 -14
- package/docs/assets/pi-web-demo.gif +0 -0
- 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 = [
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
249
|
-
const
|
|
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");
|