pi-webdesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +111 -0
  2. package/dist/apps/daemon/src/appearance-preferences.js +218 -0
  3. package/dist/apps/daemon/src/auth.js +88 -0
  4. package/dist/apps/daemon/src/bin.js +123 -0
  5. package/dist/apps/daemon/src/cli.js +48 -0
  6. package/dist/apps/daemon/src/event-hub.js +155 -0
  7. package/dist/apps/daemon/src/index.js +102 -0
  8. package/dist/apps/daemon/src/launcher-control.js +114 -0
  9. package/dist/apps/daemon/src/launcher.js +73 -0
  10. package/dist/apps/daemon/src/pi-auth.js +290 -0
  11. package/dist/apps/daemon/src/pi-resources.js +182 -0
  12. package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
  13. package/dist/apps/daemon/src/pi-sessions.js +265 -0
  14. package/dist/apps/daemon/src/runtime-process.js +241 -0
  15. package/dist/apps/daemon/src/secret.js +71 -0
  16. package/dist/apps/daemon/src/server.js +1662 -0
  17. package/dist/apps/daemon/src/session-projection.js +117 -0
  18. package/dist/apps/daemon/src/state-lock.js +31 -0
  19. package/dist/apps/daemon/src/static-web.js +53 -0
  20. package/dist/apps/daemon/src/task-archive.js +152 -0
  21. package/dist/apps/daemon/src/task-commit.js +503 -0
  22. package/dist/apps/daemon/src/task-merge.js +912 -0
  23. package/dist/apps/daemon/src/task-review.js +204 -0
  24. package/dist/apps/daemon/src/task-runtime.js +1124 -0
  25. package/dist/apps/daemon/src/task-validation.js +352 -0
  26. package/dist/apps/daemon/src/workspace-store.js +140 -0
  27. package/dist/apps/daemon/src/workspace.js +795 -0
  28. package/dist/extensions/webdesk.js +34 -0
  29. package/dist/packages/git/src/commit.js +675 -0
  30. package/dist/packages/git/src/errors.js +55 -0
  31. package/dist/packages/git/src/fingerprint.js +286 -0
  32. package/dist/packages/git/src/index.js +123 -0
  33. package/dist/packages/git/src/merge.js +1008 -0
  34. package/dist/packages/git/src/paths.js +58 -0
  35. package/dist/packages/git/src/repository.js +77 -0
  36. package/dist/packages/git/src/review.js +396 -0
  37. package/dist/packages/git/src/runner.js +110 -0
  38. package/dist/packages/git/src/validation.js +263 -0
  39. package/dist/packages/git/src/worktree.js +233 -0
  40. package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
  41. package/dist/packages/pi-bridge/src/auth.js +80 -0
  42. package/dist/packages/pi-bridge/src/errors.js +19 -0
  43. package/dist/packages/pi-bridge/src/handshake.js +43 -0
  44. package/dist/packages/pi-bridge/src/index.js +76 -0
  45. package/dist/packages/pi-bridge/src/jsonl.js +105 -0
  46. package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
  47. package/dist/packages/pi-bridge/src/resolve.js +59 -0
  48. package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
  49. package/dist/packages/pi-bridge/src/resources.js +481 -0
  50. package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
  51. package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
  52. package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
  53. package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
  54. package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
  55. package/dist/packages/pi-bridge/src/runtime.js +0 -0
  56. package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
  57. package/dist/packages/pi-bridge/src/sessions.js +314 -0
  58. package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
  59. package/dist/packages/protocol/src/index.js +1863 -0
  60. package/dist/web/assets/index-BOw_fhvO.css +2 -0
  61. package/dist/web/assets/index-oXs7yAAo.js +119 -0
  62. package/dist/web/index.html +14 -0
  63. package/package.json +69 -0
  64. package/scripts/prepare.mjs +7 -0
@@ -0,0 +1,1662 @@
1
+ // apps/daemon/src/server.ts
2
+ import { realpathSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import path from "node:path";
5
+ import { WebSocket, WebSocketServer } from "ws";
6
+ import { z } from "zod";
7
+ import {
8
+ PROTOCOL_VERSION,
9
+ continuePiSessionRequestSchema,
10
+ continuePiSessionResultSchema,
11
+ createTaskRequestSchema,
12
+ executeTaskCommitRequestSchema,
13
+ executeTaskMergeRequestSchema,
14
+ piAuthErrorSchema,
15
+ piAuthOperationSchema,
16
+ piAuthSnapshotSchema,
17
+ piConfigurationErrorSchema,
18
+ piConfigurationSnapshotSchema,
19
+ piSessionCatalogErrorSchema,
20
+ piSessionCatalogSnapshotSchema,
21
+ pitaPreferencesErrorSchema,
22
+ pitaPreferencesSnapshotSchema,
23
+ promptTaskRequestSchema,
24
+ registerRepositoryRequestSchema,
25
+ repositoryMergeSyncResultSchema,
26
+ repositoryMergeSyncRequestSchema,
27
+ respondPiAuthRequestSchema,
28
+ resolveTaskApprovalRequestSchema,
29
+ runTaskValidationRequestSchema,
30
+ safeParseClientMessage,
31
+ selectTaskModelRequestSchema,
32
+ serverControlMessageSchema,
33
+ serviceSessionSchema,
34
+ startPiAuthRequestSchema,
35
+ steerTaskRequestSchema,
36
+ taskArchiveRequestSchema,
37
+ taskCommitErrorSchema,
38
+ taskCommitPreflightRequestSchema,
39
+ taskCommitPreflightSchema,
40
+ taskCommitResultSchema,
41
+ taskMergeErrorSchema,
42
+ taskMergePreflightRequestSchema,
43
+ taskMergePreflightSchema,
44
+ taskMergeResultSchema,
45
+ taskReviewErrorSchema,
46
+ taskReviewSchema,
47
+ taskRuntimeErrorSchema,
48
+ taskRuntimeStatusSchema,
49
+ taskSessionTreeSchema,
50
+ taskValidationErrorSchema,
51
+ taskValidationSchema,
52
+ updatePitaPreferencesRequestSchema,
53
+ workspaceErrorSchema
54
+ } from "../../../packages/protocol/src/index.js";
55
+ import { createPiAuthManager } from "../../../packages/pi-bridge/src/index.js";
56
+ import {
57
+ SESSION_COOKIE_NAME,
58
+ createSessionToken,
59
+ generateBootstrapToken,
60
+ readCookie,
61
+ tokensMatch,
62
+ verifySessionToken
63
+ } from "./auth.js";
64
+ import { loadOrCreateSessionSecret } from "./secret.js";
65
+ import {
66
+ PitaPreferencesOperationError,
67
+ createPitaPreferencesService
68
+ } from "./appearance-preferences.js";
69
+ import {
70
+ PiAuthOperationError,
71
+ createPiAuthCoordinator
72
+ } from "./pi-auth.js";
73
+ import {
74
+ PiConfigurationOperationError,
75
+ createPiConfigurationService
76
+ } from "./pi-resources.js";
77
+ import {
78
+ PiSessionCatalogOperationError,
79
+ createPiSessionCatalogService
80
+ } from "./pi-sessions.js";
81
+ import { createTaskEventHub } from "./event-hub.js";
82
+ import { createPiTaskRuntimeFactory } from "./pi-runtime-factory.js";
83
+ import { acquireDaemonStateLock } from "./state-lock.js";
84
+ import {
85
+ RuntimeProcessRecoveryError,
86
+ captureRuntimeProcess,
87
+ recoverRuntimeProcess
88
+ } from "./runtime-process.js";
89
+ import {
90
+ TaskReviewOperationError,
91
+ createTaskReviewService
92
+ } from "./task-review.js";
93
+ import {
94
+ TaskValidationOperationError,
95
+ createTaskValidationService
96
+ } from "./task-validation.js";
97
+ import {
98
+ TaskCommitOperationError,
99
+ createTaskCommitService
100
+ } from "./task-commit.js";
101
+ import {
102
+ TaskMergeOperationError,
103
+ createTaskMergeService
104
+ } from "./task-merge.js";
105
+ import { createTaskArchiveService } from "./task-archive.js";
106
+ import { createStaticWebHandler } from "./static-web.js";
107
+ import {
108
+ TaskRuntimeOperationError,
109
+ createTaskRuntimeCoordinator
110
+ } from "./task-runtime.js";
111
+ import {
112
+ WorkspaceOperationError,
113
+ createWorkspaceService
114
+ } from "./workspace.js";
115
+ var LOOPBACK_ADDRESS = "127.0.0.1";
116
+ var MAX_BODY_BYTES = 4096;
117
+ var MAX_PROMPT_BODY_BYTES = 128 * 1024;
118
+ var DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1e3;
119
+ var DEFAULT_BOOTSTRAP_TTL_MS = 15 * 60 * 1e3;
120
+ var LOOPBACK_ORIGIN_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost"]);
121
+ var DaemonConfigError = class extends Error {
122
+ name = "DaemonConfigError";
123
+ };
124
+ var bootstrapBodySchema = z.object({
125
+ token: z.string().min(1).max(256)
126
+ });
127
+ function createDaemonServer(options) {
128
+ const serveWeb = options.webDirectory === void 0 ? void 0 : createStaticWebHandler(options.webDirectory);
129
+ const port = options.port ?? 0;
130
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
131
+ throw new DaemonConfigError("Daemon port must be an integer from 0 through 65535");
132
+ }
133
+ const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
134
+ if (!Number.isSafeInteger(sessionTtlMs) || sessionTtlMs < 1e3) {
135
+ throw new DaemonConfigError("Session TTL must be a safe integer of at least 1000ms");
136
+ }
137
+ const bootstrapTtlMs = options.bootstrapTtlMs ?? DEFAULT_BOOTSTRAP_TTL_MS;
138
+ if (!Number.isSafeInteger(bootstrapTtlMs) || bootstrapTtlMs < 1e3) {
139
+ throw new DaemonConfigError("Bootstrap TTL must be a safe integer of at least 1000ms");
140
+ }
141
+ let originUrl;
142
+ try {
143
+ originUrl = new URL(options.allowedOrigin);
144
+ } catch {
145
+ throw new DaemonConfigError("Allowed browser origin must be a valid loopback HTTP origin");
146
+ }
147
+ if (originUrl.protocol !== "http:" || originUrl.origin !== options.allowedOrigin || !LOOPBACK_ORIGIN_HOSTS.has(originUrl.hostname)) {
148
+ throw new DaemonConfigError(
149
+ "Allowed browser origin must be an exact http://localhost or http://127.0.0.1 origin"
150
+ );
151
+ }
152
+ const configuredStateDir = path.resolve(options.stateDir);
153
+ const secret = loadOrCreateSessionSecret(configuredStateDir);
154
+ const canonicalStateDir = realpathSync(configuredStateDir);
155
+ const workspace = options.workspaceService ?? createWorkspaceService({ stateDir: configuredStateDir });
156
+ const piAuth = options.piAuthCoordinator ?? createPiAuthCoordinator({ auth: createPiAuthManager() });
157
+ const piConfiguration = options.piConfigurationService ?? createPiConfigurationService({
158
+ getTask: async (taskId) => {
159
+ const snapshot = await workspace.snapshot();
160
+ return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
161
+ },
162
+ verifyTaskWorktree: (taskId) => workspace.verifyTaskWorktree(taskId)
163
+ });
164
+ const piSessions = options.piSessionCatalogService ?? createPiSessionCatalogService({ workspace });
165
+ const pitaPreferences = options.pitaPreferencesService ?? createPitaPreferencesService({ stateDir: configuredStateDir });
166
+ let taskArchive;
167
+ const taskReview = options.taskReviewService ?? createTaskReviewService({
168
+ getTask: async (taskId) => {
169
+ const snapshot = await workspace.snapshot();
170
+ return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
171
+ },
172
+ getRepository: async (repositoryId) => {
173
+ const snapshot = await workspace.snapshot();
174
+ return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
175
+ }
176
+ });
177
+ const taskValidation = options.taskValidationService ?? createTaskValidationService({
178
+ getTask: async (taskId) => {
179
+ const snapshot = await workspace.snapshot();
180
+ return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
181
+ },
182
+ getRepository: async (repositoryId) => {
183
+ const snapshot = await workspace.snapshot();
184
+ return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
185
+ },
186
+ recordValidation: (taskId, result) => workspace.recordTaskValidation(taskId, result),
187
+ // Late-bound reciprocal busy checks; taskCommit and taskMerge are
188
+ // declared below and only consulted once a run request arrives.
189
+ isCommitActive: (taskId) => taskCommit.isBusy(taskId),
190
+ isMergeActive: (taskId) => taskMerge.isBusy(taskId),
191
+ isArchiveActive: (taskId) => taskArchive.isBusy(taskId),
192
+ isAgentWorking: async (taskId) => (await runtimeCoordinator.status(taskId)).working
193
+ });
194
+ const taskCommit = options.taskCommitService ?? createTaskCommitService({
195
+ getTask: async (taskId) => {
196
+ const snapshot = await workspace.snapshot();
197
+ return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
198
+ },
199
+ getRepository: async (repositoryId) => {
200
+ const snapshot = await workspace.snapshot();
201
+ return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
202
+ },
203
+ isValidationRunning: (taskId) => taskValidation.isRunning(taskId),
204
+ isMergeActive: (taskId) => taskMerge.isBusy(taskId),
205
+ isArchiveActive: (taskId) => taskArchive.isBusy(taskId),
206
+ // The runtime coordinator is declared below; a task with no live
207
+ // runtime reports an idle, non-working status.
208
+ isAgentWorking: async (taskId) => (await runtimeCoordinator.status(taskId)).working
209
+ });
210
+ const taskMerge = options.taskMergeService ?? createTaskMergeService({
211
+ getTask: async (taskId) => {
212
+ const snapshot = await workspace.snapshot();
213
+ return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
214
+ },
215
+ getRepository: async (repositoryId) => {
216
+ const snapshot = await workspace.snapshot();
217
+ return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
218
+ },
219
+ isValidationRunning: (taskId) => taskValidation.isRunning(taskId),
220
+ isCommitActive: (taskId) => taskCommit.isBusy(taskId),
221
+ isArchiveActive: (taskId) => taskArchive.isBusy(taskId),
222
+ isAgentWorking: async (taskId) => (await runtimeCoordinator.status(taskId)).working,
223
+ recordPendingMerge: (repositoryId, record) => workspace.recordPendingMerge(repositoryId, record),
224
+ clearPendingMerge: (repositoryId, preflightId, resolvedHead) => workspace.clearPendingMerge(repositoryId, preflightId, resolvedHead)
225
+ });
226
+ const runtimeRecoveryIssues = /* @__PURE__ */ new Map();
227
+ async function reconcileReadyTaskRuntimeProcess(task) {
228
+ const owner = task.runtimeProcess;
229
+ if (owner === void 0) {
230
+ runtimeRecoveryIssues.delete(task.id);
231
+ return;
232
+ }
233
+ try {
234
+ await recoverRuntimeProcess(owner, {
235
+ ...options.runtimeProcessInspector === void 0 ? {} : { inspector: options.runtimeProcessInspector },
236
+ ...options.runtimeProcessRecoveryGraceMs === void 0 ? {} : { graceMs: options.runtimeProcessRecoveryGraceMs }
237
+ });
238
+ } catch (error) {
239
+ const recoveryError = error instanceof RuntimeProcessRecoveryError ? error : new RuntimeProcessRecoveryError(
240
+ `Runtime process ${owner.pid} could not be inspected safely`,
241
+ { cause: error }
242
+ );
243
+ runtimeRecoveryIssues.set(task.id, recoveryError.message);
244
+ throw recoveryError;
245
+ }
246
+ await workspace.clearTaskRuntimeProcess(task.id, owner.runtimeGeneration, owner.pid);
247
+ runtimeRecoveryIssues.delete(task.id);
248
+ }
249
+ async function reconcileTaskRuntimeProcess(taskId) {
250
+ const current = (await workspace.snapshot()).state.tasks.find((task) => task.id === taskId);
251
+ if (current?.status === "ready") {
252
+ await reconcileReadyTaskRuntimeProcess(current);
253
+ }
254
+ }
255
+ const allowedOrigin = originUrl.origin;
256
+ const now = options.now ?? Date.now;
257
+ let bootstrapToken = generateBootstrapToken();
258
+ let pendingBootstrapToken = {
259
+ token: bootstrapToken,
260
+ expiresAt: now() + bootstrapTtlMs
261
+ };
262
+ const configuredPort = port > 0 ? port : null;
263
+ let boundPort = null;
264
+ let releaseStateLock = null;
265
+ let listenOperation = null;
266
+ let closeOperation = null;
267
+ let closed = false;
268
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 4096 });
269
+ const sockets = /* @__PURE__ */ new Map();
270
+ const activeRequests = /* @__PURE__ */ new Set();
271
+ const eventHub = createTaskEventHub({
272
+ snapshot: (taskId) => runtimeCoordinator.snapshot(taskId)
273
+ });
274
+ function broadcastEvent(event) {
275
+ eventHub.publish(event);
276
+ }
277
+ const runtimeCoordinator = options.createRuntimeCoordinator !== void 0 ? options.createRuntimeCoordinator(broadcastEvent) : createTaskRuntimeCoordinator({
278
+ getTask: async (taskId) => {
279
+ const snapshot = await workspace.snapshot();
280
+ return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
281
+ },
282
+ recordTaskSession: (taskId, sessionFile) => workspace.recordTaskSession(taskId, sessionFile),
283
+ claimRuntimeGeneration: (taskId) => workspace.claimTaskRuntimeGeneration(taskId),
284
+ reconcileRuntimeProcess: reconcileTaskRuntimeProcess,
285
+ recordRuntimeProcess: async (taskId, generation, pid) => {
286
+ const identity = await captureRuntimeProcess(pid, options.runtimeProcessInspector);
287
+ await workspace.recordTaskRuntimeProcess(taskId, {
288
+ runtimeGeneration: generation,
289
+ ...identity,
290
+ recordedAtMs: now()
291
+ });
292
+ },
293
+ clearRuntimeProcess: (taskId, generation, pid) => workspace.clearTaskRuntimeProcess(taskId, generation, pid),
294
+ createRuntime: createPiTaskRuntimeFactory({
295
+ ...options.runtimeSupervisorIgnoreEofForTests === true ? { supervisorIgnoreEofForTests: true } : {}
296
+ }),
297
+ validateWorktree: (task) => workspace.verifyTaskWorktree(task.id),
298
+ isProviderAuthenticated: (providerId) => piAuth.isAuthenticated(providerId),
299
+ isTaskLifecycleBusy: (taskId) => taskValidation.isRunning(taskId) || taskCommit.isBusy(taskId) || taskMerge.isBusy(taskId) || taskArchive.isBusy(taskId),
300
+ isTaskArchiving: (taskId) => taskArchive.isBusy(taskId),
301
+ publish: broadcastEvent
302
+ });
303
+ taskArchive = options.taskArchiveService ?? createTaskArchiveService({
304
+ getTask: async (taskId) => {
305
+ const snapshot = await workspace.snapshot();
306
+ return snapshot.state.tasks.find((task) => task.id === taskId) ?? null;
307
+ },
308
+ getRepository: async (repositoryId) => {
309
+ const snapshot = await workspace.snapshot();
310
+ return snapshot.state.repositories.find((repository) => repository.id === repositoryId) ?? null;
311
+ },
312
+ archiveTask: (taskId) => workspace.archiveTask(taskId),
313
+ restoreTask: (taskId, archivedAtMs) => workspace.restoreTask(taskId, archivedAtMs),
314
+ disposeRuntime: (taskId) => runtimeCoordinator.stopForArchive(taskId),
315
+ isRuntimeMutating: (taskId) => runtimeCoordinator.isMutating(taskId),
316
+ isValidationRunning: (taskId) => taskValidation.isRunning(taskId),
317
+ isCommitActive: (taskId) => taskCommit.isBusy(taskId),
318
+ isMergeActive: (taskId) => taskMerge.isBusy(taskId),
319
+ isMergeRepositoryActive: (repositoryId) => taskMerge.isRepositoryBusy(repositoryId),
320
+ invalidateCommitPreflight: (taskId) => taskCommit.invalidate(taskId),
321
+ invalidateMergePreflight: (taskId) => taskMerge.invalidate(taskId)
322
+ });
323
+ function rotateBootstrapToken() {
324
+ bootstrapToken = generateBootstrapToken();
325
+ pendingBootstrapToken = {
326
+ token: bootstrapToken,
327
+ expiresAt: now() + bootstrapTtlMs
328
+ };
329
+ return bootstrapToken;
330
+ }
331
+ function hostAllowed(req) {
332
+ const expectedPort = boundPort ?? configuredPort;
333
+ if (expectedPort === null) {
334
+ return false;
335
+ }
336
+ const host = req.headers.host;
337
+ return host === `${LOOPBACK_ADDRESS}:${expectedPort}` || host === `localhost:${expectedPort}`;
338
+ }
339
+ function sessionFromRequest(req) {
340
+ const cookie = readCookie(req.headers.cookie, SESSION_COOKIE_NAME);
341
+ if (cookie === null) {
342
+ return null;
343
+ }
344
+ return verifySessionToken(secret, cookie, now());
345
+ }
346
+ function sendJson(res, status, body) {
347
+ const payload = JSON.stringify(body);
348
+ res.writeHead(status, {
349
+ "content-type": "application/json; charset=utf-8",
350
+ "content-length": Buffer.byteLength(payload),
351
+ "cache-control": "no-store",
352
+ "x-content-type-options": "nosniff"
353
+ });
354
+ res.end(payload);
355
+ }
356
+ function sendError(res, status, error) {
357
+ sendJson(res, status, { error });
358
+ }
359
+ function sendWorkspaceError(res, status, code, message) {
360
+ sendJson(
361
+ res,
362
+ status,
363
+ workspaceErrorSchema.parse({
364
+ protocol: PROTOCOL_VERSION,
365
+ type: "workspace-error",
366
+ code,
367
+ message
368
+ })
369
+ );
370
+ }
371
+ function sendTaskRuntimeError(res, status, code, message) {
372
+ sendJson(
373
+ res,
374
+ status,
375
+ taskRuntimeErrorSchema.parse({
376
+ protocol: PROTOCOL_VERSION,
377
+ type: "task-runtime-error",
378
+ code,
379
+ message
380
+ })
381
+ );
382
+ }
383
+ function sendTaskReviewError(res, status, code, message) {
384
+ sendJson(
385
+ res,
386
+ status,
387
+ taskReviewErrorSchema.parse({
388
+ protocol: PROTOCOL_VERSION,
389
+ type: "task-review-error",
390
+ code,
391
+ message
392
+ })
393
+ );
394
+ }
395
+ function sendTaskValidationError(res, status, code, message) {
396
+ sendJson(
397
+ res,
398
+ status,
399
+ taskValidationErrorSchema.parse({
400
+ protocol: PROTOCOL_VERSION,
401
+ type: "task-validation-error",
402
+ code,
403
+ message
404
+ })
405
+ );
406
+ }
407
+ function sendTaskCommitError(res, status, code, message) {
408
+ sendJson(
409
+ res,
410
+ status,
411
+ taskCommitErrorSchema.parse({
412
+ protocol: PROTOCOL_VERSION,
413
+ type: "task-commit-error",
414
+ code,
415
+ message
416
+ })
417
+ );
418
+ }
419
+ function sendTaskMergeError(res, status, code, message, conflict) {
420
+ sendJson(
421
+ res,
422
+ status,
423
+ taskMergeErrorSchema.parse({
424
+ protocol: PROTOCOL_VERSION,
425
+ type: "task-merge-error",
426
+ code,
427
+ message,
428
+ ...conflict?.conflictPaths === void 0 ? {} : {
429
+ conflictPaths: conflict.conflictPaths,
430
+ omittedConflictPaths: conflict.omittedConflictPaths ?? 0
431
+ }
432
+ })
433
+ );
434
+ }
435
+ function handleTaskMergeFailure(res, error) {
436
+ if (error instanceof TaskMergeOperationError) {
437
+ sendTaskMergeError(res, error.status, error.code, error.message, {
438
+ conflictPaths: error.conflictPaths,
439
+ omittedConflictPaths: error.omittedConflictPaths
440
+ });
441
+ return;
442
+ }
443
+ if (error instanceof WorkspaceOperationError) {
444
+ sendTaskMergeError(res, error.status, "invalid-request", error.message);
445
+ return;
446
+ }
447
+ sendTaskMergeError(
448
+ res,
449
+ 503,
450
+ "merge-failed",
451
+ "The task merge service is unavailable. Check the daemon output for details."
452
+ );
453
+ }
454
+ function sendPiAuthError(res, status, code, message) {
455
+ sendJson(
456
+ res,
457
+ status,
458
+ piAuthErrorSchema.parse({
459
+ protocol: PROTOCOL_VERSION,
460
+ type: "pi-auth-error",
461
+ code,
462
+ message
463
+ })
464
+ );
465
+ }
466
+ function sendPiConfigurationError(res, status, code, message) {
467
+ sendJson(
468
+ res,
469
+ status,
470
+ piConfigurationErrorSchema.parse({
471
+ protocol: PROTOCOL_VERSION,
472
+ type: "pi-configuration-error",
473
+ code,
474
+ message
475
+ })
476
+ );
477
+ }
478
+ function sendPiSessionCatalogError(res, status, code, message) {
479
+ sendJson(
480
+ res,
481
+ status,
482
+ piSessionCatalogErrorSchema.parse({
483
+ protocol: PROTOCOL_VERSION,
484
+ type: "pi-session-catalog-error",
485
+ code,
486
+ message
487
+ })
488
+ );
489
+ }
490
+ function sendPitaPreferencesError(res, status, code, message) {
491
+ sendJson(
492
+ res,
493
+ status,
494
+ pitaPreferencesErrorSchema.parse({
495
+ protocol: PROTOCOL_VERSION,
496
+ type: "pita-preferences-error",
497
+ code,
498
+ message
499
+ })
500
+ );
501
+ }
502
+ async function handlePiAuthOperation(res, operation, successStatus = 200) {
503
+ try {
504
+ sendJson(res, successStatus, await operation());
505
+ } catch (error) {
506
+ if (error instanceof PiAuthOperationError) {
507
+ sendPiAuthError(res, error.status, error.code, error.message);
508
+ return;
509
+ }
510
+ if (error instanceof WorkspaceOperationError) {
511
+ sendPiAuthError(res, error.status, "invalid-request", error.message);
512
+ return;
513
+ }
514
+ sendPiAuthError(
515
+ res,
516
+ 503,
517
+ "auth-unavailable",
518
+ "Pi authentication is unavailable. Check the daemon output for details."
519
+ );
520
+ }
521
+ }
522
+ function readBoundedBody(req, maxBytes = MAX_BODY_BYTES) {
523
+ return new Promise((resolve, reject) => {
524
+ const chunks = [];
525
+ let size = 0;
526
+ let tooLarge = false;
527
+ let settled = false;
528
+ const settle = (body) => {
529
+ if (settled) return;
530
+ settled = true;
531
+ resolve(body);
532
+ };
533
+ req.on("data", (chunk) => {
534
+ if (tooLarge) return;
535
+ size += chunk.length;
536
+ if (size > maxBytes) {
537
+ tooLarge = true;
538
+ chunks.length = 0;
539
+ settle(null);
540
+ return;
541
+ }
542
+ chunks.push(chunk);
543
+ });
544
+ req.on("end", () => settle(tooLarge ? null : Buffer.concat(chunks)));
545
+ req.on("error", (error) => {
546
+ if (!settled) reject(error);
547
+ });
548
+ });
549
+ }
550
+ async function handleBootstrap(req, res) {
551
+ const body = await readBoundedBody(req);
552
+ if (body === null) {
553
+ sendError(res, 413, "body-too-large");
554
+ return;
555
+ }
556
+ let parsed;
557
+ try {
558
+ parsed = bootstrapBodySchema.parse(JSON.parse(body.toString("utf8")));
559
+ } catch {
560
+ sendError(res, 400, "invalid-body");
561
+ return;
562
+ }
563
+ if (pendingBootstrapToken === null || pendingBootstrapToken.expiresAt <= now() || !tokensMatch(pendingBootstrapToken.token, parsed.token)) {
564
+ sendError(res, 403, "invalid-bootstrap-token");
565
+ return;
566
+ }
567
+ pendingBootstrapToken = null;
568
+ const session = createSessionToken(secret, {
569
+ nowMs: now(),
570
+ ttlMs: sessionTtlMs
571
+ });
572
+ setSessionCookie(res, session);
573
+ res.writeHead(204);
574
+ res.end();
575
+ }
576
+ async function readJsonBody(req, maxBytes = MAX_BODY_BYTES) {
577
+ const body = await readBoundedBody(req, maxBytes);
578
+ if (body === null) {
579
+ throw new WorkspaceOperationError("invalid-request", "The request body is too large.", 413);
580
+ }
581
+ try {
582
+ return JSON.parse(body.toString("utf8"));
583
+ } catch {
584
+ throw new WorkspaceOperationError(
585
+ "invalid-request",
586
+ "The request body must be valid JSON.",
587
+ 400
588
+ );
589
+ }
590
+ }
591
+ async function handleWorkspaceOperation(res, operation, successStatus = 200) {
592
+ try {
593
+ sendJson(res, successStatus, await operation());
594
+ } catch (error) {
595
+ if (error instanceof WorkspaceOperationError) {
596
+ sendWorkspaceError(res, error.status, error.code, error.message);
597
+ return;
598
+ }
599
+ sendWorkspaceError(
600
+ res,
601
+ 503,
602
+ "workspace-unavailable",
603
+ "The local workspace is unavailable. Check the daemon output for details."
604
+ );
605
+ }
606
+ }
607
+ function setSessionCookie(res, session) {
608
+ res.setHeader(
609
+ "set-cookie",
610
+ `${SESSION_COOKIE_NAME}=${session.token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(sessionTtlMs / 1e3)}`
611
+ );
612
+ }
613
+ function handleSession(res) {
614
+ const session = createSessionToken(secret, {
615
+ nowMs: now(),
616
+ ttlMs: sessionTtlMs
617
+ });
618
+ setSessionCookie(res, session);
619
+ sendJson(
620
+ res,
621
+ 200,
622
+ serviceSessionSchema.parse({
623
+ protocol: PROTOCOL_VERSION,
624
+ type: "service-session",
625
+ authenticated: true,
626
+ sessionExpiresAt: session.expiresAt
627
+ })
628
+ );
629
+ }
630
+ async function handleRequest(req, res) {
631
+ res.setHeader("vary", "origin");
632
+ if (!hostAllowed(req)) {
633
+ sendError(res, 403, "forbidden-host");
634
+ return;
635
+ }
636
+ const origin = req.headers.origin;
637
+ if (origin !== void 0 && origin !== allowedOrigin) {
638
+ sendError(res, 403, "forbidden-origin");
639
+ return;
640
+ }
641
+ if (origin === allowedOrigin) {
642
+ res.setHeader("access-control-allow-origin", allowedOrigin);
643
+ res.setHeader("access-control-allow-credentials", "true");
644
+ }
645
+ if (serveWeb && await serveWeb(req, res)) return;
646
+ if (req.method === "OPTIONS") {
647
+ if (origin !== allowedOrigin) {
648
+ sendError(res, 403, "forbidden-origin");
649
+ return;
650
+ }
651
+ res.writeHead(204, {
652
+ "access-control-allow-methods": "GET, POST, OPTIONS",
653
+ "access-control-allow-headers": "content-type",
654
+ "access-control-max-age": "600"
655
+ });
656
+ res.end();
657
+ return;
658
+ }
659
+ const requestUrl = new URL(req.url ?? "/", `http://${LOOPBACK_ADDRESS}`);
660
+ const pathname = requestUrl.pathname;
661
+ if (pathname === "/api/auth/bootstrap" && req.method === "POST") {
662
+ if (origin !== allowedOrigin) {
663
+ sendError(res, 403, "origin-required");
664
+ return;
665
+ }
666
+ await handleBootstrap(req, res);
667
+ return;
668
+ }
669
+ if (pathname === "/api/session" && req.method === "GET") {
670
+ const claims = sessionFromRequest(req);
671
+ if (claims === null) {
672
+ sendError(res, 401, "unauthenticated");
673
+ return;
674
+ }
675
+ handleSession(res);
676
+ return;
677
+ }
678
+ if (pathname === "/api/workspace" && req.method === "GET") {
679
+ if (sessionFromRequest(req) === null) {
680
+ sendError(res, 401, "unauthenticated");
681
+ return;
682
+ }
683
+ await handleWorkspaceOperation(res, () => workspace.snapshot());
684
+ return;
685
+ }
686
+ if (pathname === "/api/preferences") {
687
+ if (sessionFromRequest(req) === null) {
688
+ sendError(res, 401, "unauthenticated");
689
+ return;
690
+ }
691
+ const rawRepositoryId = requestUrl.searchParams.get("repositoryId");
692
+ const repositoryId = rawRepositoryId === null || rawRepositoryId === "" ? null : z.uuid().safeParse(rawRepositoryId);
693
+ if (repositoryId !== null && !repositoryId.success) {
694
+ sendPitaPreferencesError(
695
+ res,
696
+ 400,
697
+ "invalid-request",
698
+ "Choose a known repository before loading appearance preferences."
699
+ );
700
+ return;
701
+ }
702
+ const selectedRepositoryId = repositoryId === null ? null : repositoryId.data;
703
+ try {
704
+ if (selectedRepositoryId !== null) {
705
+ const snapshot = await workspace.snapshot();
706
+ if (!snapshot.state.repositories.some((entry) => entry.id === selectedRepositoryId)) {
707
+ sendPitaPreferencesError(
708
+ res,
709
+ 404,
710
+ "repository-not-found",
711
+ "The selected repository is no longer registered."
712
+ );
713
+ return;
714
+ }
715
+ }
716
+ if (req.method === "GET") {
717
+ sendJson(
718
+ res,
719
+ 200,
720
+ pitaPreferencesSnapshotSchema.parse(
721
+ await pitaPreferences.snapshot(selectedRepositoryId)
722
+ )
723
+ );
724
+ return;
725
+ }
726
+ if (req.method === "POST") {
727
+ if (origin !== allowedOrigin) {
728
+ sendError(res, 403, "origin-required");
729
+ return;
730
+ }
731
+ const parsed = updatePitaPreferencesRequestSchema.safeParse(await readJsonBody(req));
732
+ if (!parsed.success || parsed.data.repositoryId !== selectedRepositoryId) {
733
+ sendPitaPreferencesError(
734
+ res,
735
+ 400,
736
+ "invalid-request",
737
+ "The appearance request does not match the selected repository."
738
+ );
739
+ return;
740
+ }
741
+ if (parsed.data.scope === "repository" && selectedRepositoryId === null) {
742
+ sendPitaPreferencesError(
743
+ res,
744
+ 400,
745
+ "invalid-request",
746
+ "Choose a repository before changing repository appearance."
747
+ );
748
+ return;
749
+ }
750
+ sendJson(
751
+ res,
752
+ 200,
753
+ pitaPreferencesSnapshotSchema.parse(await pitaPreferences.update(parsed.data))
754
+ );
755
+ return;
756
+ }
757
+ sendError(res, 404, "not-found");
758
+ } catch (error) {
759
+ if (error instanceof PitaPreferencesOperationError) {
760
+ sendPitaPreferencesError(res, error.status, error.code, error.message);
761
+ return;
762
+ }
763
+ sendPitaPreferencesError(
764
+ res,
765
+ 503,
766
+ "preferences-unavailable",
767
+ "Webdesk appearance preferences are unavailable. Check the daemon output for details."
768
+ );
769
+ }
770
+ return;
771
+ }
772
+ if (pathname === "/api/pi/auth" && req.method === "GET") {
773
+ if (sessionFromRequest(req) === null) {
774
+ sendError(res, 401, "unauthenticated");
775
+ return;
776
+ }
777
+ await handlePiAuthOperation(
778
+ res,
779
+ async () => piAuthSnapshotSchema.parse(await piAuth.snapshot())
780
+ );
781
+ return;
782
+ }
783
+ if (pathname === "/api/pi/sessions") {
784
+ if (req.method !== "GET") {
785
+ sendError(res, 404, "not-found");
786
+ return;
787
+ }
788
+ if (sessionFromRequest(req) === null) {
789
+ sendError(res, 401, "unauthenticated");
790
+ return;
791
+ }
792
+ try {
793
+ sendJson(res, 200, piSessionCatalogSnapshotSchema.parse(await piSessions.refresh()));
794
+ } catch (error) {
795
+ if (error instanceof PiSessionCatalogOperationError) {
796
+ sendPiSessionCatalogError(res, error.status, error.code, error.message);
797
+ return;
798
+ }
799
+ sendPiSessionCatalogError(
800
+ res,
801
+ 503,
802
+ "session-unavailable",
803
+ "Pi sessions could not be inspected. Check the daemon output for details."
804
+ );
805
+ }
806
+ return;
807
+ }
808
+ if (pathname === "/api/pi/sessions/continue") {
809
+ if (req.method !== "POST") {
810
+ sendError(res, 404, "not-found");
811
+ return;
812
+ }
813
+ if (origin !== allowedOrigin) {
814
+ sendError(res, 403, "origin-required");
815
+ return;
816
+ }
817
+ if (sessionFromRequest(req) === null) {
818
+ sendError(res, 401, "unauthenticated");
819
+ return;
820
+ }
821
+ try {
822
+ const parsed = continuePiSessionRequestSchema.safeParse(await readJsonBody(req));
823
+ if (!parsed.success) {
824
+ throw new PiSessionCatalogOperationError(
825
+ "invalid-request",
826
+ "Choose an existing Pi session, repository, task title, and base revision.",
827
+ 400
828
+ );
829
+ }
830
+ sendJson(
831
+ res,
832
+ 201,
833
+ continuePiSessionResultSchema.parse(
834
+ await piSessions.continueInNewTask(parsed.data)
835
+ )
836
+ );
837
+ } catch (error) {
838
+ if (error instanceof PiSessionCatalogOperationError) {
839
+ sendPiSessionCatalogError(res, error.status, error.code, error.message);
840
+ return;
841
+ }
842
+ if (error instanceof WorkspaceOperationError) {
843
+ sendPiSessionCatalogError(
844
+ res,
845
+ error.status,
846
+ "continuation-unavailable",
847
+ error.message
848
+ );
849
+ return;
850
+ }
851
+ sendPiSessionCatalogError(
852
+ res,
853
+ 503,
854
+ "continuation-unavailable",
855
+ "The isolated task could not be created from this Pi session. Check the daemon output for details."
856
+ );
857
+ }
858
+ return;
859
+ }
860
+ const piConfigurationRoute = /^\/api\/tasks\/([^/]+)\/pi-configuration$/.exec(pathname);
861
+ if (piConfigurationRoute !== null) {
862
+ if (req.method !== "GET") {
863
+ sendError(res, 404, "not-found");
864
+ return;
865
+ }
866
+ if (sessionFromRequest(req) === null) {
867
+ sendError(res, 401, "unauthenticated");
868
+ return;
869
+ }
870
+ const taskId = z.uuid().safeParse(piConfigurationRoute[1] ?? "");
871
+ if (!taskId.success) {
872
+ sendPiConfigurationError(res, 400, "invalid-request", "Choose a known task.");
873
+ return;
874
+ }
875
+ try {
876
+ sendJson(
877
+ res,
878
+ 200,
879
+ piConfigurationSnapshotSchema.parse(await piConfiguration.inspect(taskId.data))
880
+ );
881
+ } catch (error) {
882
+ if (error instanceof PiConfigurationOperationError) {
883
+ sendPiConfigurationError(res, error.status, error.code, error.message);
884
+ return;
885
+ }
886
+ sendPiConfigurationError(
887
+ res,
888
+ 503,
889
+ "inspection-failed",
890
+ "Pi configuration could not be inspected. Check the daemon output for details."
891
+ );
892
+ }
893
+ return;
894
+ }
895
+ if (pathname === "/api/pi/auth/login" && req.method === "POST") {
896
+ if (origin !== allowedOrigin) {
897
+ sendError(res, 403, "origin-required");
898
+ return;
899
+ }
900
+ if (sessionFromRequest(req) === null) {
901
+ sendError(res, 401, "unauthenticated");
902
+ return;
903
+ }
904
+ await handlePiAuthOperation(
905
+ res,
906
+ async () => {
907
+ const parsed = startPiAuthRequestSchema.safeParse(await readJsonBody(req));
908
+ if (!parsed.success) {
909
+ throw new PiAuthOperationError(
910
+ "invalid-request",
911
+ "Choose a Pi provider and sign-in method.",
912
+ 400
913
+ );
914
+ }
915
+ return piAuthOperationSchema.parse(
916
+ piAuth.startLogin(parsed.data.providerId, parsed.data.method)
917
+ );
918
+ },
919
+ 201
920
+ );
921
+ return;
922
+ }
923
+ const piAuthOperationRoute = /^\/api\/pi\/auth\/operations\/([^/]+)(?:\/(respond|cancel))?$/.exec(pathname);
924
+ if (piAuthOperationRoute !== null) {
925
+ if (sessionFromRequest(req) === null) {
926
+ sendError(res, 401, "unauthenticated");
927
+ return;
928
+ }
929
+ const operationId = z.uuid().safeParse(piAuthOperationRoute[1] ?? "");
930
+ if (!operationId.success) {
931
+ sendPiAuthError(res, 400, "invalid-request", "Choose a known Pi login operation.");
932
+ return;
933
+ }
934
+ const action = piAuthOperationRoute[2];
935
+ if (action === void 0 && req.method === "GET") {
936
+ await handlePiAuthOperation(
937
+ res,
938
+ () => piAuthOperationSchema.parse(piAuth.operation(operationId.data))
939
+ );
940
+ return;
941
+ }
942
+ if (req.method === "POST" && (action === "respond" || action === "cancel")) {
943
+ if (origin !== allowedOrigin) {
944
+ sendError(res, 403, "origin-required");
945
+ return;
946
+ }
947
+ await handlePiAuthOperation(res, async () => {
948
+ if (action === "cancel") {
949
+ return piAuthOperationSchema.parse(piAuth.cancel(operationId.data));
950
+ }
951
+ const parsed = respondPiAuthRequestSchema.safeParse(
952
+ await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
953
+ );
954
+ if (!parsed.success) {
955
+ throw new PiAuthOperationError(
956
+ "invalid-request",
957
+ "Provide a response for the active Pi login prompt.",
958
+ 400
959
+ );
960
+ }
961
+ return piAuthOperationSchema.parse(
962
+ piAuth.respond(operationId.data, parsed.data.promptId, parsed.data.value)
963
+ );
964
+ });
965
+ return;
966
+ }
967
+ }
968
+ if (pathname === "/api/repositories" && req.method === "POST") {
969
+ if (origin !== allowedOrigin) {
970
+ sendError(res, 403, "origin-required");
971
+ return;
972
+ }
973
+ if (sessionFromRequest(req) === null) {
974
+ sendError(res, 401, "unauthenticated");
975
+ return;
976
+ }
977
+ await handleWorkspaceOperation(res, async () => {
978
+ const parsed = registerRepositoryRequestSchema.safeParse(await readJsonBody(req));
979
+ if (!parsed.success) {
980
+ throw new WorkspaceOperationError(
981
+ "invalid-request",
982
+ "Choose an absolute local repository path.",
983
+ 400
984
+ );
985
+ }
986
+ return workspace.registerRepository(parsed.data);
987
+ });
988
+ return;
989
+ }
990
+ if (pathname === "/api/tasks" && req.method === "POST") {
991
+ if (origin !== allowedOrigin) {
992
+ sendError(res, 403, "origin-required");
993
+ return;
994
+ }
995
+ if (sessionFromRequest(req) === null) {
996
+ sendError(res, 401, "unauthenticated");
997
+ return;
998
+ }
999
+ await handleWorkspaceOperation(
1000
+ res,
1001
+ async () => {
1002
+ const parsed = createTaskRequestSchema.safeParse(await readJsonBody(req));
1003
+ if (!parsed.success) {
1004
+ throw new WorkspaceOperationError(
1005
+ "invalid-request",
1006
+ "Provide a repository, task title, and base revision.",
1007
+ 400
1008
+ );
1009
+ }
1010
+ return workspace.createTask(parsed.data);
1011
+ },
1012
+ 201
1013
+ );
1014
+ return;
1015
+ }
1016
+ const taskArchiveRoute = /^\/api\/tasks\/([^/]+)\/(archive|restore)$/.exec(pathname);
1017
+ if (taskArchiveRoute !== null) {
1018
+ if (req.method !== "POST") {
1019
+ sendError(res, 404, "not-found");
1020
+ return;
1021
+ }
1022
+ if (origin !== allowedOrigin) {
1023
+ sendError(res, 403, "origin-required");
1024
+ return;
1025
+ }
1026
+ if (sessionFromRequest(req) === null) {
1027
+ sendError(res, 401, "unauthenticated");
1028
+ return;
1029
+ }
1030
+ const taskId = z.uuid().safeParse(taskArchiveRoute[1] ?? "");
1031
+ if (!taskId.success) {
1032
+ await handleWorkspaceOperation(res, async () => {
1033
+ throw new WorkspaceOperationError("invalid-request", "Choose a known task.", 400);
1034
+ });
1035
+ return;
1036
+ }
1037
+ await handleWorkspaceOperation(res, async () => {
1038
+ const request = taskArchiveRequestSchema.safeParse(await readJsonBody(req));
1039
+ if (!request.success) {
1040
+ throw new WorkspaceOperationError(
1041
+ "invalid-request",
1042
+ "Archive and restore requests do not accept additional options.",
1043
+ 400
1044
+ );
1045
+ }
1046
+ return taskArchiveRoute[2] === "archive" ? taskArchive.archive(taskId.data) : taskArchive.restore(taskId.data);
1047
+ });
1048
+ return;
1049
+ }
1050
+ const taskCommitRoute = /^\/api\/tasks\/([^/]+)\/commit(?:\/(preflight))?$/.exec(pathname);
1051
+ if (taskCommitRoute !== null) {
1052
+ if (req.method !== "POST") {
1053
+ sendError(res, 404, "not-found");
1054
+ return;
1055
+ }
1056
+ if (origin !== allowedOrigin) {
1057
+ sendError(res, 403, "origin-required");
1058
+ return;
1059
+ }
1060
+ if (sessionFromRequest(req) === null) {
1061
+ sendError(res, 401, "unauthenticated");
1062
+ return;
1063
+ }
1064
+ const taskId = z.uuid().safeParse(taskCommitRoute[1] ?? "");
1065
+ if (!taskId.success) {
1066
+ sendTaskCommitError(res, 400, "invalid-request", "Choose a known task.");
1067
+ return;
1068
+ }
1069
+ try {
1070
+ if (taskCommitRoute[2] === "preflight") {
1071
+ const parsed2 = taskCommitPreflightRequestSchema.safeParse(
1072
+ await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
1073
+ );
1074
+ if (!parsed2.success) {
1075
+ throw new TaskCommitOperationError(
1076
+ "invalid-request",
1077
+ "Provide a non-empty commit message within Webdesk's length bound and without unsafe invisible characters.",
1078
+ 400
1079
+ );
1080
+ }
1081
+ sendJson(
1082
+ res,
1083
+ 200,
1084
+ taskCommitPreflightSchema.parse(
1085
+ await taskCommit.preflight(taskId.data, parsed2.data.message)
1086
+ )
1087
+ );
1088
+ return;
1089
+ }
1090
+ const parsed = executeTaskCommitRequestSchema.safeParse(await readJsonBody(req));
1091
+ if (!parsed.success) {
1092
+ throw new TaskCommitOperationError(
1093
+ "invalid-request",
1094
+ "Provide the current commit preflight and an explicit validation acknowledgement.",
1095
+ 400
1096
+ );
1097
+ }
1098
+ sendJson(
1099
+ res,
1100
+ 200,
1101
+ taskCommitResultSchema.parse(await taskCommit.execute(taskId.data, parsed.data))
1102
+ );
1103
+ } catch (error) {
1104
+ if (error instanceof TaskCommitOperationError) {
1105
+ sendTaskCommitError(res, error.status, error.code, error.message);
1106
+ return;
1107
+ }
1108
+ if (error instanceof WorkspaceOperationError) {
1109
+ sendTaskCommitError(res, error.status, "invalid-request", error.message);
1110
+ return;
1111
+ }
1112
+ sendTaskCommitError(
1113
+ res,
1114
+ 503,
1115
+ "commit-failed",
1116
+ "The task commit service is unavailable. Check the daemon output for details."
1117
+ );
1118
+ }
1119
+ return;
1120
+ }
1121
+ const taskMergeRoute = /^\/api\/tasks\/([^/]+)\/merge(?:\/(preflight))?$/.exec(pathname);
1122
+ if (taskMergeRoute !== null) {
1123
+ if (req.method !== "POST") {
1124
+ sendError(res, 404, "not-found");
1125
+ return;
1126
+ }
1127
+ if (origin !== allowedOrigin) {
1128
+ sendError(res, 403, "origin-required");
1129
+ return;
1130
+ }
1131
+ if (sessionFromRequest(req) === null) {
1132
+ sendError(res, 401, "unauthenticated");
1133
+ return;
1134
+ }
1135
+ const taskId = z.uuid().safeParse(taskMergeRoute[1] ?? "");
1136
+ if (!taskId.success) {
1137
+ sendTaskMergeError(res, 400, "invalid-request", "Choose a known task.");
1138
+ return;
1139
+ }
1140
+ try {
1141
+ if (taskMergeRoute[2] === "preflight") {
1142
+ const parsed2 = taskMergePreflightRequestSchema.safeParse(
1143
+ await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
1144
+ );
1145
+ if (!parsed2.success) {
1146
+ throw new TaskMergeOperationError(
1147
+ "invalid-request",
1148
+ "Provide an optional merge message within Webdesk's length bound and without unsafe invisible characters.",
1149
+ 400
1150
+ );
1151
+ }
1152
+ sendJson(
1153
+ res,
1154
+ 200,
1155
+ taskMergePreflightSchema.parse(await taskMerge.preflight(taskId.data, parsed2.data))
1156
+ );
1157
+ return;
1158
+ }
1159
+ const parsed = executeTaskMergeRequestSchema.safeParse(await readJsonBody(req));
1160
+ if (!parsed.success) {
1161
+ throw new TaskMergeOperationError(
1162
+ "invalid-request",
1163
+ "Provide the current merge preflight and an explicit validation acknowledgement.",
1164
+ 400
1165
+ );
1166
+ }
1167
+ sendJson(
1168
+ res,
1169
+ 200,
1170
+ taskMergeResultSchema.parse(await taskMerge.execute(taskId.data, parsed.data))
1171
+ );
1172
+ } catch (error) {
1173
+ handleTaskMergeFailure(res, error);
1174
+ }
1175
+ return;
1176
+ }
1177
+ const repositoryMergeSyncRoute = /^\/api\/repositories\/([^/]+)\/merge-sync$/.exec(pathname);
1178
+ if (repositoryMergeSyncRoute !== null) {
1179
+ if (req.method !== "POST") {
1180
+ sendError(res, 404, "not-found");
1181
+ return;
1182
+ }
1183
+ if (origin !== allowedOrigin) {
1184
+ sendError(res, 403, "origin-required");
1185
+ return;
1186
+ }
1187
+ if (sessionFromRequest(req) === null) {
1188
+ sendError(res, 401, "unauthenticated");
1189
+ return;
1190
+ }
1191
+ const repositoryId = z.uuid().safeParse(repositoryMergeSyncRoute[1] ?? "");
1192
+ if (!repositoryId.success) {
1193
+ sendTaskMergeError(res, 400, "invalid-request", "Choose a known repository.");
1194
+ return;
1195
+ }
1196
+ try {
1197
+ const request = repositoryMergeSyncRequestSchema.safeParse(await readJsonBody(req));
1198
+ if (!request.success) {
1199
+ throw new TaskMergeOperationError(
1200
+ "invalid-request",
1201
+ "Choose whether an inspected externally moved target should dismiss merge recovery.",
1202
+ 400
1203
+ );
1204
+ }
1205
+ sendJson(
1206
+ res,
1207
+ 200,
1208
+ repositoryMergeSyncResultSchema.parse(
1209
+ await taskMerge.syncRepository(repositoryId.data, request.data)
1210
+ )
1211
+ );
1212
+ } catch (error) {
1213
+ handleTaskMergeFailure(res, error);
1214
+ }
1215
+ return;
1216
+ }
1217
+ const taskRuntimeRoute = /^\/api\/tasks\/([^/]+)\/(runtime|restart|model|prompt|steer|abort|approval|session-tree|review|validation)$/.exec(
1218
+ pathname
1219
+ );
1220
+ if (taskRuntimeRoute !== null && taskRuntimeRoute[2] === "review" && req.method === "GET") {
1221
+ if (sessionFromRequest(req) === null) {
1222
+ sendError(res, 401, "unauthenticated");
1223
+ return;
1224
+ }
1225
+ const taskId = z.uuid().safeParse(taskRuntimeRoute[1] ?? "");
1226
+ if (!taskId.success) {
1227
+ sendTaskReviewError(res, 400, "invalid-request", "Choose a known task.");
1228
+ return;
1229
+ }
1230
+ try {
1231
+ sendJson(res, 200, taskReviewSchema.parse(await taskReview.review(taskId.data)));
1232
+ } catch (error) {
1233
+ if (error instanceof TaskReviewOperationError) {
1234
+ sendTaskReviewError(res, error.status, error.code, error.message);
1235
+ return;
1236
+ }
1237
+ sendTaskReviewError(
1238
+ res,
1239
+ 503,
1240
+ "review-failed",
1241
+ "The task review service is unavailable. Check the daemon output for details."
1242
+ );
1243
+ }
1244
+ return;
1245
+ }
1246
+ if (taskRuntimeRoute !== null && taskRuntimeRoute[2] === "validation") {
1247
+ if (req.method !== "GET" && req.method !== "POST") {
1248
+ sendError(res, 404, "not-found");
1249
+ return;
1250
+ }
1251
+ if (req.method === "POST" && origin !== allowedOrigin) {
1252
+ sendError(res, 403, "origin-required");
1253
+ return;
1254
+ }
1255
+ if (sessionFromRequest(req) === null) {
1256
+ sendError(res, 401, "unauthenticated");
1257
+ return;
1258
+ }
1259
+ const taskId = z.uuid().safeParse(taskRuntimeRoute[1] ?? "");
1260
+ if (!taskId.success) {
1261
+ sendTaskValidationError(res, 400, "invalid-request", "Choose a known task.");
1262
+ return;
1263
+ }
1264
+ try {
1265
+ if (req.method === "GET") {
1266
+ sendJson(res, 200, taskValidationSchema.parse(await taskValidation.status(taskId.data)));
1267
+ return;
1268
+ }
1269
+ const parsed = runTaskValidationRequestSchema.safeParse(
1270
+ await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
1271
+ );
1272
+ if (!parsed.success) {
1273
+ throw new TaskValidationOperationError(
1274
+ "invalid-request",
1275
+ "Provide a non-empty validation command within Webdesk's length bound and without unsafe invisible characters.",
1276
+ 400
1277
+ );
1278
+ }
1279
+ sendJson(
1280
+ res,
1281
+ 200,
1282
+ taskValidationSchema.parse(await taskValidation.run(taskId.data, parsed.data.command))
1283
+ );
1284
+ } catch (error) {
1285
+ if (error instanceof TaskValidationOperationError) {
1286
+ sendTaskValidationError(res, error.status, error.code, error.message);
1287
+ return;
1288
+ }
1289
+ if (error instanceof WorkspaceOperationError) {
1290
+ sendTaskValidationError(res, error.status, "invalid-request", error.message);
1291
+ return;
1292
+ }
1293
+ sendTaskValidationError(
1294
+ res,
1295
+ 503,
1296
+ "validation-failed",
1297
+ "The task validation service is unavailable. Check the daemon output for details."
1298
+ );
1299
+ }
1300
+ return;
1301
+ }
1302
+ if (taskRuntimeRoute !== null && (taskRuntimeRoute[2] === "runtime" || taskRuntimeRoute[2] === "session-tree") && req.method === "GET") {
1303
+ if (sessionFromRequest(req) === null) {
1304
+ sendError(res, 401, "unauthenticated");
1305
+ return;
1306
+ }
1307
+ const taskId = z.uuid().safeParse(taskRuntimeRoute[1] ?? "");
1308
+ if (!taskId.success) {
1309
+ sendTaskRuntimeError(res, 400, "invalid-request", "Choose a known task.");
1310
+ return;
1311
+ }
1312
+ try {
1313
+ if (taskRuntimeRoute[2] === "session-tree") {
1314
+ sendJson(
1315
+ res,
1316
+ 200,
1317
+ taskSessionTreeSchema.parse(await runtimeCoordinator.sessionTree(taskId.data))
1318
+ );
1319
+ } else {
1320
+ const status = await runtimeCoordinator.status(taskId.data);
1321
+ const recoveryIssue = runtimeRecoveryIssues.get(taskId.data);
1322
+ sendJson(
1323
+ res,
1324
+ 200,
1325
+ taskRuntimeStatusSchema.parse(
1326
+ recoveryIssue === void 0 || status.state !== "idle" && status.state !== "exited" && status.state !== "failed" ? status : {
1327
+ ...status,
1328
+ detail: `Runtime recovery required: ${recoveryIssue} Stop any remaining task processes, then choose Start Pi to retry.`
1329
+ }
1330
+ )
1331
+ );
1332
+ }
1333
+ } catch (error) {
1334
+ if (error instanceof TaskRuntimeOperationError) {
1335
+ sendTaskRuntimeError(res, error.status, error.code, error.message);
1336
+ return;
1337
+ }
1338
+ sendTaskRuntimeError(
1339
+ res,
1340
+ 503,
1341
+ "runtime-unavailable",
1342
+ "The task runtime service is unavailable. Check the daemon output for details."
1343
+ );
1344
+ }
1345
+ return;
1346
+ }
1347
+ if (taskRuntimeRoute !== null && taskRuntimeRoute[2] !== "session-tree" && taskRuntimeRoute[2] !== "review" && req.method === "POST") {
1348
+ if (origin !== allowedOrigin) {
1349
+ sendError(res, 403, "origin-required");
1350
+ return;
1351
+ }
1352
+ if (sessionFromRequest(req) === null) {
1353
+ sendError(res, 401, "unauthenticated");
1354
+ return;
1355
+ }
1356
+ const taskId = z.uuid().safeParse(taskRuntimeRoute[1] ?? "");
1357
+ if (!taskId.success) {
1358
+ sendTaskRuntimeError(res, 400, "invalid-request", "Choose a known task.");
1359
+ return;
1360
+ }
1361
+ const action = taskRuntimeRoute[2];
1362
+ try {
1363
+ let status;
1364
+ if (action === "runtime") {
1365
+ status = await runtimeCoordinator.ensure(taskId.data);
1366
+ } else if (action === "restart") {
1367
+ status = await runtimeCoordinator.restart(taskId.data);
1368
+ } else if (action === "model") {
1369
+ const parsed = selectTaskModelRequestSchema.safeParse(await readJsonBody(req));
1370
+ if (!parsed.success) {
1371
+ throw new TaskRuntimeOperationError(
1372
+ "invalid-request",
1373
+ "Choose an available Pi model.",
1374
+ 400
1375
+ );
1376
+ }
1377
+ status = await runtimeCoordinator.setModel(
1378
+ taskId.data,
1379
+ parsed.data.provider,
1380
+ parsed.data.modelId
1381
+ );
1382
+ } else if (action === "prompt") {
1383
+ const parsed = promptTaskRequestSchema.safeParse(
1384
+ await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
1385
+ );
1386
+ if (!parsed.success) {
1387
+ throw new TaskRuntimeOperationError(
1388
+ "invalid-request",
1389
+ "Provide a non-empty prompt message.",
1390
+ 400
1391
+ );
1392
+ }
1393
+ if (taskValidation.isRunning(taskId.data) || taskCommit.isBusy(taskId.data) || taskMerge.isBusy(taskId.data)) {
1394
+ throw new TaskRuntimeOperationError(
1395
+ "runtime-busy",
1396
+ "A validation, commit, or merge operation is active for this task. Wait for it to finish before prompting Pi.",
1397
+ 409
1398
+ );
1399
+ }
1400
+ status = await runtimeCoordinator.prompt(taskId.data, parsed.data.message);
1401
+ } else if (action === "steer") {
1402
+ const parsed = steerTaskRequestSchema.safeParse(
1403
+ await readJsonBody(req, MAX_PROMPT_BODY_BYTES)
1404
+ );
1405
+ if (!parsed.success) {
1406
+ throw new TaskRuntimeOperationError(
1407
+ "invalid-request",
1408
+ "Provide a non-empty steering message.",
1409
+ 400
1410
+ );
1411
+ }
1412
+ status = await runtimeCoordinator.steer(taskId.data, parsed.data.message);
1413
+ } else if (action === "approval") {
1414
+ const parsed = resolveTaskApprovalRequestSchema.safeParse(await readJsonBody(req));
1415
+ if (!parsed.success) {
1416
+ throw new TaskRuntimeOperationError(
1417
+ "invalid-request",
1418
+ "Choose the current approval and an explicit decision.",
1419
+ 400
1420
+ );
1421
+ }
1422
+ status = await runtimeCoordinator.resolveApproval(
1423
+ taskId.data,
1424
+ parsed.data.approvalId,
1425
+ parsed.data.decision
1426
+ );
1427
+ } else {
1428
+ status = await runtimeCoordinator.abort(taskId.data);
1429
+ }
1430
+ sendJson(res, 200, taskRuntimeStatusSchema.parse(status));
1431
+ } catch (error) {
1432
+ if (error instanceof TaskRuntimeOperationError) {
1433
+ sendTaskRuntimeError(res, error.status, error.code, error.message);
1434
+ return;
1435
+ }
1436
+ if (error instanceof WorkspaceOperationError) {
1437
+ sendTaskRuntimeError(res, error.status, "invalid-request", error.message);
1438
+ return;
1439
+ }
1440
+ sendTaskRuntimeError(
1441
+ res,
1442
+ 503,
1443
+ "runtime-unavailable",
1444
+ "The task runtime service is unavailable. Check the daemon output for details."
1445
+ );
1446
+ }
1447
+ return;
1448
+ }
1449
+ sendError(res, 404, "not-found");
1450
+ }
1451
+ function rejectUpgrade(socket, status, reason) {
1452
+ socket.end(`HTTP/1.1 ${status} ${reason}\r
1453
+ Connection: close\r
1454
+ Content-Length: 0\r
1455
+ \r
1456
+ `);
1457
+ }
1458
+ function handleUpgrade(req, socket, head) {
1459
+ socket.on("error", () => socket.destroy());
1460
+ const pathname = new URL(req.url ?? "/", `http://${LOOPBACK_ADDRESS}`).pathname;
1461
+ if (pathname !== "/api/events") {
1462
+ rejectUpgrade(socket, 404, "Not Found");
1463
+ return;
1464
+ }
1465
+ if (!hostAllowed(req)) {
1466
+ rejectUpgrade(socket, 403, "Forbidden");
1467
+ return;
1468
+ }
1469
+ if (req.headers.origin !== allowedOrigin) {
1470
+ rejectUpgrade(socket, 403, "Forbidden");
1471
+ return;
1472
+ }
1473
+ const claims = sessionFromRequest(req);
1474
+ if (claims === null) {
1475
+ rejectUpgrade(socket, 401, "Unauthorized");
1476
+ return;
1477
+ }
1478
+ wss.handleUpgrade(req, socket, head, (ws) => {
1479
+ ws.on("error", () => ws.terminate());
1480
+ const expiryDelayMs = Math.max(0, claims.expiresAt - now());
1481
+ const expiryTimer = setTimeout(() => {
1482
+ if (sockets.has(ws)) {
1483
+ ws.close(4001, "session-expired");
1484
+ }
1485
+ }, expiryDelayMs);
1486
+ expiryTimer.unref?.();
1487
+ sockets.set(ws, expiryTimer);
1488
+ const channel = eventHub.connect((message) => {
1489
+ if (ws.readyState === WebSocket.OPEN) {
1490
+ ws.send(JSON.stringify(message));
1491
+ }
1492
+ });
1493
+ ws.on("message", (data, isBinary) => {
1494
+ let parsed;
1495
+ try {
1496
+ parsed = safeParseClientMessage(JSON.parse(isBinary ? "" : String(data)));
1497
+ } catch {
1498
+ ws.close(1008, "invalid-message");
1499
+ return;
1500
+ }
1501
+ if (!parsed.success) {
1502
+ ws.close(1008, "invalid-message");
1503
+ return;
1504
+ }
1505
+ channel.subscribe(parsed.data);
1506
+ });
1507
+ ws.on("close", () => {
1508
+ channel.close();
1509
+ const timer = sockets.get(ws);
1510
+ if (timer !== void 0) {
1511
+ clearTimeout(timer);
1512
+ }
1513
+ sockets.delete(ws);
1514
+ });
1515
+ ws.send(
1516
+ JSON.stringify(
1517
+ serverControlMessageSchema.parse({
1518
+ protocol: PROTOCOL_VERSION,
1519
+ type: "service-ready",
1520
+ timestamp: now(),
1521
+ sessionExpiresAt: claims.expiresAt
1522
+ })
1523
+ )
1524
+ );
1525
+ });
1526
+ }
1527
+ const httpServer = createServer((req, res) => {
1528
+ const operation = handleRequest(req, res).catch(() => {
1529
+ if (!res.headersSent) {
1530
+ sendError(res, 500, "internal-error");
1531
+ } else {
1532
+ res.end();
1533
+ }
1534
+ });
1535
+ activeRequests.add(operation);
1536
+ void operation.then(
1537
+ () => activeRequests.delete(operation),
1538
+ () => activeRequests.delete(operation)
1539
+ );
1540
+ });
1541
+ httpServer.on("upgrade", handleUpgrade);
1542
+ return {
1543
+ get bootstrapToken() {
1544
+ return bootstrapToken;
1545
+ },
1546
+ rotateBootstrapToken,
1547
+ listen() {
1548
+ if (closed) {
1549
+ return Promise.reject(new DaemonConfigError("Daemon server is closed"));
1550
+ }
1551
+ if (listenOperation !== null || releaseStateLock !== null || httpServer.listening) {
1552
+ return Promise.reject(
1553
+ new DaemonConfigError("Daemon server is already starting or listening")
1554
+ );
1555
+ }
1556
+ const operation = (async () => {
1557
+ let acquiredLock = null;
1558
+ try {
1559
+ acquiredLock = await acquireDaemonStateLock(canonicalStateDir);
1560
+ if (closed) throw new DaemonConfigError("Daemon server is closed");
1561
+ releaseStateLock = acquiredLock;
1562
+ await workspace.initialize();
1563
+ if (closed) throw new DaemonConfigError("Daemon server is closed");
1564
+ const startupSnapshot = await workspace.snapshot();
1565
+ for (const task of startupSnapshot.state.tasks) {
1566
+ if (task.status !== "ready" || task.runtimeProcess === void 0) continue;
1567
+ try {
1568
+ await reconcileReadyTaskRuntimeProcess(task);
1569
+ } catch (error) {
1570
+ if (error instanceof RuntimeProcessRecoveryError) continue;
1571
+ throw error;
1572
+ }
1573
+ }
1574
+ if (closed) throw new DaemonConfigError("Daemon server is closed");
1575
+ return await new Promise((resolve, reject) => {
1576
+ const onError = (error) => reject(error);
1577
+ httpServer.once("error", onError);
1578
+ httpServer.listen(port, LOOPBACK_ADDRESS, () => {
1579
+ httpServer.removeListener("error", onError);
1580
+ if (closed) {
1581
+ httpServer.close(() => reject(new DaemonConfigError("Daemon server is closed")));
1582
+ httpServer.closeAllConnections();
1583
+ return;
1584
+ }
1585
+ const address = httpServer.address();
1586
+ if (address === null || typeof address === "string") {
1587
+ reject(new Error("daemon server did not bind to a TCP port"));
1588
+ return;
1589
+ }
1590
+ boundPort = address.port;
1591
+ resolve({ address: address.address, port: address.port });
1592
+ });
1593
+ });
1594
+ } catch (error) {
1595
+ if (releaseStateLock === acquiredLock) {
1596
+ releaseStateLock = null;
1597
+ }
1598
+ await acquiredLock?.().catch(() => void 0);
1599
+ throw error;
1600
+ }
1601
+ })();
1602
+ listenOperation = operation;
1603
+ void operation.then(
1604
+ () => {
1605
+ if (listenOperation === operation) listenOperation = null;
1606
+ },
1607
+ () => {
1608
+ if (listenOperation === operation) listenOperation = null;
1609
+ }
1610
+ );
1611
+ return operation;
1612
+ },
1613
+ close() {
1614
+ if (closeOperation !== null) return closeOperation;
1615
+ closed = true;
1616
+ const pendingListen = listenOperation;
1617
+ closeOperation = (async () => {
1618
+ await pendingListen?.catch(() => void 0);
1619
+ for (const [ws, timer] of sockets) {
1620
+ clearTimeout(timer);
1621
+ ws.terminate();
1622
+ }
1623
+ sockets.clear();
1624
+ wss.close();
1625
+ const runtimeDisposal = runtimeCoordinator.disposeAll();
1626
+ const validationDisposal = taskValidation.disposeAll();
1627
+ piAuth.dispose();
1628
+ if (httpServer.listening) {
1629
+ await new Promise((resolve, reject) => {
1630
+ httpServer.close((error) => {
1631
+ if (error) {
1632
+ reject(error);
1633
+ } else {
1634
+ resolve();
1635
+ }
1636
+ });
1637
+ httpServer.closeAllConnections();
1638
+ });
1639
+ }
1640
+ await runtimeDisposal;
1641
+ await validationDisposal;
1642
+ await Promise.allSettled([...activeRequests]);
1643
+ boundPort = null;
1644
+ const release = releaseStateLock;
1645
+ releaseStateLock = null;
1646
+ await release?.();
1647
+ })();
1648
+ return closeOperation;
1649
+ },
1650
+ injectConnection(socket) {
1651
+ if (closed) {
1652
+ socket.destroy();
1653
+ return;
1654
+ }
1655
+ httpServer.emit("connection", socket);
1656
+ }
1657
+ };
1658
+ }
1659
+ export {
1660
+ DaemonConfigError,
1661
+ createDaemonServer
1662
+ };