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