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,1124 @@
1
+ // apps/daemon/src/task-runtime.ts
2
+ import { randomUUID } from "node:crypto";
3
+ import { stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import {
6
+ isPiBridgeError
7
+ } from "../../../packages/pi-bridge/src/index.js";
8
+ import {
9
+ PROTOCOL_VERSION,
10
+ createDaemonEventFactory,
11
+ taskRuntimeSnapshotSchema,
12
+ taskRuntimeStatusSchema,
13
+ taskSessionTreeSchema
14
+ } from "../../../packages/protocol/src/index.js";
15
+ import { buildSessionTree, buildTranscript } from "./session-projection.js";
16
+ var TaskRuntimeOperationError = class extends Error {
17
+ name = "TaskRuntimeOperationError";
18
+ code;
19
+ status;
20
+ constructor(code, message, status, options) {
21
+ super(message, options);
22
+ this.code = code;
23
+ this.status = status;
24
+ }
25
+ };
26
+ var DETAIL_LIMIT = 1e3;
27
+ function clip(text) {
28
+ return text.length > DETAIL_LIMIT ? `${text.slice(0, DETAIL_LIMIT - 1)}\u2026` : text;
29
+ }
30
+ function errorMessage(error) {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+ var MAX_APPROVAL_TIMEOUT_MS = 10 * 6e4;
34
+ var MAX_APPROVAL_TIMEOUT_MARGIN_MS = 5e3;
35
+ function createTaskRuntimeCoordinator(options) {
36
+ const now = options.now ?? Date.now;
37
+ const entries = /* @__PURE__ */ new Map();
38
+ const startsInFlight = /* @__PURE__ */ new Map();
39
+ const runtimeCleanups = /* @__PURE__ */ new Map();
40
+ const uncertainRuntimeCleanups = /* @__PURE__ */ new Set();
41
+ const activeRuntimeRequests = /* @__PURE__ */ new Map();
42
+ const exclusiveRuntimeRequests = /* @__PURE__ */ new Set();
43
+ const archiveStops = /* @__PURE__ */ new Set();
44
+ const lastStatuses = /* @__PURE__ */ new Map();
45
+ let disposed = false;
46
+ function statusOf(entry) {
47
+ return taskRuntimeStatusSchema.parse({
48
+ protocol: PROTOCOL_VERSION,
49
+ type: "task-runtime",
50
+ taskId: entry.taskId,
51
+ runtimeGeneration: entry.generation,
52
+ lastSequence: entry.lastSequence,
53
+ state: entry.state,
54
+ working: entry.working,
55
+ approval: entry.pendingApproval?.approval ?? null,
56
+ model: entry.model,
57
+ ...entry.detail === null ? {} : { detail: clip(entry.detail) }
58
+ });
59
+ }
60
+ function publishStatus(entry) {
61
+ const event = entry.emit("runtime-status", {
62
+ state: entry.state,
63
+ model: entry.model,
64
+ ...entry.detail === null ? {} : { detail: clip(entry.detail) }
65
+ });
66
+ lastStatuses.set(entry.taskId, statusOf(entry));
67
+ options.publish(event);
68
+ }
69
+ function publishApprovalResolved(entry, pending, resolution) {
70
+ const event = entry.emit("approval-resolved", {
71
+ approvalId: pending.approval.approvalId,
72
+ resolution
73
+ });
74
+ lastStatuses.set(entry.taskId, statusOf(entry));
75
+ options.publish(event);
76
+ }
77
+ function detachPendingApproval(entry) {
78
+ const pending = entry.pendingApproval;
79
+ if (pending === null) return null;
80
+ entry.pendingApproval = null;
81
+ if (pending.timer !== null) clearTimeout(pending.timer);
82
+ pending.timer = null;
83
+ return pending;
84
+ }
85
+ function expirePendingApproval(entry, pending) {
86
+ if (entry.removed || entry.pendingApproval !== pending) return;
87
+ detachPendingApproval(entry);
88
+ try {
89
+ entry.runtime.respondToDialog({
90
+ requestId: pending.requestId,
91
+ kind: "confirm",
92
+ confirmed: false
93
+ });
94
+ } catch (error) {
95
+ options.publish(
96
+ entry.emit("log", {
97
+ level: "error",
98
+ message: `Failed to deliver approval timeout ${pending.approval.approvalId}: ${errorMessage(error)}`
99
+ })
100
+ );
101
+ }
102
+ publishApprovalResolved(entry, pending, "timeout");
103
+ }
104
+ function supersedePendingApproval(entry, respond) {
105
+ const pending = detachPendingApproval(entry);
106
+ if (pending === null) return;
107
+ if (respond) {
108
+ try {
109
+ entry.runtime.respondToDialog({
110
+ requestId: pending.requestId,
111
+ kind: "confirm",
112
+ confirmed: false
113
+ });
114
+ } catch (error) {
115
+ options.publish(
116
+ entry.emit("log", {
117
+ level: "error",
118
+ message: `Failed to deny superseded approval ${pending.approval.approvalId}: ${errorMessage(error)}`
119
+ })
120
+ );
121
+ }
122
+ }
123
+ publishApprovalResolved(entry, pending, "superseded");
124
+ }
125
+ function removeEntry(entry, respondToApproval = true) {
126
+ supersedePendingApproval(entry, respondToApproval);
127
+ entry.removed = true;
128
+ entry.unsubscribe();
129
+ if (entries.get(entry.taskId) === entry) {
130
+ entries.delete(entry.taskId);
131
+ }
132
+ }
133
+ function trackRuntimeCleanup(entry) {
134
+ const existing = runtimeCleanups.get(entry);
135
+ if (existing !== void 0) return existing;
136
+ const cleanup = (async () => {
137
+ try {
138
+ await entry.runtime.dispose();
139
+ } catch (error) {
140
+ options.publish(
141
+ entry.emit("log", {
142
+ level: "error",
143
+ message: clip(
144
+ `Pi runtime cleanup failed; durable recovery ownership was retained: ${errorMessage(error)}`
145
+ )
146
+ })
147
+ );
148
+ return false;
149
+ }
150
+ if (entry.processRecording !== null) {
151
+ try {
152
+ await entry.processRecording;
153
+ } catch (error) {
154
+ options.publish(
155
+ entry.emit("log", {
156
+ level: "error",
157
+ message: clip(
158
+ `Pi runtime ownership recording did not settle cleanly; the lease was retained: ${errorMessage(error)}`
159
+ )
160
+ })
161
+ );
162
+ return false;
163
+ }
164
+ }
165
+ if (entry.processPid !== null && options.clearRuntimeProcess !== void 0) {
166
+ try {
167
+ await options.clearRuntimeProcess(entry.taskId, entry.generation, entry.processPid);
168
+ } catch (error) {
169
+ options.publish(
170
+ entry.emit("log", {
171
+ level: "error",
172
+ message: clip(
173
+ `Pi stopped, but its durable recovery lease could not be cleared: ${errorMessage(error)}`
174
+ )
175
+ })
176
+ );
177
+ return false;
178
+ }
179
+ }
180
+ return true;
181
+ })();
182
+ runtimeCleanups.set(entry, cleanup);
183
+ void cleanup.then((clean) => {
184
+ if (!clean) uncertainRuntimeCleanups.add(entry);
185
+ if (runtimeCleanups.get(entry) === cleanup) {
186
+ runtimeCleanups.delete(entry);
187
+ }
188
+ });
189
+ return cleanup;
190
+ }
191
+ async function waitForTaskCleanups(taskId) {
192
+ const results = await Promise.all(
193
+ [...runtimeCleanups].filter(([entry]) => entry.taskId === taskId).map(([, cleanup]) => cleanup)
194
+ );
195
+ return results.every(Boolean) && ![...uncertainRuntimeCleanups].some((entry) => entry.taskId === taskId);
196
+ }
197
+ function publishCleanupUncertain(taskId, preferred) {
198
+ const entry = preferred ?? [...uncertainRuntimeCleanups].find((candidate) => candidate.taskId === taskId);
199
+ if (entry === void 0) return;
200
+ entry.state = "failed";
201
+ entry.working = false;
202
+ entry.detail = "Pi runtime cleanup could not be proven. Restart Webdesk to run durable process recovery before starting or archiving this task.";
203
+ publishStatus(entry);
204
+ }
205
+ function requireCleanupProven(taskId, clean, entry) {
206
+ if (!clean) {
207
+ publishCleanupUncertain(taskId, entry);
208
+ throw new TaskRuntimeOperationError(
209
+ "runtime-recovery-required",
210
+ "A previous Pi runtime cleanup could not be proven. Restart Webdesk to run durable process recovery before starting this task again.",
211
+ 409
212
+ );
213
+ }
214
+ }
215
+ function assertTaskNotArchiving(taskId) {
216
+ if (options.isTaskArchiving?.(taskId) === true || archiveStops.has(taskId)) {
217
+ throw new TaskRuntimeOperationError(
218
+ "runtime-busy",
219
+ "This task is being archived or restored. Wait for that operation to finish.",
220
+ 409
221
+ );
222
+ }
223
+ }
224
+ async function trackRuntimeRequest(taskId, operation, exclusive = true) {
225
+ assertTaskNotArchiving(taskId);
226
+ if (exclusive && activeRuntimeRequests.has(taskId)) {
227
+ throw new TaskRuntimeOperationError(
228
+ "runtime-busy",
229
+ "Another Pi runtime request is already in progress for this task.",
230
+ 409
231
+ );
232
+ }
233
+ if (exclusive) exclusiveRuntimeRequests.add(taskId);
234
+ activeRuntimeRequests.set(taskId, (activeRuntimeRequests.get(taskId) ?? 0) + 1);
235
+ try {
236
+ return await operation();
237
+ } finally {
238
+ if (exclusive) exclusiveRuntimeRequests.delete(taskId);
239
+ const remaining = (activeRuntimeRequests.get(taskId) ?? 1) - 1;
240
+ if (remaining === 0) activeRuntimeRequests.delete(taskId);
241
+ else activeRuntimeRequests.set(taskId, remaining);
242
+ }
243
+ }
244
+ function cleanUpFailedRuntime(entry) {
245
+ if (entry.removed) return;
246
+ removeEntry(entry);
247
+ void trackRuntimeCleanup(entry);
248
+ }
249
+ function handleDialogRequest(entry, request) {
250
+ if (request.kind === "approval") {
251
+ if (entry.pendingApproval !== null) {
252
+ try {
253
+ entry.runtime.respondToDialog({
254
+ requestId: request.requestId,
255
+ kind: "confirm",
256
+ confirmed: false
257
+ });
258
+ } catch {
259
+ }
260
+ options.publish(
261
+ entry.emit("log", {
262
+ level: "error",
263
+ message: `Refused overlapping ${request.toolName} approval for tool call ${request.toolCallId}; only one decision may be pending per task runtime.`
264
+ })
265
+ );
266
+ return;
267
+ }
268
+ const receivedAtMs = now();
269
+ const requestedAtMs = Math.min(request.requestedAtMs, receivedAtMs);
270
+ const upstreamTimeoutMs = Math.min(request.timeoutMs, MAX_APPROVAL_TIMEOUT_MS);
271
+ const timeoutMarginMs = Math.min(
272
+ MAX_APPROVAL_TIMEOUT_MARGIN_MS,
273
+ Math.max(1, Math.floor(upstreamTimeoutMs * 0.05))
274
+ );
275
+ const expiresAtMs = requestedAtMs + Math.max(1, upstreamTimeoutMs - timeoutMarginMs);
276
+ const timeoutMs = expiresAtMs - receivedAtMs;
277
+ if (timeoutMs <= 0) {
278
+ try {
279
+ entry.runtime.respondToDialog({
280
+ requestId: request.requestId,
281
+ kind: "confirm",
282
+ confirmed: false
283
+ });
284
+ } catch {
285
+ }
286
+ options.publish(
287
+ entry.emit("log", {
288
+ level: "warning",
289
+ message: `Refused expired ${request.toolName} approval for tool call ${request.toolCallId}; the action remains blocked.`
290
+ })
291
+ );
292
+ return;
293
+ }
294
+ const pending = {
295
+ approval: {
296
+ approvalId: randomUUID(),
297
+ toolCallId: request.toolCallId,
298
+ toolName: request.toolName,
299
+ summary: request.summary,
300
+ requestedAtMs,
301
+ expiresAtMs
302
+ },
303
+ requestId: request.requestId,
304
+ timer: null
305
+ };
306
+ entry.pendingApproval = pending;
307
+ options.publish(entry.emit("approval-requested", pending.approval));
308
+ pending.timer = setTimeout(() => {
309
+ expirePendingApproval(entry, pending);
310
+ }, timeoutMs);
311
+ pending.timer.unref?.();
312
+ return;
313
+ }
314
+ try {
315
+ entry.runtime.respondToDialog({
316
+ requestId: request.requestId,
317
+ kind: "cancel"
318
+ });
319
+ options.publish(
320
+ entry.emit("log", {
321
+ level: "warning",
322
+ message: `Cancelled unsupported Pi ${request.kind} dialog ("${request.title}"): only versioned Webdesk policy approvals may reach the browser.`
323
+ })
324
+ );
325
+ } catch (error) {
326
+ options.publish(
327
+ entry.emit("log", {
328
+ level: "error",
329
+ message: `Failed to cancel Pi dialog ${request.requestId}: ${errorMessage(error)}`
330
+ })
331
+ );
332
+ }
333
+ }
334
+ function handleRuntimeEvent(entry, event) {
335
+ if (entry.removed) return;
336
+ switch (event.kind) {
337
+ case "lifecycle":
338
+ if (event.state === "failed" && entry.state === "ready" && !entry.removed) {
339
+ entry.state = "failed";
340
+ entry.working = false;
341
+ entry.detail = "Pi reported a fatal runtime failure.";
342
+ publishStatus(entry);
343
+ queueMicrotask(() => cleanUpFailedRuntime(entry));
344
+ }
345
+ return;
346
+ case "assistant-delta":
347
+ options.publish(entry.emit("assistant-delta", { text: event.text }));
348
+ return;
349
+ case "tool-activity":
350
+ options.publish(entry.emit("tool-activity", event.tool));
351
+ return;
352
+ case "agent-activity":
353
+ entry.activityRevision++;
354
+ if (event.phase === "agent-start" || event.phase === "turn-start") {
355
+ entry.working = true;
356
+ } else if (event.phase === "agent-end" || event.phase === "agent-settled") {
357
+ entry.working = false;
358
+ }
359
+ options.publish(entry.emit("agent-activity", { phase: event.phase }));
360
+ return;
361
+ case "notification":
362
+ options.publish(entry.emit("log", { level: event.level, message: event.message }));
363
+ return;
364
+ case "extension-error":
365
+ options.publish(
366
+ entry.emit("log", {
367
+ level: "error",
368
+ message: `Pi extension error: ${event.message}`
369
+ })
370
+ );
371
+ return;
372
+ case "protocol-issue":
373
+ options.publish(
374
+ entry.emit("log", {
375
+ level: event.fatal ? "error" : "warning",
376
+ message: `Pi protocol issue: ${event.message}`
377
+ })
378
+ );
379
+ if (event.fatal && entry.state === "failed") {
380
+ entry.detail = `Pi protocol failure: ${event.message}`;
381
+ publishStatus(entry);
382
+ cleanUpFailedRuntime(entry);
383
+ }
384
+ return;
385
+ case "policy-status":
386
+ return;
387
+ case "dialog-requested":
388
+ handleDialogRequest(entry, event.request);
389
+ return;
390
+ case "exited": {
391
+ const interrupted = entry.working || entry.pendingApproval !== null;
392
+ entry.working = false;
393
+ entry.detail = `Pi exited (code ${event.code}, signal ${event.signal ?? "null"})`;
394
+ if (entry.state === "starting") {
395
+ entry.startupExit = entry.detail;
396
+ return;
397
+ }
398
+ if (entry.state !== "failed") entry.state = interrupted ? "failed" : "exited";
399
+ publishStatus(entry);
400
+ removeEntry(entry, false);
401
+ void trackRuntimeCleanup(entry);
402
+ return;
403
+ }
404
+ }
405
+ }
406
+ async function verifyRuntimeWorktree(task) {
407
+ try {
408
+ if (options.validateWorktree !== void 0) return await options.validateWorktree(task);
409
+ const worktree = await stat(task.worktreePath);
410
+ if (!worktree.isDirectory()) throw new Error("not a directory");
411
+ return task.worktreePath;
412
+ } catch (error) {
413
+ throw new TaskRuntimeOperationError(
414
+ "task-not-ready",
415
+ `The task worktree is unavailable at ${task.worktreePath}. Recover the worktree before starting Pi.`,
416
+ 409,
417
+ { cause: error }
418
+ );
419
+ }
420
+ }
421
+ async function startRuntime(taskId) {
422
+ const task = await options.getTask(taskId);
423
+ if (disposed) {
424
+ throw new TaskRuntimeOperationError(
425
+ "runtime-unavailable",
426
+ "The daemon is shutting down.",
427
+ 503
428
+ );
429
+ }
430
+ if (task === null) {
431
+ throw new TaskRuntimeOperationError(
432
+ "task-not-found",
433
+ "The selected task no longer exists.",
434
+ 404
435
+ );
436
+ }
437
+ if (task.status !== "ready") {
438
+ throw new TaskRuntimeOperationError(
439
+ "task-not-ready",
440
+ `Only a ready task can start a runtime; this task is ${task.status}.`,
441
+ 409
442
+ );
443
+ }
444
+ let verifiedWorktreePath = await verifyRuntimeWorktree(task);
445
+ if (disposed) {
446
+ throw new TaskRuntimeOperationError(
447
+ "runtime-unavailable",
448
+ "The daemon is shutting down.",
449
+ 503
450
+ );
451
+ }
452
+ if (options.reconcileRuntimeProcess !== void 0) {
453
+ try {
454
+ await options.reconcileRuntimeProcess(taskId);
455
+ } catch (error) {
456
+ throw new TaskRuntimeOperationError(
457
+ "runtime-recovery-required",
458
+ `Webdesk could not safely reconcile the previous Pi runtime: ${errorMessage(error)} Stop any remaining task processes, then choose Start Pi to retry.`,
459
+ 409,
460
+ { cause: error }
461
+ );
462
+ }
463
+ }
464
+ let generation;
465
+ try {
466
+ generation = await options.claimRuntimeGeneration(taskId);
467
+ } catch (error) {
468
+ throw new TaskRuntimeOperationError(
469
+ "runtime-unavailable",
470
+ `Webdesk could not reserve a runtime generation: ${errorMessage(error)}`,
471
+ 503,
472
+ { cause: error }
473
+ );
474
+ }
475
+ if (disposed) {
476
+ throw new TaskRuntimeOperationError(
477
+ "runtime-unavailable",
478
+ "The daemon is shutting down.",
479
+ 503
480
+ );
481
+ }
482
+ verifiedWorktreePath = await verifyRuntimeWorktree(task);
483
+ if (disposed) {
484
+ throw new TaskRuntimeOperationError(
485
+ "runtime-unavailable",
486
+ "The daemon is shutting down.",
487
+ 503
488
+ );
489
+ }
490
+ let runtime;
491
+ try {
492
+ runtime = options.createRuntime({
493
+ taskId,
494
+ worktreePath: verifiedWorktreePath,
495
+ sessionFile: task.piSessionFile ?? null
496
+ });
497
+ } catch (error) {
498
+ throw new TaskRuntimeOperationError(
499
+ "runtime-start-failed",
500
+ `The Pi runtime could not be created: ${errorMessage(error)}`,
501
+ 502,
502
+ { cause: error }
503
+ );
504
+ }
505
+ const rawEmit = createDaemonEventFactory({
506
+ taskId,
507
+ runtimeGeneration: generation,
508
+ ...options.now === void 0 ? {} : { now: options.now }
509
+ });
510
+ let entry;
511
+ entry = {
512
+ taskId,
513
+ generation,
514
+ runtime,
515
+ state: "starting",
516
+ working: false,
517
+ activityRevision: 0,
518
+ startupExit: null,
519
+ detail: null,
520
+ model: null,
521
+ lastSequence: null,
522
+ pendingApproval: null,
523
+ processPid: null,
524
+ processRecording: null,
525
+ removed: false,
526
+ unsubscribe: () => {
527
+ },
528
+ emit(type, payload) {
529
+ const event = rawEmit(type, payload);
530
+ entry.lastSequence = event.sequence;
531
+ return event;
532
+ }
533
+ };
534
+ const competingEntry = entries.get(taskId);
535
+ if (competingEntry !== void 0 && !competingEntry.removed) {
536
+ await runtime.dispose().catch(() => void 0);
537
+ throw new TaskRuntimeOperationError(
538
+ "runtime-busy",
539
+ "Another Pi runtime became active while this task was starting.",
540
+ 409
541
+ );
542
+ }
543
+ entry.unsubscribe = runtime.subscribe((event) => handleRuntimeEvent(entry, event));
544
+ entries.set(taskId, entry);
545
+ publishStatus(entry);
546
+ try {
547
+ const handshake = await runtime.start({
548
+ onProcessStarted: async (pid) => {
549
+ entry.processPid = pid;
550
+ const recording = options.recordRuntimeProcess?.(taskId, generation, pid) ?? Promise.resolve();
551
+ entry.processRecording = recording;
552
+ await recording;
553
+ }
554
+ });
555
+ assertRuntimeInitializing(entry);
556
+ const piState = await runtime.getState();
557
+ assertRuntimeInitializing(entry);
558
+ if (piState.sessionFile === null || !path.isAbsolute(piState.sessionFile)) {
559
+ throw new Error("Pi did not report an absolute persistent session file for this task.");
560
+ }
561
+ const sessionFile = path.normalize(piState.sessionFile);
562
+ if (task.piSessionFile !== void 0 && path.normalize(task.piSessionFile) !== sessionFile) {
563
+ throw new Error(
564
+ "Pi resumed a different session file than the one associated with this task."
565
+ );
566
+ }
567
+ if (task.piSessionFile === void 0) {
568
+ await options.recordTaskSession(taskId, sessionFile);
569
+ assertRuntimeInitializing(entry);
570
+ }
571
+ assertRuntimeInitializing(entry);
572
+ entry.model = normalizeModel(piState.model);
573
+ const authenticated = entry.model === null || options.isProviderAuthenticated === void 0 ? true : await options.isProviderAuthenticated(entry.model.provider);
574
+ assertRuntimeInitializing(entry);
575
+ entry.state = authenticated ? "ready" : "needs_auth";
576
+ entry.detail = authenticated ? `pita-policy v${handshake.version}, ${handshake.mode} mode` : `Sign in to ${entry.model?.provider ?? "the selected Pi provider"} before sending a prompt.`;
577
+ publishStatus(entry);
578
+ return statusOf(entry);
579
+ } catch (error) {
580
+ const message = errorMessage(error);
581
+ const removedBeforeFailure = entry.removed;
582
+ if (!entry.removed) {
583
+ entry.state = "failed";
584
+ entry.detail = message;
585
+ publishStatus(entry);
586
+ removeEntry(entry);
587
+ }
588
+ if (!removedBeforeFailure) await trackRuntimeCleanup(entry);
589
+ if (error instanceof TaskRuntimeOperationError) throw error;
590
+ throw new TaskRuntimeOperationError(
591
+ "runtime-start-failed",
592
+ `The Pi runtime did not become ready: ${message}`,
593
+ 502,
594
+ { cause: error }
595
+ );
596
+ }
597
+ }
598
+ function assertRuntimeInitializing(entry) {
599
+ if (disposed || entry.removed) {
600
+ throw new TaskRuntimeOperationError(
601
+ "runtime-unavailable",
602
+ "The daemon is shutting down.",
603
+ 503
604
+ );
605
+ }
606
+ if (entry.startupExit !== null) {
607
+ throw new Error(entry.startupExit);
608
+ }
609
+ }
610
+ async function inspectRuntime(taskId) {
611
+ const task = await options.getTask(taskId);
612
+ requireNotDisposed();
613
+ if (task === null) {
614
+ throw new TaskRuntimeOperationError(
615
+ "task-not-found",
616
+ "The selected task no longer exists.",
617
+ 404
618
+ );
619
+ }
620
+ if (task.status !== "ready") {
621
+ throw new TaskRuntimeOperationError(
622
+ "task-not-ready",
623
+ `Only a ready task can own a runtime; this task is ${task.status}.`,
624
+ 409
625
+ );
626
+ }
627
+ const entry = entries.get(taskId);
628
+ if (entry !== void 0) return statusOf(entry);
629
+ const previous = lastStatuses.get(taskId);
630
+ if (previous !== void 0) return previous;
631
+ return taskRuntimeStatusSchema.parse({
632
+ protocol: PROTOCOL_VERSION,
633
+ type: "task-runtime",
634
+ taskId,
635
+ runtimeGeneration: task.runtimeGeneration ?? 0,
636
+ lastSequence: null,
637
+ state: "idle",
638
+ working: false,
639
+ model: null
640
+ });
641
+ }
642
+ async function offlineSnapshot(taskId) {
643
+ const status = await inspectRuntime(taskId);
644
+ return taskRuntimeSnapshotSchema.parse({
645
+ protocol: PROTOCOL_VERSION,
646
+ type: "task-runtime-snapshot",
647
+ taskId,
648
+ runtimeGeneration: status.runtimeGeneration,
649
+ lastSequence: status.lastSequence,
650
+ state: status.state,
651
+ working: status.working,
652
+ approval: status.approval,
653
+ model: status.model ?? null,
654
+ ...status.detail === void 0 ? {} : { detail: status.detail },
655
+ transcript: null,
656
+ transcriptUnavailableReason: "runtime-not-live",
657
+ omittedTranscriptItems: 0,
658
+ sessionTree: null,
659
+ inFlightGap: status.working
660
+ });
661
+ }
662
+ async function captureSnapshot(taskId) {
663
+ const entry = entries.get(taskId);
664
+ if (entry === void 0 || entry.removed) return offlineSnapshot(taskId);
665
+ let piEntries = null;
666
+ let piTree = null;
667
+ let unavailableReason;
668
+ let captureStable = false;
669
+ for (let attempt = 0; attempt < 3; attempt++) {
670
+ const cursorBefore = entry.lastSequence;
671
+ try {
672
+ piEntries = await entry.runtime.getEntries();
673
+ unavailableReason = void 0;
674
+ } catch (error) {
675
+ piEntries = null;
676
+ unavailableReason = isPiBridgeError(error, "PI_RUNTIME_NOT_READY") ? "runtime-not-live" : "pi-request-failed";
677
+ break;
678
+ }
679
+ try {
680
+ piTree = await entry.runtime.getTree();
681
+ } catch {
682
+ piTree = null;
683
+ }
684
+ if (entry.removed) break;
685
+ if (entry.lastSequence === cursorBefore) {
686
+ captureStable = true;
687
+ break;
688
+ }
689
+ }
690
+ if (entry.removed) return offlineSnapshot(taskId);
691
+ const status = statusOf(entry);
692
+ const transcript = piEntries === null ? null : buildTranscript(piEntries);
693
+ return taskRuntimeSnapshotSchema.parse({
694
+ protocol: PROTOCOL_VERSION,
695
+ type: "task-runtime-snapshot",
696
+ taskId,
697
+ runtimeGeneration: status.runtimeGeneration,
698
+ lastSequence: status.lastSequence,
699
+ state: status.state,
700
+ working: status.working,
701
+ approval: status.approval,
702
+ model: status.model ?? null,
703
+ ...status.detail === void 0 ? {} : { detail: status.detail },
704
+ transcript: transcript?.items ?? null,
705
+ ...unavailableReason === void 0 ? {} : { transcriptUnavailableReason: unavailableReason },
706
+ omittedTranscriptItems: transcript?.omittedItems ?? 0,
707
+ sessionTree: piTree === null ? null : buildSessionTree(piTree),
708
+ // A working runtime is streaming non-canonical output. Likewise, if all
709
+ // bounded attempts observed cursor movement, the transcript and cursor
710
+ // were not captured atomically even if the turn just settled.
711
+ inFlightGap: status.working || piEntries !== null && !captureStable
712
+ });
713
+ }
714
+ function requireLiveRuntime(taskId) {
715
+ const entry = entries.get(taskId);
716
+ if (entry === void 0 || entry.state !== "ready") {
717
+ throw new TaskRuntimeOperationError(
718
+ "runtime-not-ready",
719
+ "Start the task runtime and wait for it to become ready first.",
720
+ 409
721
+ );
722
+ }
723
+ return entry;
724
+ }
725
+ function requireModelRuntime(taskId) {
726
+ const entry = entries.get(taskId);
727
+ if (entry === void 0 || entry.state !== "ready" && entry.state !== "needs_auth") {
728
+ throw new TaskRuntimeOperationError(
729
+ "runtime-not-ready",
730
+ "Start the task runtime and wait for Pi setup before selecting a model.",
731
+ 409
732
+ );
733
+ }
734
+ return entry;
735
+ }
736
+ function requireNotDisposed() {
737
+ if (disposed) {
738
+ throw new TaskRuntimeOperationError(
739
+ "runtime-unavailable",
740
+ "The daemon is shutting down.",
741
+ 503
742
+ );
743
+ }
744
+ }
745
+ return {
746
+ async ensure(taskId) {
747
+ requireNotDisposed();
748
+ assertTaskNotArchiving(taskId);
749
+ const inFlight = startsInFlight.get(taskId);
750
+ if (inFlight !== void 0) return inFlight;
751
+ const entry = entries.get(taskId);
752
+ if (entry !== void 0 && entry.state !== "failed") return statusOf(entry);
753
+ if (entry?.state === "failed") cleanUpFailedRuntime(entry);
754
+ const start = waitForTaskCleanups(taskId).then((clean) => {
755
+ requireCleanupProven(taskId, clean);
756
+ return startRuntime(taskId);
757
+ }).finally(() => {
758
+ startsInFlight.delete(taskId);
759
+ });
760
+ startsInFlight.set(taskId, start);
761
+ return start;
762
+ },
763
+ status: inspectRuntime,
764
+ async snapshot(taskId) {
765
+ requireNotDisposed();
766
+ return captureSnapshot(taskId);
767
+ },
768
+ async sessionTree(taskId) {
769
+ requireNotDisposed();
770
+ const entry = entries.get(taskId);
771
+ if (entry === void 0 || entry.removed || entry.state !== "ready" && entry.state !== "needs_auth") {
772
+ throw new TaskRuntimeOperationError(
773
+ "runtime-not-ready",
774
+ "Start the task runtime to view its Pi session tree.",
775
+ 409
776
+ );
777
+ }
778
+ try {
779
+ const tree = await entry.runtime.getTree();
780
+ return taskSessionTreeSchema.parse({
781
+ protocol: PROTOCOL_VERSION,
782
+ type: "task-session-tree",
783
+ taskId,
784
+ runtimeGeneration: entry.generation,
785
+ sessionTree: buildSessionTree(tree)
786
+ });
787
+ } catch (error) {
788
+ throw new TaskRuntimeOperationError(
789
+ "runtime-request-failed",
790
+ "Pi could not provide the session tree. Retry after the runtime settles.",
791
+ 502,
792
+ { cause: error }
793
+ );
794
+ }
795
+ },
796
+ steer(taskId, message) {
797
+ return trackRuntimeRequest(taskId, async () => {
798
+ requireNotDisposed();
799
+ const entry = requireLiveRuntime(taskId);
800
+ if (entry.pendingApproval !== null) {
801
+ throw new TaskRuntimeOperationError(
802
+ "runtime-busy",
803
+ "Resolve the pending tool approval before steering Pi.",
804
+ 409
805
+ );
806
+ }
807
+ if (!entry.working) {
808
+ throw new TaskRuntimeOperationError(
809
+ "runtime-not-working",
810
+ "Pi is not working on a turn; send a prompt instead.",
811
+ 409
812
+ );
813
+ }
814
+ try {
815
+ await entry.runtime.steer(message);
816
+ } catch (error) {
817
+ throw new TaskRuntimeOperationError(
818
+ "runtime-request-failed",
819
+ `Pi did not accept the steering message: ${errorMessage(error)}`,
820
+ 502,
821
+ { cause: error }
822
+ );
823
+ }
824
+ return statusOf(entry);
825
+ });
826
+ },
827
+ prompt(taskId, message) {
828
+ return trackRuntimeRequest(taskId, async () => {
829
+ requireNotDisposed();
830
+ if (options.isTaskLifecycleBusy?.(taskId) === true) {
831
+ throw new TaskRuntimeOperationError(
832
+ "runtime-busy",
833
+ "A validation, commit, or merge operation is active for this task. Wait for it to finish before prompting Pi.",
834
+ 409
835
+ );
836
+ }
837
+ const candidate = entries.get(taskId);
838
+ if (candidate?.state === "needs_auth") {
839
+ throw new TaskRuntimeOperationError(
840
+ "runtime-auth-required",
841
+ `Sign in to ${candidate.model?.provider ?? "the selected Pi provider"}, then restart Pi before resending the prompt.`,
842
+ 409
843
+ );
844
+ }
845
+ const entry = requireLiveRuntime(taskId);
846
+ if (entry.working) {
847
+ throw new TaskRuntimeOperationError(
848
+ "runtime-busy",
849
+ "Pi is already working. Abort the active turn or wait for it to finish.",
850
+ 409
851
+ );
852
+ }
853
+ const activityBeforePrompt = entry.activityRevision;
854
+ entry.working = true;
855
+ try {
856
+ await entry.runtime.prompt(message);
857
+ } catch (error) {
858
+ if (entry.activityRevision === activityBeforePrompt) entry.working = false;
859
+ if (isAuthRequiredError(error)) {
860
+ entry.state = "needs_auth";
861
+ entry.detail = `Sign in to ${entry.model?.provider ?? "the selected Pi provider"}, then restart Pi.`;
862
+ publishStatus(entry);
863
+ throw new TaskRuntimeOperationError("runtime-auth-required", entry.detail, 409, {
864
+ cause: error
865
+ });
866
+ }
867
+ throw new TaskRuntimeOperationError(
868
+ "runtime-request-failed",
869
+ `Pi did not accept the prompt: ${errorMessage(error)}`,
870
+ 502,
871
+ { cause: error }
872
+ );
873
+ }
874
+ return statusOf(entry);
875
+ });
876
+ },
877
+ async restart(taskId) {
878
+ requireNotDisposed();
879
+ assertTaskNotArchiving(taskId);
880
+ if (startsInFlight.has(taskId)) {
881
+ throw new TaskRuntimeOperationError(
882
+ "runtime-busy",
883
+ "Wait for the current Pi startup to finish before restarting it.",
884
+ 409
885
+ );
886
+ }
887
+ if (activeRuntimeRequests.has(taskId)) {
888
+ throw new TaskRuntimeOperationError(
889
+ "runtime-busy",
890
+ "Wait for the active Pi runtime request to finish before restarting it.",
891
+ 409
892
+ );
893
+ }
894
+ const entry = entries.get(taskId);
895
+ if (entry?.working === true) {
896
+ throw new TaskRuntimeOperationError(
897
+ "runtime-busy",
898
+ "Abort the active Pi turn before restarting the runtime.",
899
+ 409
900
+ );
901
+ }
902
+ const restart = (async () => {
903
+ let clean;
904
+ if (entry !== void 0) {
905
+ removeEntry(entry);
906
+ clean = await trackRuntimeCleanup(entry);
907
+ } else {
908
+ clean = await waitForTaskCleanups(taskId);
909
+ }
910
+ requireCleanupProven(taskId, clean, entry);
911
+ return startRuntime(taskId);
912
+ })().finally(() => {
913
+ if (startsInFlight.get(taskId) === restart) {
914
+ startsInFlight.delete(taskId);
915
+ }
916
+ });
917
+ startsInFlight.set(taskId, restart);
918
+ return restart;
919
+ },
920
+ setModel(taskId, provider, modelId) {
921
+ return trackRuntimeRequest(taskId, async () => {
922
+ requireNotDisposed();
923
+ const entry = requireModelRuntime(taskId);
924
+ if (entry.working) {
925
+ throw new TaskRuntimeOperationError(
926
+ "runtime-busy",
927
+ "Wait for Pi to finish before changing the model.",
928
+ 409
929
+ );
930
+ }
931
+ try {
932
+ const selected = await entry.runtime.setModel(provider, modelId);
933
+ if (selected.provider !== provider || selected.id !== modelId) {
934
+ throw new Error("Pi selected a different model than requested.");
935
+ }
936
+ entry.model = normalizeModel(selected);
937
+ const authenticated = options.isProviderAuthenticated === void 0 ? true : await options.isProviderAuthenticated(provider);
938
+ entry.state = authenticated ? "ready" : "needs_auth";
939
+ entry.detail = authenticated ? `Using ${entry.model?.name ?? modelId} via ${provider}.` : `Sign in to ${provider} before sending a prompt.`;
940
+ publishStatus(entry);
941
+ return statusOf(entry);
942
+ } catch (error) {
943
+ const authRequired = isAuthRequiredError(error);
944
+ if (authRequired) {
945
+ entry.state = "needs_auth";
946
+ entry.detail = `Sign in to ${provider}, then select the model again.`;
947
+ publishStatus(entry);
948
+ }
949
+ throw new TaskRuntimeOperationError(
950
+ authRequired ? "runtime-auth-required" : "runtime-request-failed",
951
+ `Pi could not select ${provider}/${modelId}: ${errorMessage(error)}`,
952
+ authRequired ? 409 : 502,
953
+ { cause: error }
954
+ );
955
+ }
956
+ });
957
+ },
958
+ async resolveApproval(taskId, approvalId, decision) {
959
+ requireNotDisposed();
960
+ assertTaskNotArchiving(taskId);
961
+ const entry = entries.get(taskId);
962
+ const pending = entry?.pendingApproval ?? null;
963
+ if (entry === void 0 || pending === null) {
964
+ throw new TaskRuntimeOperationError(
965
+ "approval-not-pending",
966
+ "This task has no approval waiting for a decision.",
967
+ 409
968
+ );
969
+ }
970
+ if (pending.approval.approvalId !== approvalId) {
971
+ throw new TaskRuntimeOperationError(
972
+ "approval-not-pending",
973
+ "That approval is stale or belongs to a different runtime request.",
974
+ 409
975
+ );
976
+ }
977
+ if (now() >= pending.approval.expiresAtMs) {
978
+ expirePendingApproval(entry, pending);
979
+ throw new TaskRuntimeOperationError(
980
+ "approval-not-pending",
981
+ "That approval expired; the action remains blocked.",
982
+ 409
983
+ );
984
+ }
985
+ detachPendingApproval(entry);
986
+ try {
987
+ entry.runtime.respondToDialog({
988
+ requestId: pending.requestId,
989
+ kind: "confirm",
990
+ confirmed: decision === "approve"
991
+ });
992
+ } catch (error) {
993
+ publishApprovalResolved(entry, pending, "superseded");
994
+ options.publish(
995
+ entry.emit("log", {
996
+ level: "error",
997
+ message: `Failed to deliver approval decision ${approvalId}: ${errorMessage(error)}`
998
+ })
999
+ );
1000
+ throw new TaskRuntimeOperationError(
1001
+ "runtime-request-failed",
1002
+ "Pi could not accept the approval decision; the action remains blocked.",
1003
+ 502,
1004
+ { cause: error }
1005
+ );
1006
+ }
1007
+ publishApprovalResolved(entry, pending, decision === "approve" ? "approved" : "denied");
1008
+ return statusOf(entry);
1009
+ },
1010
+ abort(taskId) {
1011
+ return trackRuntimeRequest(taskId, async () => {
1012
+ requireNotDisposed();
1013
+ const entry = requireLiveRuntime(taskId);
1014
+ if (exclusiveRuntimeRequests.has(taskId) && !entry.working && entry.pendingApproval === null) {
1015
+ throw new TaskRuntimeOperationError(
1016
+ "runtime-busy",
1017
+ "Another Pi runtime request is already in progress for this task.",
1018
+ 409
1019
+ );
1020
+ }
1021
+ supersedePendingApproval(entry, true);
1022
+ try {
1023
+ await entry.runtime.abort();
1024
+ } catch (error) {
1025
+ throw new TaskRuntimeOperationError(
1026
+ "runtime-request-failed",
1027
+ `Pi did not accept the abort: ${errorMessage(error)}`,
1028
+ 502,
1029
+ { cause: error }
1030
+ );
1031
+ }
1032
+ return statusOf(entry);
1033
+ }, false);
1034
+ },
1035
+ isMutating(taskId) {
1036
+ const entry = entries.get(taskId);
1037
+ return startsInFlight.has(taskId) || activeRuntimeRequests.has(taskId) || archiveStops.has(taskId) || entry?.working === true || entry?.pendingApproval != null;
1038
+ },
1039
+ async stopForArchive(taskId) {
1040
+ requireNotDisposed();
1041
+ if (archiveStops.has(taskId)) {
1042
+ throw new TaskRuntimeOperationError(
1043
+ "runtime-busy",
1044
+ "Runtime cleanup is already active for this task.",
1045
+ 409
1046
+ );
1047
+ }
1048
+ const entry = entries.get(taskId);
1049
+ if (startsInFlight.has(taskId) || activeRuntimeRequests.has(taskId) || entry?.working === true || entry?.pendingApproval != null) {
1050
+ throw new TaskRuntimeOperationError(
1051
+ "runtime-busy",
1052
+ "Wait for Pi startup, the active turn, or its pending approval to settle before archiving.",
1053
+ 409
1054
+ );
1055
+ }
1056
+ archiveStops.add(taskId);
1057
+ try {
1058
+ let clean = await waitForTaskCleanups(taskId);
1059
+ if (entry !== void 0 && !entry.removed) {
1060
+ removeEntry(entry);
1061
+ clean = await trackRuntimeCleanup(entry) && clean;
1062
+ }
1063
+ clean = await waitForTaskCleanups(taskId) && clean;
1064
+ if (options.reconcileRuntimeProcess !== void 0) {
1065
+ try {
1066
+ await options.reconcileRuntimeProcess(taskId);
1067
+ } catch (error) {
1068
+ throw new TaskRuntimeOperationError(
1069
+ "runtime-recovery-required",
1070
+ `Webdesk could not safely reconcile the previous Pi runtime: ${errorMessage(error)}`,
1071
+ 409,
1072
+ { cause: error }
1073
+ );
1074
+ }
1075
+ }
1076
+ if (!clean) {
1077
+ publishCleanupUncertain(taskId, entry);
1078
+ throw new TaskRuntimeOperationError(
1079
+ "runtime-recovery-required",
1080
+ "Pi runtime cleanup could not be proven; Webdesk kept its recovery lease and did not archive the task.",
1081
+ 409
1082
+ );
1083
+ }
1084
+ lastStatuses.delete(taskId);
1085
+ } finally {
1086
+ archiveStops.delete(taskId);
1087
+ }
1088
+ },
1089
+ async disposeAll() {
1090
+ if (disposed) return;
1091
+ disposed = true;
1092
+ const live = [...entries.values()];
1093
+ entries.clear();
1094
+ await Promise.all([
1095
+ ...runtimeCleanups.values(),
1096
+ ...live.map(async (entry) => {
1097
+ supersedePendingApproval(entry, true);
1098
+ entry.removed = true;
1099
+ entry.unsubscribe();
1100
+ await trackRuntimeCleanup(entry);
1101
+ })
1102
+ ]);
1103
+ runtimeCleanups.clear();
1104
+ uncertainRuntimeCleanups.clear();
1105
+ }
1106
+ };
1107
+ }
1108
+ function normalizeModel(model) {
1109
+ if (model === null || model.provider === void 0) return null;
1110
+ return {
1111
+ provider: model.provider,
1112
+ id: model.id,
1113
+ name: model.name ?? model.id
1114
+ };
1115
+ }
1116
+ function isAuthRequiredError(error) {
1117
+ if (!isPiBridgeError(error, "PI_REQUEST_FAILED")) return false;
1118
+ const detail = error.details["error"];
1119
+ return typeof detail === "string" && /(?:no api key found|use \/login|not authenticated|authentication required)/i.test(detail);
1120
+ }
1121
+ export {
1122
+ TaskRuntimeOperationError,
1123
+ createTaskRuntimeCoordinator
1124
+ };