@marcoscale98/pi-extensible-workflows 5.19.1-fork.1 → 6.2.1-fork.1

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 (123) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +2 -2
  3. package/dist/src/agent-execution.d.ts +43 -10
  4. package/dist/src/agent-execution.js +365 -127
  5. package/dist/src/background-widget.d.ts +0 -1
  6. package/dist/src/background-widget.js +4 -9
  7. package/dist/src/decoders.d.ts +2 -3
  8. package/dist/src/decoders.js +33 -37
  9. package/dist/src/execution.d.ts +2 -0
  10. package/dist/src/execution.js +5 -5
  11. package/dist/src/host-delivery.js +4 -8
  12. package/dist/src/host-navigator.js +5 -2
  13. package/dist/src/host-phases.d.ts +1 -0
  14. package/dist/src/host-recovery.d.ts +6 -6
  15. package/dist/src/host-recovery.js +14 -22
  16. package/dist/src/host-runtime.js +56 -5
  17. package/dist/src/host-view.d.ts +1 -1
  18. package/dist/src/host-view.js +17 -15
  19. package/dist/src/host.js +62 -87
  20. package/dist/src/index.d.ts +1 -2
  21. package/dist/src/index.js +1523 -1630
  22. package/dist/src/index.js.map +3 -3
  23. package/dist/src/paths.d.ts +7 -6
  24. package/dist/src/paths.js +24 -15
  25. package/dist/src/persistence.d.ts +1 -0
  26. package/dist/src/persistence.js +1 -0
  27. package/dist/src/pi-runtime-runner.js +4 -1
  28. package/dist/src/registry.d.ts +9 -7
  29. package/dist/src/registry.js +28 -70
  30. package/dist/src/runtime/agent-runner.d.ts +2 -0
  31. package/dist/src/session-lease.d.ts +1 -1
  32. package/dist/src/session-lease.js +45 -11
  33. package/dist/src/settings.d.ts +6 -3
  34. package/dist/src/settings.js +36 -47
  35. package/dist/src/store.d.ts +3 -1
  36. package/dist/src/store.js +17 -1
  37. package/dist/src/trajectory.d.ts +0 -1
  38. package/dist/src/trajectory.js +2 -3
  39. package/dist/src/types.d.ts +100 -44
  40. package/dist/src/types.js +4 -7
  41. package/dist/src/utils.d.ts +3 -2
  42. package/dist/src/utils.js +20 -10
  43. package/dist/src/validation.d.ts +8 -2
  44. package/dist/src/validation.js +122 -86
  45. package/dist/starter/index.js +8 -9
  46. package/dist/starter/index.js.map +1 -1
  47. package/dist/starter/review-loop.js +8 -7
  48. package/dist/subagents/index.js +654 -814
  49. package/dist/subagents/index.js.map +3 -3
  50. package/dist/subagents/src/contracts.d.ts +4 -3
  51. package/dist/subagents/src/contracts.js +2 -2
  52. package/dist/subagents/src/index.d.ts +1 -1
  53. package/dist/subagents/src/index.js +1 -2
  54. package/dist/subagents/src/manager.js +49 -70
  55. package/dist/subagents/src/navigator.js +3 -14
  56. package/dist/subagents/src/view.d.ts +0 -1
  57. package/dist/subagents/src/view.js +2 -16
  58. package/dist/subagents/src/worktree.js +1 -1
  59. package/dist/trajectory/assets/index.html +8 -8
  60. package/dist/trajectory/index.js +487 -891
  61. package/dist/trajectory/index.js.map +3 -3
  62. package/dist/trajectory/src/assets/index.html +8 -8
  63. package/dist/trajectory/src/index.js +126 -86
  64. package/dist/trajectory/src/server.js +264 -25
  65. package/dist/trajectory/src/server.js.map +3 -3
  66. package/dist/trajectory/src/startup-mutex.d.ts +17 -0
  67. package/dist/trajectory/src/startup-mutex.js +172 -0
  68. package/examples/workflow-extension-template/README.md +4 -7
  69. package/examples/workflow-extension-template/extension.test.mjs +4 -8
  70. package/examples/workflow-extension-template/index.js +0 -3
  71. package/package.json +5 -5
  72. package/skills/pi-extensible-workflows/SKILL.md +10 -8
  73. package/src/agent-execution.ts +339 -127
  74. package/src/background-widget.ts +4 -10
  75. package/src/decoders.ts +31 -37
  76. package/src/execution.ts +5 -5
  77. package/src/host-delivery.ts +4 -8
  78. package/src/host-navigator.ts +4 -2
  79. package/src/host-phases.ts +1 -1
  80. package/src/host-recovery.ts +20 -26
  81. package/src/host-runtime.ts +44 -6
  82. package/src/host-view.ts +18 -11
  83. package/src/host.ts +62 -113
  84. package/src/index.ts +1 -2
  85. package/src/paths.ts +18 -12
  86. package/src/persistence.ts +1 -0
  87. package/src/pi-runtime-runner.ts +3 -1
  88. package/src/registry.ts +27 -63
  89. package/src/runtime/agent-runner.ts +2 -0
  90. package/src/session-lease.ts +34 -6
  91. package/src/settings.ts +35 -47
  92. package/src/store.ts +18 -2
  93. package/src/trajectory.ts +2 -4
  94. package/src/types.ts +71 -30
  95. package/src/utils.ts +18 -9
  96. package/src/validation.ts +102 -79
  97. package/starter/index.ts +0 -1
  98. package/starter/review-loop.ts +8 -7
  99. package/starter/roles/developer.md +0 -1
  100. package/starter/roles/oracle.md +0 -1
  101. package/starter/roles/researcher.md +0 -1
  102. package/starter/roles/reviewer.md +0 -1
  103. package/starter/roles/scout.md +0 -1
  104. package/subagents/README.md +8 -14
  105. package/subagents/src/contracts.ts +3 -4
  106. package/subagents/src/index.ts +2 -3
  107. package/subagents/src/manager.ts +45 -125
  108. package/subagents/src/navigator.ts +4 -13
  109. package/subagents/src/view.ts +4 -14
  110. package/subagents/src/worktree.ts +1 -1
  111. package/trajectory/src/assets/index.html +8 -8
  112. package/trajectory/src/index.ts +106 -82
  113. package/trajectory/src/server.ts +44 -8
  114. package/trajectory/src/startup-mutex.ts +127 -0
  115. package/dist/src/roles.d.ts +0 -70
  116. package/dist/src/roles.js +0 -326
  117. package/dist/starter/roles/developer.md +0 -6
  118. package/dist/starter/roles/oracle.md +0 -18
  119. package/dist/starter/roles/researcher.md +0 -20
  120. package/dist/starter/roles/reviewer.md +0 -19
  121. package/dist/starter/roles/scout.md +0 -18
  122. package/examples/workflow-extension-template/roles/reviewer.md +0 -4
  123. package/src/roles.ts +0 -327
@@ -20,12 +20,7 @@ export type SubagentRenderState = {
20
20
  subagentProgressFrozenAt?: number;
21
21
  };
22
22
 
23
- function roleName(value: unknown): string | undefined {
24
- if (typeof value === "string") return value.trim() || undefined;
25
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
26
- const name = (value as Record<string, unknown>).name;
27
- return typeof name === "string" ? name.trim() || undefined : undefined;
28
- }
23
+
29
24
 
30
25
  function modeName(value: unknown): "background" | "foreground" | undefined {
31
26
  return value === "background" || value === "foreground" ? value : undefined;
@@ -33,14 +28,11 @@ function modeName(value: unknown): "background" | "foreground" | undefined {
33
28
 
34
29
  function requestMetadata(args: SubagentRenderArgs, metadataAvailable: boolean): string {
35
30
  const mode = modeName(args.mode) ?? (metadataAvailable ? "background" : undefined);
36
- const role = metadataAvailable ? roleName(args.role) ?? "none" : undefined;
37
- return [mode === undefined ? undefined : `mode=${mode}`, role === undefined ? undefined : `role=${role}`].filter((value): value is string => value !== undefined).join(" ");
31
+ return [mode === undefined ? undefined : `mode=${mode}`].filter((value): value is string => value !== undefined).join(" ");
38
32
  }
39
33
 
40
34
  function label(args: SubagentRenderArgs): string {
41
35
  if (typeof args.label === "string" && args.label.trim()) return args.label.trim();
42
- const role = roleName(args.role);
43
- if (role) return role;
44
36
  if (typeof args.id === "string" && args.id) return args.id.slice(0, 8);
45
37
  return "subagent";
46
38
  }
@@ -243,19 +235,17 @@ function widgetRow({ status, request }: WidgetRun, now: number, paint: Paint): {
243
235
  }
244
236
 
245
237
  const RECEIPT_ENTRY_TYPE = "piewf-subagent-receipt";
246
- type SubagentReceipt = { readonly id: string; readonly label: string; readonly state: string; readonly model?: string; readonly role?: string; readonly tools?: readonly string[]; readonly input: number; readonly output: number; readonly cacheRead: number; readonly costUsd: number; readonly durationMs: number; readonly attempts: number; readonly error?: string };
238
+ type SubagentReceipt = { readonly id: string; readonly label: string; readonly state: string; readonly model?: string; readonly tools?: readonly string[]; readonly input: number; readonly output: number; readonly cacheRead: number; readonly costUsd: number; readonly durationMs: number; readonly attempts: number; readonly error?: string };
247
239
 
248
240
  function receiptFor(status: Readonly<SubagentStatus>, request: Readonly<SubagentRunRequest>): SubagentReceipt {
249
241
  const accounting = status.progress?.accounting;
250
242
  const model = modelName(status);
251
- const role = roleName(request.role);
252
243
  const tools = status.progress?.state?.tools ?? status.attemptDetails?.at(-1)?.setup.tools;
253
244
  return {
254
245
  id: status.id,
255
246
  label: label({ ...request, id: status.id }),
256
247
  state: status.state,
257
248
  ...(model ? { model } : {}),
258
- ...(role === undefined ? {} : { role }),
259
249
  ...(tools === undefined ? {} : { tools: [...tools] }),
260
250
  input: accounting?.input ?? 0,
261
251
  output: accounting?.output ?? 0,
@@ -273,7 +263,7 @@ export function renderSubagentReceipt(data: SubagentReceipt, expanded: boolean,
273
263
  const headline = [formatTokens(data.input + data.output), formatCost(data.costUsd) || "$0.00", formatElapsed(data.durationMs), data.state].filter(Boolean).join(" · ");
274
264
  const lines = [`${glyph} ${theme.bold(sanitizeDisplayText(data.label))} ${theme.fg("muted", headline)}`];
275
265
  if (!expanded) return lines;
276
- const meta = [data.model ?? "", data.role ? `role ${data.role}` : "", data.attempts > 1 ? `${String(data.attempts)} attempts` : "", data.tools?.join(" ") ?? ""].filter(Boolean);
266
+ const meta = [data.model ?? "", data.attempts > 1 ? `${String(data.attempts)} attempts` : "", data.tools?.join(" ") ?? ""].filter(Boolean);
277
267
  if (meta.length > 0) lines.push(theme.fg("muted", ` ${meta.join(" · ")}`));
278
268
  const split = [data.input ? `in ${formatTokens(data.input)}` : "", data.output ? `out ${formatTokens(data.output)}` : "", data.cacheRead ? `cache ${formatTokens(data.cacheRead)}` : ""].filter(Boolean);
279
269
  if (split.length > 0) lines.push(theme.fg("muted", ` ${split.join(" · ")}`));
@@ -54,7 +54,7 @@ function syntheticSnapshot(context: Readonly<SubagentWorktreeContext>): LaunchSn
54
54
  ...(context.worktreePostCreateCommandSource === undefined ? {} : { settingsSources: { concurrency: "subagents", modelAliases: "subagents", worktreePostCreateCommand: context.worktreePostCreateCommandSource } }),
55
55
  models: [],
56
56
  tools: [],
57
- agentTypes: [],
57
+ agentConfigurations: {},
58
58
  schemas: [],
59
59
  };
60
60
  }
@@ -142,7 +142,7 @@
142
142
  .workflow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 16px/1.2 var(--serif); }
143
143
  .workflow-meta { color: var(--dim); font: 11px ui-monospace, Menlo, Consolas, monospace; white-space: nowrap; }
144
144
  .agent-grid { min-width: 900px; padding: 0 12px 8px 28px; }
145
- .agent-grid-head, .agent-grid-row { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(140px, 1.2fr) 80px minmax(150px, 1.5fr) 80px 72px 64px; gap: 8px; align-items: center; }
145
+ .agent-grid-head, .agent-grid-row { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(140px, 1.2fr) minmax(150px, 1.5fr) 80px 72px 64px; gap: 8px; align-items: center; }
146
146
  .agent-grid-head { padding: 4px 8px; color: var(--mute); font-size: 10px; letter-spacing: .05em; }
147
147
  .agent-grid-row { min-height: 24px; padding: 3px 8px; cursor: pointer; font-size: 11px; border-radius: 8px; }
148
148
  .agent-grid-row:hover { background: var(--bg-hover); }
@@ -732,9 +732,9 @@
732
732
  const phaseAgents = phase.agents.flatMap((id) => { const agent = byId.get(id); return agent ? [agent] : []; });
733
733
  const groups = agentGridGroups(phaseAgents);
734
734
  const showScopes = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
735
- return `<div class="phaseband">PHASE ${esc(phase.name)}</div>${groups.map((group) => `${showScopes ? `<div class="agent-grid-scope">${esc(group.label)}</div>` : ""}${group.agents.map((agent) => { const depth = agentDepth(agent, byId); const label = agent.label || agent.name; const settled = depth ? "•" : "▾"; const indent = (showScopes ? 12 : 0) + depth * 12; const toolSummary = toolTimingSummary(timingsByAgent.get(agent.id) || []); return `<div class="agent-grid-row${agent.id === state.currentAgent ? " on" : ""}" data-agent="${esc(agent.id)}"><div class="agent-cell" style="padding-left:${indent}px">${settled} ${esc(label)}</div><div class="agent-cell">${esc(agentModel(agent))}</div><div class="agent-cell">${esc(agent.role || "agent")}</div><div class="agent-cell" title="${esc(toolSummary)}">${esc(toolSummary)}</div><div class="agent-cell num">${fmtRuntime(agent.durationMs || 0)}</div><div class="agent-cell num">${fmtTokens(accounting(agent).input + accounting(agent).output)}</div><div class="agent-cell num">${fmtCost(accounting(agent).cost)}</div></div>`; }).join("")}`).join("")}`;
735
+ return `<div class="phaseband">PHASE ${esc(phase.name)}</div>${groups.map((group) => `${showScopes ? `<div class="agent-grid-scope">${esc(group.label)}</div>` : ""}${group.agents.map((agent) => { const depth = agentDepth(agent, byId); const label = agent.label || agent.name; const settled = depth ? "•" : "▾"; const indent = (showScopes ? 12 : 0) + depth * 12; const toolSummary = toolTimingSummary(timingsByAgent.get(agent.id) || []); return `<div class="agent-grid-row${agent.id === state.currentAgent ? " on" : ""}" data-agent="${esc(agent.id)}"><div class="agent-cell" style="padding-left:${indent}px">${settled} ${esc(label)}</div><div class="agent-cell">${esc(agentModel(agent))}</div><div class="agent-cell" title="${esc(toolSummary)}">${esc(toolSummary)}</div><div class="agent-cell num">${fmtRuntime(agent.durationMs || 0)}</div><div class="agent-cell num">${fmtTokens(accounting(agent).input + accounting(agent).output)}</div><div class="agent-cell num">${fmtCost(accounting(agent).cost)}</div></div>`; }).join("")}`).join("")}`;
736
736
  }).join("");
737
- return `<div class="agent-grid"><div class="agent-grid-head"><span>AGENT</span><span>MODEL</span><span>ROLE</span><span>TOOL TIMING</span><span>DURATION</span><span>TOKENS</span><span>COST</span></div>${body}</div>`;
737
+ return `<div class="agent-grid"><div class="agent-grid-head"><span>AGENT</span><span>MODEL</span><span>TOOL TIMING</span><span>DURATION</span><span>TOKENS</span><span>COST</span></div>${body}</div>`;
738
738
  }
739
739
  function renderLogs(record) { const events = record.run.events || []; const logs = events.filter((event) => event.type === "log"); const omitted = events.find((event) => event.type === "trajectory:truncated"); if (!logs.length && !omitted) return ""; const collapsed = state.runLayout.logsCollapsed; return `<section class="logs"><button type="button" class="sec section-toggle" data-toggle="logs" aria-expanded="${!collapsed}">${collapsed ? "▸" : "▾"} LOGS</button>${collapsed ? "" : `<div class="logs-list">${logs.map((event) => `<div class="log-row"><span class="log-time">${esc(fmtClock(event.timestamp))}</span><span class="log-message">${esc(event.message)}</span></div>`).join("")}${omitted ? `<div class="log-row"><span class="log-time">—</span><span class="log-message">${esc(omitted.omitted)} events omitted from live state</span></div>` : ""}</div>`}</section>`; }
740
740
  const scriptCalls = (record) => (record.run.scriptCalls || []).filter((call) => call?.type !== "trajectory:truncated");
@@ -751,7 +751,7 @@
751
751
  function renderAgentSection(record, timingsByAgent) { const collapsed = state.runLayout.agentsCollapsed; return `<button type="button" class="sec section-toggle" data-toggle="agents" aria-expanded="${!collapsed}">${collapsed ? "▸" : "▾"} AGENTS</button>${collapsed ? "" : renderAgentGrid(record, timingsByAgent)}`; }
752
752
  function renderList(record, timingsByAgent) { const run = record.run; const completed = run.agents.filter((agent) => agent.state === "completed").length; return `<section class="workflow-card"><button type="button" class="workflow-head on" data-run="${esc(state.currentRun)}">${glyph(run.state)}<span class="workflow-name">${esc(run.workflowName)}</span><span class="workflow-meta">${completed}/${run.agents.length} agents · ${fmtCost(runCost(record))} · <span data-clock-kind="runtime" data-clock-start="${startOf(record).getTime()}" data-clock-end="${run.state === "running" ? "live" : endOf(record)}"></span></span></button>${renderAgentSection(record, timingsByAgent)}${renderCalls(record)}${renderLogs(record)}</section>`; }
753
753
  function actionButton(label, action, disabled) { return `<button class="btn-alt" data-act="${action}"${disabled ? " disabled" : ""}>${label}</button>`; }
754
- function subagentAgent(subagent) { return { id: subagent.id, name: subagentLabel(subagent), label: subagentLabel(subagent), role: subagent.role || "subagent", state: subagent.state, model: subagent.model, requestedModel: subagent.request.model, tools: subagent.tools || [], toolDefinitions: subagent.toolDefinitions || [], skills: subagent.request.skills || [], extensions: subagent.request.extensions || [], contextFiles: subagent.request.contextFiles || [], prompt: subagent.request.prompt, systemPrompt: subagent.attempt?.setup?.systemPrompt || subagent.request.prompt, attempts: subagent.attempts || 0, mode: subagent.mode, cwd: subagent.cwd, worktree: subagent.worktree, output: subagent.output || subagentOutputFallback(subagent), outcome: subagent.result !== undefined ? { kind: "result", value: subagent.result } : subagent.failure !== undefined ? { kind: "failure", value: subagent.failure } : subagent.attempt?.error !== undefined ? { kind: "failure", value: subagent.attempt.error } : undefined, finishedAt: subagent.finishedAt, attemptDetails: subagent.attempt ? [subagent.attempt] : [] }; }
754
+ function subagentAgent(subagent) { return { id: subagent.id, name: subagentLabel(subagent), label: subagentLabel(subagent), state: subagent.state, model: subagent.model, requestedModel: subagent.request.model, tools: subagent.tools || [], toolDefinitions: subagent.toolDefinitions || [], skills: subagent.request.skills || [], extensions: subagent.request.extensions || [], contextFiles: subagent.request.contextFiles || [], prompt: subagent.request.prompt, systemPrompt: subagent.attempt?.setup?.systemPrompt || subagent.request.prompt, attempts: subagent.attempts || 0, mode: subagent.mode, cwd: subagent.cwd, worktree: subagent.worktree, output: subagent.output || subagentOutputFallback(subagent), outcome: subagent.result !== undefined ? { kind: "result", value: subagent.result } : subagent.failure !== undefined ? { kind: "failure", value: subagent.failure } : subagent.attempt?.error !== undefined ? { kind: "failure", value: subagent.attempt.error } : undefined, finishedAt: subagent.finishedAt, attemptDetails: subagent.attempt ? [subagent.attempt] : [] }; }
755
755
  function subagentOutputFallback(subagent) { if (subagent.result !== undefined) return { status: "available", value: subagent.result, bytes: 0 }; if (subagent.failure !== undefined) return { status: "failed", code: subagent.failure.code || "AGENT_FAILED", message: subagent.failure.message || "Subagent failed" }; if (subagent.state === "running") return { status: "pending" }; if (subagent.state === "completed") return { status: "unavailable" }; if (subagent.state === "stopped") return { status: "cancelled", code: subagent.error?.code || "CANCELLED", message: subagent.error?.message || "Subagent stopped" }; if (subagent.state === "failed") return { status: "failed", code: subagent.error?.code || "AGENT_FAILED", message: subagent.error?.message || "Subagent failed" }; return { status: "unavailable" }; }
756
756
  function renderAgentStats(found, agent, isSubagent) {
757
757
  const offline = !found.publisher.connected; const stateText = agent.state || "—"; const value = isSubagent ? subagentAccounting(found.record) : accounting(agent);
@@ -761,7 +761,7 @@
761
761
  return `${glyph(stateText)} <button type="button" class="btn-alt agent-details" data-agent-details="1">Agent details</button> ${fmtCost(value.cost)} · ${fmtTokens(value.input + value.output)} · runtime ${clock}${controls ? `<span class="actions">${controls}</span>` : ""}${isSubagent && offline ? '<span class="offline">publisher offline — actions disabled</span>' : ""}`;
762
762
  }
763
763
  function renderWorkflowArguments(record, open = false, key = "") { const args = record.snapshot?.args; const incomplete = record.snapshotArgsTruncated === true; if (args === null || args === undefined && !incomplete) return ""; const status = incomplete ? ' <span class="ins-sub">incomplete</span>' : ""; const body = args === undefined ? '<div class="hint">Arguments unavailable in the live transport.</div>' : `<div class="code args">${highlightCode(json(args), "json")}</div>`; const warning = incomplete ? '<div class="workflow-args-warning">Arguments were truncated by the live transport; this value is incomplete.</div>' : ""; return `<details class="workflow-args"${open ? " open" : ""} data-workflow-args="${esc(key)}"><summary class="sec">ARGUMENTS${status}</summary>${body}${warning}</details>`; }
764
- function renderDossier(publisher, record) { const offline = !publisher.connected; const issues = record.run.agents.filter((agent) => agent.state === "failed").map((agent) => `<div class="pending issue"><div class="ttl">${glyph(agent.state)} ${esc(agent.label || agent.name)} needs retry</div><div class="ins-sub">${esc(agent.attemptDetails?.at(-1)?.error?.message || record.run.error?.message || "agent failed")}</div><div class="actions">${actionButton("Retry", "retry", offline)}</div>${offline ? '<div class="offline">publisher offline — actions disabled</div>' : ""}</div>`).join(""); const pending = issues + (record.awaiting || []).map((item) => `<div class="pending" data-name="${esc(item.name)}"><div class="ttl">${glyph("awaiting_input")} checkpoint ${esc(item.name)}</div><div class="ins-sub">${esc(item.prompt)}</div><div class="actions">${actionButton("Approve", "checkpoint-approve", offline)}${actionButton("Reject", "checkpoint-reject", offline)}</div>${offline ? '<div class="offline">publisher offline — actions disabled</div>' : ""}</div>`).join(""); const run = record.run; const controls = (run.state === "running" || run.state === "awaiting_input" ? `${actionButton("Pause", "pause", offline)}${actionButton("Abort run", "stop", offline)}` : run.state === "pausing" ? `${actionButton("Cancel pause", "resume", offline)}${actionButton("Abort run", "stop", offline)}` : run.state === "paused" || run.state === "interrupted" || run.state === "budget_exhausted" ? `${actionButton("Resume", "resume", offline)}${actionButton("Abort run", "stop", offline)}` : "") + (window.__PIEWF_STATIC__ ? "" : actionButton("Share", "share", offline)); const rows = run.agents.map((agent) => `<tr data-agent="${esc(agent.id)}"><td>${glyph(agent.state)}</td><td><b>${esc(agent.label || agent.name)}</b><div class="ins-sub">${esc(agent.role || "agent")}</div></td><td class="num">${fmtTokens(accounting(agent).input + accounting(agent).output)}</td><td class="num">${fmtCost(accounting(agent).cost)}</td></tr>`).join(""); const script = record.snapshot?.script ? `<div class="sec">SCRIPT <span class="ins-sub">click to expand</span></div><div class="code script" data-script="1">${highlightCode(record.snapshot.script, "javascript")}</div>` : ""; const argumentsSection = renderWorkflowArguments(record, state.workflowArgumentsOpen.has(state.currentRun), state.currentRun); return `<div class="ins-head" data-run-panel="1">${glyph(run.state)}<b>${esc(run.workflowName)}</b><span class="ins-sub">${esc(run.state)}</span></div><div class="ins-body">${pending ? '<div class="sec">NEEDS ATTENTION</div>' + pending : ""}<div class="sec">STATE</div><div class="kv"><div class="k">Phase</div><div>${esc(run.phase || "—")}</div><div class="k">Active</div><div>${run.agents.filter((agent) => agent.state === "running").length} running · ${run.agents.filter((agent) => agent.state === "queued").length} queued · ${run.agents.filter((agent) => agent.state === "failed").length} failed</div><div class="k">Outcome</div><div>${esc(run.error ? `${run.error.code}: ${run.error.message}` : run.state === "completed" ? "completed" : attention(record) ? `${attention(record)} agent(s) need a human` : "—")}</div><div class="k">Cost</div><div>${fmtCost(runCost(record))}${run.budget?.costUsd?.hard !== undefined ? ` / ${fmtCost(run.budget.costUsd.hard)} hard` : ""}</div><div class="k">Tokens</div><div>${fmtTokens(runTokens(record))}${run.budget?.tokens?.hard !== undefined ? ` / ${fmtTokens(run.budget.tokens.hard)} hard` : ""}</div><div class="k">Started</div><div>${fmtClock(startOf(record))}</div><div class="k">Runtime</div><div data-clock-kind="runtime" data-clock-start="${startOf(record).getTime()}" data-clock-end="${run.state === "running" ? "live" : endOf(record)}"></div></div><div class="actions">${controls}</div>${state.shareUrls[run.id] ? `<div class="share-url"><input readonly value="${esc(state.shareUrls[run.id])}" /><button class="btn-alt" data-copy-share="${esc(state.shareUrls[run.id])}">Copy</button></div><div class="ins-sub">secret gist: anyone with the link can read it</div>` : ""}${script}${argumentsSection}<div class="sec">AGENTS</div><table class="acct"><thead><tr><th></th><th>agent</th><th class="num">tok</th><th class="num">cost</th></tr></thead><tbody>${rows}</tbody></table></div>`; }
764
+ function renderDossier(publisher, record) { const offline = !publisher.connected; const issues = record.run.agents.filter((agent) => agent.state === "failed").map((agent) => `<div class="pending issue"><div class="ttl">${glyph(agent.state)} ${esc(agent.label || agent.name)} needs retry</div><div class="ins-sub">${esc(agent.attemptDetails?.at(-1)?.error?.message || record.run.error?.message || "agent failed")}</div><div class="actions">${actionButton("Retry", "retry", offline)}</div>${offline ? '<div class="offline">publisher offline — actions disabled</div>' : ""}</div>`).join(""); const pending = issues + (record.awaiting || []).map((item) => `<div class="pending" data-name="${esc(item.name)}"><div class="ttl">${glyph("awaiting_input")} checkpoint ${esc(item.name)}</div><div class="ins-sub">${esc(item.prompt)}</div><div class="actions">${actionButton("Approve", "checkpoint-approve", offline)}${actionButton("Reject", "checkpoint-reject", offline)}</div>${offline ? '<div class="offline">publisher offline — actions disabled</div>' : ""}</div>`).join(""); const run = record.run; const controls = (run.state === "running" || run.state === "awaiting_input" ? `${actionButton("Pause", "pause", offline)}${actionButton("Abort run", "stop", offline)}` : run.state === "pausing" ? `${actionButton("Cancel pause", "resume", offline)}${actionButton("Abort run", "stop", offline)}` : run.state === "paused" || run.state === "interrupted" || run.state === "budget_exhausted" ? `${actionButton("Resume", "resume", offline)}${actionButton("Abort run", "stop", offline)}` : "") + (window.__PIEWF_STATIC__ ? "" : actionButton("Share", "share", offline)); const rows = run.agents.map((agent) => `<tr data-agent="${esc(agent.id)}"><td>${glyph(agent.state)}</td><td><b>${esc(agent.label || agent.name)}</b></td><td class="num">${fmtTokens(accounting(agent).input + accounting(agent).output)}</td><td class="num">${fmtCost(accounting(agent).cost)}</td></tr>`).join(""); const script = record.snapshot?.script ? `<div class="sec">SCRIPT <span class="ins-sub">click to expand</span></div><div class="code script" data-script="1">${highlightCode(record.snapshot.script, "javascript")}</div>` : ""; const argumentsSection = renderWorkflowArguments(record, state.workflowArgumentsOpen.has(state.currentRun), state.currentRun); return `<div class="ins-head" data-run-panel="1">${glyph(run.state)}<b>${esc(run.workflowName)}</b><span class="ins-sub">${esc(run.state)}</span></div><div class="ins-body">${pending ? '<div class="sec">NEEDS ATTENTION</div>' + pending : ""}<div class="sec">STATE</div><div class="kv"><div class="k">Phase</div><div>${esc(run.phase || "—")}</div><div class="k">Active</div><div>${run.agents.filter((agent) => agent.state === "running").length} running · ${run.agents.filter((agent) => agent.state === "queued").length} queued · ${run.agents.filter((agent) => agent.state === "failed").length} failed</div><div class="k">Outcome</div><div>${esc(run.error ? `${run.error.code}: ${run.error.message}` : run.state === "completed" ? "completed" : attention(record) ? `${attention(record)} agent(s) need a human` : "—")}</div><div class="k">Cost</div><div>${fmtCost(runCost(record))}${run.budget?.costUsd?.hard !== undefined ? ` / ${fmtCost(run.budget.costUsd.hard)} hard` : ""}</div><div class="k">Tokens</div><div>${fmtTokens(runTokens(record))}${run.budget?.tokens?.hard !== undefined ? ` / ${fmtTokens(run.budget.tokens.hard)} hard` : ""}</div><div class="k">Started</div><div>${fmtClock(startOf(record))}</div><div class="k">Runtime</div><div data-clock-kind="runtime" data-clock-start="${startOf(record).getTime()}" data-clock-end="${run.state === "running" ? "live" : endOf(record)}"></div></div><div class="actions">${controls}</div>${state.shareUrls[run.id] ? `<div class="share-url"><input readonly value="${esc(state.shareUrls[run.id])}" /><button class="btn-alt" data-copy-share="${esc(state.shareUrls[run.id])}">Copy</button></div><div class="ins-sub">secret gist: anyone with the link can read it</div>` : ""}${script}${argumentsSection}<div class="sec">AGENTS</div><table class="acct"><thead><tr><th></th><th>agent</th><th class="num">tok</th><th class="num">cost</th></tr></thead><tbody>${rows}</tbody></table></div>`; }
765
765
  function renderRunLayout() { const ganttCollapsed = state.runLayout.ganttCollapsed; const splitSwim = $("split-swim"); $("swim").classList.toggle("hidden", ganttCollapsed); splitSwim.classList.toggle("hidden", ganttCollapsed); const heading = $("toggle-gantt"); heading.textContent = `${ganttCollapsed ? "▸" : "▾"} GANTT`; heading.setAttribute("aria-expanded", String(!ganttCollapsed)); const swimHeight = Math.min(state.runLayout.swimHeight, maxSwimHeight()); document.documentElement.style.setProperty("--swim", `${swimHeight}px`); }
766
766
  function clampSwimHeight() { renderRunLayout(); }
767
767
  function renderHome() { const homePublisher = livePublishers().find((publisher) => publisher.id === state.currentPub); const hasSubagents = Boolean(homePublisher?.subagents?.length); $("canvas-pub").textContent = homePublisher ? homePublisher.id.slice(0, 8) : "—"; $("canvas-run").textContent = hasSubagents ? "SUBAGENTS" : "—"; renderedRunHtml.clear(); ganttOrder = ""; patch($("swim-content"), hasSubagents ? renderSubagentGantt(homePublisher) : ""); $("swim").dataset.timelineHasTime = hasSubagents ? "true" : "false"; $("swim").dataset.timelineRunning = String(Boolean(hasSubagents && homePublisher.subagents.some((subagent) => subagent.state === "running"))); if (hasSubagents) { const subagents = homePublisher.subagents || []; const start = Math.min(...subagents.map((subagent) => subagentStartOf(subagent))); const end = Math.max(start + 1, ...subagents.map((subagent) => subagentEndOf(subagent))); $("swim").dataset.timelineStart = String(start); $("swim").dataset.timelineSpan = String(Math.max(1, end - start)); $("swim").querySelector(":scope > .now")?.classList.remove("hidden"); } else { $("swim").querySelector(":scope > .now")?.classList.add("hidden"); } patch($("list"), hasSubagents ? '<div class="hint">Select a subagent to inspect its transcript.</div>' : ""); patch($("run-stats"), hasSubagents ? `${homePublisher.subagents.length} subagents` : ""); $("run-crumb").textContent = "—"; $("agent-crumb").textContent = "—"; patch($("agent-ticks"), ""); $("agent-timeline").dataset.timelineHasTime = "false"; $("agent-timeline").querySelector(":scope > .now")?.classList.add("hidden"); $("agent-timeline").querySelector(":scope > .brush")?.classList.add("hidden"); patch($("events"), ""); $("q").value = ""; $("reset-agent-range").classList.add("hidden"); patch($("insp"), hasSubagents ? '<div class="ins-body hint">Select a subagent.</div>' : '<div class="ins-body hint">Select a run.</div>'); state.inspSig = null; renderRunLayout(); }
@@ -786,9 +786,9 @@
786
786
  const { skillFilters, skillResolved, extensionFilters, extensionResolved } = sessionResources(agent);
787
787
  const attempt = agent.attemptDetails?.at(-1);
788
788
  const attempts = (agent.attemptDetails || []).map((detail) => ({ attempt: detail.attempt, transport: detail.transport, cwd: detail.setup?.cwd, model: detail.setup?.model, tools: detail.setup?.tools, ...(detail.error ? { error: detail.error } : {}) }));
789
- const environment = { role: agent.role, requestedModel: agent.requestedModel, model: agent.model, cwd: agent.cwd || attempt?.setup?.cwd || selected()?.record?.run?.cwd, mode: agent.mode, contextFiles: agent.contextFiles, worktree: agent.worktree || (agent.worktreeOwner ? { owner: agent.worktreeOwner } : undefined), structuralPath: agent.structuralPath, parentId: agent.parentId, parentBreadcrumb: agent.parentBreadcrumb, handle: agent.handle, turn: agent.turn, continuity: agent.continuity, state: agent.state, attempts: agent.attempts, attempt: attempt?.attempt, attemptDetails: attempts };
789
+ const environment = { requestedModel: agent.requestedModel, model: agent.model, cwd: agent.cwd || attempt?.setup?.cwd || selected()?.record?.run?.cwd, mode: agent.mode, contextFiles: agent.contextFiles, worktree: agent.worktree || (agent.worktreeOwner ? { owner: agent.worktreeOwner } : undefined), structuralPath: agent.structuralPath, parentId: agent.parentId, parentBreadcrumb: agent.parentBreadcrumb, handle: agent.handle, turn: agent.turn, continuity: agent.continuity, state: agent.state, attempts: agent.attempts, attempt: attempt?.attempt, attemptDetails: attempts };
790
790
  const panes = {
791
- prompt: () => `<div class="kv"><div class="k">Tokens</div><div>est. ${estTokens(prompt)}</div></div><div class="md">${promptAvailable ? sanitizeMarkdown(marked.parse(prompt, { mangle: false, headerIds: false })) : "Prompt unavailable"}</div>`,
791
+ prompt: () => `<div class="kv"><div class="k">Tokens</div><div>est. ${estTokens(prompt)}</div></div><div class="code">${promptAvailable ? esc(prompt) : "Prompt unavailable"}</div>`,
792
792
  tools: () => (agent.tools || []).map((tool) => { const description = toolDescriptionOf(agent, tool); return `<div class="tool-item"><b>${esc(tool)}</b>${description ? `<span>${esc(description)}</span>` : ""}</div>`; }).join("") || '<div class="hint">No tools recorded.</div>',
793
793
  skills: () => `<div class="sec">FILTERS</div>${nameList(skillFilters)}<div class="sec">RESOLVED</div>${resourceList(skillResolved)}`,
794
794
  extensions: () => `<div class="sec">FILTERS</div>${nameList(extensionFilters)}<div class="sec">RESOLVED</div>${resourceList(extensionResolved)}`,
@@ -797,7 +797,7 @@
797
797
  };
798
798
  patch($("sys-pane"), (panes[state.sysPane] || panes.prompt)()); $("sys-tabs").querySelectorAll("span").forEach((tab) => tab.classList.toggle("on", tab.dataset.pane === state.sysPane));
799
799
  }
800
- function renderSystemPromptPane(agent, entry) { const prompt = typeof entry?.prompt === "string" ? entry.prompt : typeof agent.systemPrompt === "string" ? agent.systemPrompt : ""; const available = typeof entry?.prompt === "string" || typeof agent.systemPrompt === "string"; patch($("sys-pane"), `<div class="kv"><div class="k">Tokens</div><div>est. ${estTokens(prompt)}</div></div><div class="md">${available ? sanitizeMarkdown(marked.parse(prompt, { mangle: false, headerIds: false })) : "System prompt unavailable"}</div>`); }
800
+ function renderSystemPromptPane(agent, entry) { const prompt = typeof entry?.prompt === "string" ? entry.prompt : typeof agent.systemPrompt === "string" ? agent.systemPrompt : ""; const available = typeof entry?.prompt === "string" || typeof agent.systemPrompt === "string"; patch($("sys-pane"), `<div class="kv"><div class="k">Tokens</div><div>est. ${estTokens(prompt)}</div></div><div class="code">${available ? esc(prompt) : "System prompt unavailable"}</div>`); }
801
801
  function renderAssistantToolCalls(entry, entries) { const toolCalls = toolCallsOf(entry); return toolCalls.length ? `<div class="assistant-tools">${toolCalls.map((call) => `<div class="assistant-tool"><span class="pill tool">${esc(call.name)}</span><div class="code">${highlightCode(json(toolArgsOf(call, entries)), "json")}</div></div>`).join("")}</div>` : ""; }
802
802
  function renderAgentDetails(agent, root = $("insp")) { patch(root, `<div class="ins-head"><span class="pill context">AGENT</span><b class="tool-name">${esc(agent.label || agent.name)}</b><span class="ins-sub">${esc(agent.state || "")}</span></div><div class="itabs" id="sys-tabs"><span data-pane="prompt">Prompt</span><span data-pane="tools">Tools</span><span data-pane="skills">Skills</span><span data-pane="extensions">Extensions</span><span data-pane="env">Environment</span><span data-pane="output">Output</span></div><div class="ins-body"><div id="sys-pane"></div></div>`); renderSystemPane(agent); }
803
803
  function renderInspector(detail, agent, entries, root = $("insp")) { if (!detail) { patch(root, '<div class="ins-body hint">Select an event.</div>'); return; } if (detail.kind === "tool" || detail.kind === "skill") { patch(root, `<div class="ins-head"><span class="pill ${detail.kind}">${eventLabel(detail.kind)}</span><b class="tool-name">${esc(toolNameOf(detail.entry))}</b><span class="ins-sub">${esc(detail.entry.timestamp || "")}</span></div><div class="itabs" id="tool-tabs"><span data-pane="summary">Summary</span><span data-pane="payload">Input</span><span data-pane="result">Output</span><span data-pane="schema">Schema</span><span data-pane="timing">Timing</span></div><div class="ins-body"><div id="tool-pane"></div></div>`); renderToolPane(detail, entries); return; } if (detail.kind === "system") { const prompt = typeof detail.entry.prompt === "string" ? detail.entry.prompt : agent.systemPrompt; patch(root, `<div class="ins-head"><span class="pill system">SYSTEM</span><span class="ins-sub">est. ${estTokens(prompt || "")} tok</span></div><div class="ins-body"><div id="sys-pane"></div></div>`); renderSystemPromptPane(agent, detail.entry); return; } const message = detail.entry.message || detail.entry; const body = detail.kind === "assistant" ? assistantBodyText(message.content ?? detail.entry.content) : contentText(message.content ?? detail.entry.content); const tokenRows = messageTokenRows(detail.kind, message, body); const bodyHtml = detail.kind === "assistant" ? sanitizeMarkdown(marked.parse(body)) : esc(body); const tokenHtml = tokenRows ? `<div class="kv">${tokenRows}</div>` : ""; const assistantTools = detail.kind === "assistant" ? renderAssistantToolCalls(detail.entry, entries) : ""; patch(root, `<div class="ins-head"><span class="pill ${detail.kind}">${eventLabel(detail.kind)}</span></div><div class="ins-body">${tokenHtml}<div class="${detail.kind === "assistant" ? "md" : "code"}">${bodyHtml}</div>${assistantTools}</div>`); }
@@ -1,15 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { mkdir, open, readFile, rm } from "node:fs/promises";
3
+ import { mkdir, readFile, rm } from "node:fs/promises";
4
4
  import { existsSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { clearTrajectoryHost, setTrajectoryHost, type TrajectoryHost, type TrajectoryPublisherProvider } from "../../src/trajectory-host-handle.js";
9
- import { processAlive } from "../../src/session-lease.js";
9
+ import { atomicJson } from "../../src/io.js";
10
10
  import { errorText, isNodeError, object, positiveInteger } from "../../src/utils.js";
11
11
  import { isTimingTranscriptEntry, isTrajectoryAction, isTrajectoryTarget, trajectoryActionError, TRAJECTORY_MAX_TRANSCRIPT_BYTES, type TrajectoryPublisherInput, type TrajectoryPublisherMetadata, type TrajectoryTranscriptRequest, type TrajectoryTranscriptResult } from "../../src/trajectory.js";
12
12
  import { shareTrajectoryRun } from "./export.js";
13
+ import { identityOf, processConfirmed, processLive, processStart, recordStartupServer, releaseStartupMutex, tryAcquireStartupMutex } from "./startup-mutex.js";
13
14
 
14
15
  const DEFAULT_TRAJECTORY_PORT = 7432;
15
16
  const TRAJECTORY_IDLE_EXIT_MS = 5 * 60 * 1000;
@@ -24,7 +25,7 @@ type TrajectoryPublisherClient = {
24
25
 
25
26
  type TrajectoryPublisherConstructor = new (url: string) => TrajectoryPublisherClient;
26
27
 
27
- type TrajectoryLock = { pid: number; port: number; fingerprint?: string; startedAt?: number };
28
+ type TrajectoryLock = { pid: number; port: number; fingerprint?: string; startedAt?: number; start?: string };
28
29
  export type TrajectoryController = {
29
30
  open(input: TrajectoryPublisherInput): Promise<{ port: number }>;
30
31
  close(): Promise<void>;
@@ -65,111 +66,134 @@ function signalProcess(pid: number, signal: NodeJS.Signals): void {
65
66
  if (!isNodeError(error, "ESRCH")) throw error;
66
67
  }
67
68
  }
68
- async function stopStaleServer(lock: TrajectoryLock): Promise<void> {
69
- // During startup, the lock can name the current Pi process rather than the detached server.
70
- if (lock.pid === process.pid) return;
71
- // NOTE: after a reboot the pid can belong to an unrelated process; startedAt (checked against /proc ctime) is the only proof it is still ours.
72
- if (!await processAlive(lock.pid, lock.startedAt)) return;
73
- signalProcess(lock.pid, "SIGTERM");
69
+ // Called right after /health named this pid; its start, read now, tells a later reuse of the pid apart before SIGKILL.
70
+ // Returns only once the server is gone: a server that may still serve keeps its lock and port, so nothing replaces it.
71
+ async function stopStaleServer(pid: number, port: number): Promise<void> {
72
+ const server = identityOf(pid);
73
+ if (!processLive(server)) return;
74
+ signalProcess(pid, "SIGTERM");
74
75
  const deadline = Date.now() + 3000;
75
76
  while (Date.now() < deadline) {
76
- if (!await serverHealthy(lock.port)) break;
77
+ if (!await serverHealthy(port)) break;
77
78
  const remaining = deadline - Date.now();
78
79
  if (remaining <= 0) break;
79
80
  await delay(Math.min(50, remaining));
80
81
  }
81
- if (await processAlive(lock.pid, lock.startedAt)) signalProcess(lock.pid, "SIGKILL");
82
+ // Without a confirmed identity the pid may now be an unrelated process, so SIGTERM is never escalated.
83
+ if (processConfirmed(server)) signalProcess(pid, "SIGKILL");
84
+ for (let attempt = 0; attempt < 50 && processLive(server); attempt += 1) await delay(20);
85
+ if (processLive(server)) throw new Error(`Stale Trajectory server ${String(pid)} on port ${String(port)} did not stop; stop it and retry`);
82
86
  }
83
87
 
88
+ // Only a missing or malformed lock reads as absent; any other read error leaves its owner unknown, so it is thrown.
84
89
  async function readLock(path: string): Promise<TrajectoryLock | undefined> {
85
- try {
86
- const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
87
- if (!object(parsed) || !positiveInteger(parsed.pid) || !positiveInteger(parsed.port) || parsed.port > 65535) return undefined;
88
- const fingerprint = typeof parsed.fingerprint === "string" ? parsed.fingerprint : undefined;
89
- const startedAt = positiveInteger(parsed.startedAt) ? parsed.startedAt : undefined;
90
- return { pid: parsed.pid, port: parsed.port, ...(fingerprint === undefined ? {} : { fingerprint }), ...(startedAt === undefined ? {} : { startedAt }) };
91
- } catch (error) {
92
- if (isNodeError(error, "ENOENT")) return undefined;
93
- return undefined;
94
- }
90
+ let text: string;
91
+ try { text = await readFile(path, "utf8"); }
92
+ catch (error) { if (isNodeError(error, "ENOENT")) return undefined; throw error; }
93
+ let parsed: unknown;
94
+ try { parsed = JSON.parse(text); } catch { return undefined; }
95
+ if (!object(parsed) || !positiveInteger(parsed.pid) || !positiveInteger(parsed.port) || parsed.port > 65535) return undefined;
96
+ const fingerprint = typeof parsed.fingerprint === "string" ? parsed.fingerprint : undefined;
97
+ const startedAt = positiveInteger(parsed.startedAt) ? parsed.startedAt : undefined;
98
+ const start = typeof parsed.start === "string" ? parsed.start : undefined;
99
+ return { pid: parsed.pid, port: parsed.port, ...(fingerprint === undefined ? {} : { fingerprint }), ...(startedAt === undefined ? {} : { startedAt }), ...(start === undefined ? {} : { start }) };
95
100
  }
96
101
 
97
- // Only a server reporting this fingerprint counts: an older one still bound to the port would otherwise be adopted forever.
98
- async function waitForServer(port: number, fingerprint: string): Promise<void> {
99
- let foreign = false;
100
- for (let attempt = 0; attempt < 60; attempt += 1) {
102
+ // One holder runs at most two bounded health waits (60 × (300 + 50) ms each) and one stop (3 s, its final health check, and 1 s for its exit).
103
+ const STARTUP_MUTEX_WAIT_MS = 2 * 60 * (300 + 50) + 3_000 + 1_000 + 1_000;
104
+ async function acquireStartupMutex(lockPath: string): Promise<string> {
105
+ const deadline = Date.now() + STARTUP_MUTEX_WAIT_MS;
106
+ for (;;) {
107
+ const token = await tryAcquireStartupMutex(lockPath);
108
+ if (token !== undefined) return token;
109
+ if (Date.now() >= deadline) throw new Error("Another Pi is still starting Trajectory; retry");
110
+ // Contenders that saw each other both back off; a random delay keeps them from retrying in step.
111
+ await delay(25 + Math.floor(Math.random() * 50));
112
+ }
113
+ }
114
+ type IdentifiedHealth = ServerHealth & { pid: number; fingerprint: string };
115
+ // Only a Trajectory server reports a fingerprint; any other service answering /health is never signalled.
116
+ function isTrajectoryServer(health: ServerHealth | undefined): health is IdentifiedHealth { return health?.pid !== undefined && health.fingerprint !== undefined; }
117
+ // A lock proves which process serves its port only when that process reports the same identity.
118
+ function ownsLock(health: ServerHealth | undefined, lock: TrajectoryLock): health is IdentifiedHealth {
119
+ return isTrajectoryServer(health) && health.pid === lock.pid && (lock.startedAt === undefined || lock.startedAt === health.startedAt);
120
+ }
121
+ async function awaitHealth(port: number, accept: (health: ServerHealth) => boolean, stopped: () => boolean = () => false): Promise<ServerHealth | undefined> {
122
+ for (let attempt = 0; attempt < 60 && !stopped(); attempt += 1) {
101
123
  const health = await serverHealth(port);
102
- if (health?.fingerprint === fingerprint) return;
103
- foreign = health !== undefined;
124
+ if (health !== undefined && accept(health)) return health;
104
125
  await delay(50);
105
126
  }
106
- throw new Error(foreign ? `Trajectory port ${String(port)} is held by another Trajectory server; stop it and retry` : `Trajectory server did not start on port ${String(port)}`);
127
+ return undefined;
107
128
  }
108
- async function resolveExistingServer(lockPath: string, existing: TrajectoryLock, fingerprint: string): Promise<TrajectoryLock | undefined> {
109
- const health = await serverHealth(existing.port);
110
- if (health) {
111
- if (existing.fingerprint === fingerprint && health.fingerprint === fingerprint) return existing;
112
- await stopStaleServer(existing);
129
+
130
+ async function startServer(lockPath: string, serverPath: string, fingerprint: string, port: number, startupToken: string): Promise<{ port: number }> {
131
+ // NOTE: under a Bun-compiled pi binary process.execPath is the pi CLI, and Bun's node:http never writes the WebSocket 101 upgrade (oven-sh/bun#28157), so the server must run on a real node from PATH.
132
+ const child = spawn(process.versions.bun ? "node" : process.execPath, [serverPath, "--port", String(port), "--lock", lockPath, "--fingerprint", fingerprint, "--startup", startupToken], { detached: true, stdio: "ignore" });
133
+ const startup: { ended: boolean; error?: Error } = { ended: false };
134
+ const exited = new Promise<void>((resolve) => {
135
+ child.once("error", (error) => { startup.error = error; startup.ended = true; resolve(); });
136
+ child.once("exit", () => { startup.ended = true; resolve(); });
137
+ });
138
+ try {
139
+ if (child.pid !== undefined) recordStartupServer(lockPath, startupToken, child.pid);
140
+ const health = await awaitHealth(port, (candidate) => candidate.pid === child.pid && candidate.fingerprint === fingerprint, () => startup.ended);
141
+ if (startup.error !== undefined) throw startup.error;
142
+ // The server publishes its lock before it answers, so readiness is reported only once both agree.
143
+ const lock = health === undefined ? undefined : await readLock(lockPath);
144
+ // The detached server outlives this Pi only once it is ready; until then this Pi keeps waiting for its exit.
145
+ if (lock !== undefined && ownsLock(health, lock) && lock.port === port && lock.fingerprint === fingerprint) { child.unref(); return { port }; }
146
+ throw new Error(`Trajectory server did not start on port ${String(port)}`);
147
+ } catch (error) {
148
+ // A server outliving its startup could later publish over another owner's lock, so it is stopped before the mutex is released.
149
+ if (!startup.ended) { child.kill("SIGKILL"); await exited; }
150
+ if ((await readLock(lockPath))?.pid === child.pid) await rm(lockPath, { force: true });
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ // Runs under the startup mutex, so no other Pi or idle server changes the lock meanwhile.
156
+ async function startOrReuseServer(lockPath: string, serverPath: string, fingerprint: string, configuredPort: number, startupToken: string): Promise<{ port: number }> {
157
+ const existing = await readLock(lockPath);
158
+ if (existing) {
159
+ let health = await serverHealth(existing.port);
160
+ // A live lock owner can be a server still starting or one too loaded to answer in time.
161
+ if (health === undefined && processLive(existing)) health = await awaitHealth(existing.port, (candidate) => candidate.pid === existing.pid);
162
+ const lock = health === undefined ? existing : await readLock(lockPath) ?? existing;
163
+ if (ownsLock(health, lock)) {
164
+ if (lock.fingerprint === fingerprint && health.fingerprint === fingerprint) return { port: lock.port };
165
+ await stopStaleServer(health.pid, lock.port);
166
+ } else if (!isTrajectoryServer(health) && lock.pid !== process.pid && processLive(lock)) {
167
+ // Unidentified is not dead: a suspended or overloaded owner can resume serving, so its lock stays and no second server is started beside it.
168
+ // Only a lock naming this Pi, left by an older release's startup reservation, is known not to name a server.
169
+ throw new Error(health === undefined ? `Trajectory server ${String(lock.pid)} named by ${lockPath} is alive but not answering on port ${String(lock.port)}; retry once it answers, or remove the lock if that process is not a Trajectory server` : `Trajectory port ${String(lock.port)} is held by another Trajectory server; stop it and retry`);
170
+ }
171
+ // Any other owner is dead, or another Trajectory server holds its port, so it is never signalled.
113
172
  await rm(lockPath, { force: true });
114
- return undefined;
115
173
  }
116
- if (await processAlive(existing.pid, existing.startedAt)) {
117
- try {
118
- await waitForServer(existing.port, fingerprint);
119
- return existing;
120
- } catch {
121
- // A live lock can still name the attaching process during startup; after the bounded wait, replace the unrecoverable startup owner and retry normally.
122
- await stopStaleServer(existing);
123
- await rm(lockPath, { force: true });
124
- return undefined;
174
+ const occupant = await serverHealth(configuredPort);
175
+ if (occupant !== undefined) {
176
+ if (!isTrajectoryServer(occupant)) throw new Error(`Trajectory port ${String(configuredPort)} is held by another Trajectory server; stop it and retry`);
177
+ if (occupant.fingerprint === fingerprint && occupant.startedAt !== undefined) {
178
+ // This server lost its lock; restoring it keeps every healthy server named by the lock.
179
+ //NOTE: its start is read just after /health named the pid; a reuse of that pid in between is not excluded.
180
+ await atomicJson(lockPath, { pid: occupant.pid, port: configuredPort, fingerprint, startedAt: occupant.startedAt, start: processStart(occupant.pid) });
181
+ return { port: configuredPort };
125
182
  }
183
+ await stopStaleServer(occupant.pid, configuredPort);
126
184
  }
127
- await rm(lockPath, { force: true });
128
- return undefined;
185
+ return startServer(lockPath, serverPath, fingerprint, configuredPort, startupToken);
129
186
  }
130
187
 
188
+ //NOTE: Pi releases without the startup mutex do not take it, so a concurrent start by one of them is not excluded.
131
189
  async function ensureTrajectoryServer(agentDir: string, configuredPort: number): Promise<{ port: number }> {
132
190
  const lockPath = trajectoryLockPath(agentDir);
133
191
  const serverPath = trajectoryServerPath();
134
192
  const fingerprint = await trajectoryFingerprint(serverPath);
135
193
  await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
136
- const existing = await readLock(lockPath);
137
- if (existing) {
138
- const reused = await resolveExistingServer(lockPath, existing, fingerprint);
139
- if (reused) return reused;
140
- }
141
- let lockHandle: Awaited<ReturnType<typeof open>> | undefined;
142
- try {
143
- lockHandle = await open(lockPath, "wx", 0o600);
144
- await lockHandle.writeFile(`${JSON.stringify({ pid: process.pid, port: configuredPort, fingerprint, startedAt: Date.now() })}\n`, "utf8");
145
- } catch (error) {
146
- if (isNodeError(error, "EEXIST")) {
147
- const raced = await readLock(lockPath);
148
- if (raced) {
149
- const reused = await resolveExistingServer(lockPath, raced, fingerprint);
150
- if (reused) return reused;
151
- }
152
- await rm(lockPath, { force: true });
153
- lockHandle = await open(lockPath, "wx", 0o600);
154
- await lockHandle.writeFile(`${JSON.stringify({ pid: process.pid, port: configuredPort, fingerprint, startedAt: Date.now() })}\n`, "utf8");
155
- } else {
156
- throw error;
157
- }
158
- } finally { await lockHandle?.close(); }
159
- // A server can answer on the port without owning the lock, e.g. one whose lock was lost; it reports its pid, so replace it before spawning.
160
- const occupant = await serverHealth(configuredPort);
161
- if (occupant?.pid !== undefined && occupant.fingerprint !== fingerprint) await stopStaleServer({ pid: occupant.pid, port: configuredPort, ...(occupant.startedAt === undefined ? {} : { startedAt: occupant.startedAt }) });
162
- try {
163
- // NOTE: under a Bun-compiled pi binary process.execPath is the pi CLI, and Bun's node:http never writes the WebSocket 101 upgrade (oven-sh/bun#28157), so the server must run on a real node from PATH.
164
- const child = spawn(process.versions.bun ? "node" : process.execPath, [serverPath, "--port", String(configuredPort), "--lock", lockPath, "--fingerprint", fingerprint], { detached: true, stdio: "ignore" });
165
- const startupError = new Promise<never>((_resolve, reject) => { child.once("error", reject); });
166
- child.unref();
167
- await Promise.race([waitForServer(configuredPort, fingerprint), startupError]);
168
- return { port: configuredPort };
169
- } catch (error) {
170
- await rm(lockPath, { force: true });
171
- throw error;
172
- }
194
+ const startupToken = await acquireStartupMutex(lockPath);
195
+ try { return await startOrReuseServer(lockPath, serverPath, fingerprint, configuredPort, startupToken); }
196
+ finally { await releaseStartupMutex(lockPath, startupToken); }
173
197
  }
174
198
 
175
199
  function trajectoryWebSocket(): TrajectoryPublisherConstructor | undefined {
@@ -1,10 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
- import { readFile, rm, writeFile } from "node:fs/promises";
3
+ import { readFile, rm } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { URL } from "node:url";
6
6
  import { isTrajectoryAction, isTrajectoryTarget, trajectoryActionError } from "../../src/trajectory-contracts.js";
7
+ import { atomicWriteFile } from "../../src/io.js";
7
8
  import { sameFilesystemPath } from "../../src/paths.js";
9
+ import { isNodeError, object } from "../../src/utils.js";
10
+ import { processStart, readStartupHolder, releaseStartupMutex, tryAcquireStartupMutex } from "./startup-mutex.js";
8
11
  import { TOOL_TIMING_ENTRY_TYPE } from "../../src/tool-timing.js";
9
12
  const TRAJECTORY_IDLE_EXIT_MS = 5 * 60 * 1000;
10
13
 
@@ -270,17 +273,31 @@ export function createTrajectoryServer(port: number, lockPath: string, options:
270
273
  if (!requests.size) pending.delete(browser);
271
274
  emit(browser, errorResponse(requestId, request, "failed", error));
272
275
  };
276
+ const busy = () => closed || [...publishers.values()].some(({ value }) => value.connected === true);
273
277
  const scheduleIdleExit = () => {
274
- if (closed || [...publishers.values()].some(({ value }) => value.connected === true) || idleTimer !== undefined) return;
278
+ if (busy() || idleTimer !== undefined) return;
275
279
  idleTimer = setTimeout(() => {
276
- server.closeAllConnections();
277
- for (const client of clients) client.socket.destroy();
278
- server.close(() => {
279
- void rm(lockPath, { force: true }).then(() => { process.exit(0); }).catch(() => { process.exit(1); });
280
- });
280
+ idleTimer = undefined;
281
+ void tryAcquireStartupMutex(lockPath).then(async (token) => {
282
+ // A Pi holding the startup mutex may be about to reuse this server, so the exit waits for another idle period.
283
+ if (token === undefined) { scheduleIdleExit(); return; }
284
+ if (busy() || idleTimer !== undefined) { await releaseStartupMutex(lockPath, token); return; }
285
+ server.closeAllConnections();
286
+ for (const client of clients) client.socket.destroy();
287
+ server.close(() => {
288
+ void removeOwnLock().then(() => releaseStartupMutex(lockPath, token)).then(() => { process.exit(0); }).catch(() => { process.exit(1); });
289
+ });
290
+ }).catch(() => { process.exit(1); });
281
291
  }, TRAJECTORY_IDLE_EXIT_MS);
282
292
  idleTimer.unref();
283
293
  };
294
+ // Runs under the startup mutex; a Pi may have replaced the lock while this server was idle.
295
+ const removeOwnLock = async (): Promise<void> => {
296
+ let current: unknown;
297
+ try { current = JSON.parse(await readFile(lockPath, "utf8")); }
298
+ catch (error) { if (isNodeError(error, "ENOENT")) return; throw error; }
299
+ if (object(current) && current.pid === process.pid && current.startedAt === startedAt) await rm(lockPath, { force: true });
300
+ };
284
301
  const cancelIdleExit = () => {
285
302
  if (idleTimer !== undefined) { clearTimeout(idleTimer); idleTimer = undefined; }
286
303
  };
@@ -528,18 +545,37 @@ export function createTrajectoryServer(port: number, lockPath: string, options:
528
545
  // A dropped tab only sends FIN, and an upgraded socket stays half-open until this side closes it.
529
546
  socket.on("end", () => { socket.destroy(); });
530
547
  });
531
- server.once("listening", () => { startedAt = Date.now(); void writeFile(lockPath, `${JSON.stringify({ pid: process.pid, port, fingerprint: serverFingerprint, startedAt })}\n`, { mode: 0o600 }).catch(() => { process.exitCode = 1; }); scheduleIdleExit(); });
548
+ // Listening is emitted before the event loop accepts a connection, so the synchronous atomic write publishes the lock before /health can answer.
549
+ server.once("listening", () => {
550
+ startedAt = Date.now();
551
+ try { atomicWriteFile(lockPath, `${JSON.stringify({ pid: process.pid, port, fingerprint: serverFingerprint, startedAt, start: processStart(process.pid) })}\n`, true); }
552
+ catch (error) { server.close(); server.emit("error", error); return; }
553
+ scheduleIdleExit();
554
+ });
532
555
  server.on("close", () => { closed = true; if (idleTimer !== undefined) { clearTimeout(idleTimer); idleTimer = undefined; } });
533
556
  return server;
534
557
  }
535
558
 
559
+ // The spawning Pi records this server in its startup holder, which then stays held until this server publishes or exits; without that record the startup was abandoned.
560
+ async function awaitStartupRecord(lockPath: string, token: string): Promise<void> {
561
+ for (let attempt = 0; attempt < 60; attempt += 1) {
562
+ const holder = readStartupHolder(lockPath, token);
563
+ if (holder === undefined) break;
564
+ if (holder.server?.pid === process.pid) return;
565
+ await new Promise((resolve) => setTimeout(resolve, 50));
566
+ }
567
+ throw new Error("Trajectory startup was abandoned before the server listened");
568
+ }
569
+
536
570
  async function main(): Promise<void> {
537
571
  const args = new Map<string, string>();
538
572
  for (let index = 2; index + 1 < process.argv.length; index += 2) args.set(process.argv[index] ?? "", process.argv[index + 1] ?? "");
539
573
  const port = Number(args.get("--port"));
540
574
  const lockPath = args.get("--lock");
541
575
  const fingerprint = args.get("--fingerprint");
576
+ const startupToken = args.get("--startup");
542
577
  if (!Number.isSafeInteger(port) || port < 1 || port > 65535 || !lockPath || !fingerprint) throw new Error("Invalid Trajectory server arguments");
578
+ if (startupToken !== undefined) await awaitStartupRecord(lockPath, startupToken);
543
579
  const server = createTrajectoryServer(port, lockPath, { fingerprint });
544
580
  await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", () => { resolve(); }); });
545
581
  }