@marcoscale98/pi-extensible-workflows 5.17.0-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.
- package/CHANGELOG.md +145 -0
- package/README.md +2 -2
- package/dist/src/agent-execution.d.ts +43 -10
- package/dist/src/agent-execution.js +442 -162
- package/dist/src/background-widget.d.ts +5 -1
- package/dist/src/background-widget.js +5 -10
- package/dist/src/decoders.d.ts +2 -3
- package/dist/src/decoders.js +59 -37
- package/dist/src/execution.d.ts +6 -2
- package/dist/src/execution.js +48 -7
- package/dist/src/host-delivery.js +5 -8
- package/dist/src/host-navigator.js +5 -2
- package/dist/src/host-phases.d.ts +1 -0
- package/dist/src/host-recovery.d.ts +11 -8
- package/dist/src/host-recovery.js +14 -22
- package/dist/src/host-runtime.js +56 -5
- package/dist/src/host-view.d.ts +6 -1
- package/dist/src/host-view.js +46 -20
- package/dist/src/host.d.ts +2 -2
- package/dist/src/host.js +246 -118
- package/dist/src/index.d.ts +1 -2
- package/dist/src/index.js +1985 -1690
- package/dist/src/index.js.map +3 -3
- package/dist/src/paths.d.ts +8 -5
- package/dist/src/paths.js +24 -13
- package/dist/src/persistence.d.ts +1 -0
- package/dist/src/persistence.js +1 -0
- package/dist/src/pi-runtime-runner.js +4 -1
- package/dist/src/registry.d.ts +9 -7
- package/dist/src/registry.js +29 -71
- package/dist/src/runtime/agent-runner.d.ts +2 -0
- package/dist/src/script-tools.d.ts +27 -0
- package/dist/src/script-tools.js +87 -0
- package/dist/src/session-lease.d.ts +1 -1
- package/dist/src/session-lease.js +45 -11
- package/dist/src/settings.d.ts +19 -3
- package/dist/src/settings.js +63 -50
- package/dist/src/store.d.ts +3 -1
- package/dist/src/store.js +17 -1
- package/dist/src/trajectory.d.ts +12 -3
- package/dist/src/trajectory.js +32 -11
- package/dist/src/types.d.ts +129 -45
- package/dist/src/types.js +5 -8
- package/dist/src/utils.d.ts +21 -1
- package/dist/src/utils.js +46 -6
- package/dist/src/validation.d.ts +14 -3
- package/dist/src/validation.js +143 -87
- package/dist/starter/index.js +8 -11
- package/dist/starter/index.js.map +1 -1
- package/dist/starter/review-loop.d.ts +0 -13
- package/dist/starter/review-loop.js +8 -9
- package/dist/subagents/index.js +904 -956
- package/dist/subagents/index.js.map +3 -3
- package/dist/subagents/src/contracts.d.ts +48 -31
- package/dist/subagents/src/contracts.js +20 -9
- package/dist/subagents/src/index.d.ts +1 -1
- package/dist/subagents/src/index.js +14 -8
- package/dist/subagents/src/manager.js +52 -73
- package/dist/subagents/src/navigator.js +3 -14
- package/dist/subagents/src/view.d.ts +1 -1
- package/dist/subagents/src/view.js +15 -68
- package/dist/subagents/src/worktree.js +1 -1
- package/dist/trajectory/assets/index.html +35 -13
- package/dist/trajectory/index.js +576 -932
- package/dist/trajectory/index.js.map +3 -3
- package/dist/trajectory/src/assets/index.html +35 -13
- package/dist/trajectory/src/index.js +134 -104
- package/dist/trajectory/src/server.js +268 -27
- package/dist/trajectory/src/server.js.map +3 -3
- package/dist/trajectory/src/startup-mutex.d.ts +17 -0
- package/dist/trajectory/src/startup-mutex.js +172 -0
- package/examples/workflow-extension-template/README.md +4 -7
- package/examples/workflow-extension-template/extension.test.mjs +4 -8
- package/examples/workflow-extension-template/index.js +0 -3
- package/package.json +10 -10
- package/skills/pi-extensible-workflows/SKILL.md +12 -8
- package/src/agent-execution.ts +412 -164
- package/src/background-widget.ts +5 -11
- package/src/decoders.ts +51 -37
- package/src/execution.ts +46 -9
- package/src/host-delivery.ts +5 -8
- package/src/host-navigator.ts +4 -2
- package/src/host-phases.ts +1 -1
- package/src/host-recovery.ts +23 -28
- package/src/host-runtime.ts +44 -6
- package/src/host-view.ts +41 -16
- package/src/host.ts +229 -142
- package/src/index.ts +1 -2
- package/src/paths.ts +18 -9
- package/src/persistence.ts +1 -0
- package/src/pi-runtime-runner.ts +3 -1
- package/src/registry.ts +28 -64
- package/src/runtime/agent-runner.ts +2 -0
- package/src/script-tools.ts +76 -0
- package/src/session-lease.ts +34 -6
- package/src/settings.ts +56 -50
- package/src/store.ts +18 -2
- package/src/trajectory.ts +32 -15
- package/src/types.ts +82 -34
- package/src/utils.ts +42 -5
- package/src/validation.ts +121 -80
- package/starter/index.ts +0 -1
- package/starter/review-loop.ts +8 -9
- package/starter/roles/developer.md +0 -1
- package/starter/roles/oracle.md +0 -1
- package/starter/roles/researcher.md +0 -1
- package/starter/roles/reviewer.md +0 -1
- package/starter/roles/scout.md +0 -1
- package/subagents/README.md +10 -16
- package/subagents/src/contracts.ts +21 -11
- package/subagents/src/index.ts +17 -8
- package/subagents/src/manager.ts +48 -128
- package/subagents/src/navigator.ts +4 -13
- package/subagents/src/view.ts +16 -63
- package/subagents/src/worktree.ts +1 -1
- package/trajectory/src/assets/index.html +35 -13
- package/trajectory/src/index.ts +112 -91
- package/trajectory/src/server.ts +46 -10
- package/trajectory/src/startup-mutex.ts +127 -0
- package/dist/src/roles.d.ts +0 -70
- package/dist/src/roles.js +0 -325
- package/dist/starter/roles/developer.md +0 -6
- package/dist/starter/roles/oracle.md +0 -18
- package/dist/starter/roles/researcher.md +0 -20
- package/dist/starter/roles/reviewer.md +0 -19
- package/dist/starter/roles/scout.md +0 -18
- package/examples/workflow-extension-template/roles/reviewer.md +0 -4
- package/src/roles.ts +0 -326
|
@@ -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)
|
|
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); }
|
|
@@ -157,6 +157,14 @@
|
|
|
157
157
|
.log-row { display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); gap: 10px; padding: 5px 8px; border-top: 1px solid var(--line); font: 11px ui-monospace, Menlo, Consolas, monospace; }
|
|
158
158
|
.log-time { color: var(--mute); white-space: nowrap; }
|
|
159
159
|
.log-message { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
160
|
+
.calls-list { max-height: 480px; overflow: auto; }
|
|
161
|
+
.call-row { display: grid; grid-template-columns: 16px 7.5rem minmax(80px, 160px) minmax(0, 1fr) 64px; gap: 10px; align-items: center; padding: 5px 8px; border-top: 1px solid var(--line); font: 11px ui-monospace, Menlo, Consolas, monospace; cursor: pointer; list-style: none; }
|
|
162
|
+
.call-row::-webkit-details-marker { display: none; }
|
|
163
|
+
.call-row:hover { background: var(--bg-hover); }
|
|
164
|
+
.call-row b, .call-preview { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
165
|
+
.call-preview { color: var(--dim); }
|
|
166
|
+
.call-row .num { color: var(--dim); text-align: right; }
|
|
167
|
+
.call-body { padding: 4px 8px 12px 34px; }
|
|
160
168
|
.row { display: grid; grid-template-columns: 16px 1fr; gap: 8px; align-items: center; padding: 3px 12px; cursor: pointer; }
|
|
161
169
|
.row.child { padding-left: 28px; }
|
|
162
170
|
.row:hover { background: var(--bg-hover); }
|
|
@@ -341,7 +349,7 @@
|
|
|
341
349
|
<script src="./morphdom.min.js"></script>
|
|
342
350
|
<script src="./prism.min.js"></script>
|
|
343
351
|
<script>
|
|
344
|
-
const defaultRunLayout = () => ({ swimHeight: 220, ganttCollapsed: false, agentsCollapsed: false, logsCollapsed: false });
|
|
352
|
+
const defaultRunLayout = () => ({ swimHeight: 220, ganttCollapsed: false, agentsCollapsed: false, callsCollapsed: false, logsCollapsed: false });
|
|
345
353
|
const maxSwimHeight = () => Math.max(72, window.innerHeight * 0.7);
|
|
346
354
|
function loadRunLayout() {
|
|
347
355
|
const fallback = defaultRunLayout();
|
|
@@ -354,6 +362,7 @@
|
|
|
354
362
|
swimHeight: Number.isFinite(value.swimHeight) && value.swimHeight >= 72 ? value.swimHeight : fallback.swimHeight,
|
|
355
363
|
ganttCollapsed: typeof value.ganttCollapsed === "boolean" ? value.ganttCollapsed : fallback.ganttCollapsed,
|
|
356
364
|
agentsCollapsed: typeof value.agentsCollapsed === "boolean" ? value.agentsCollapsed : fallback.agentsCollapsed,
|
|
365
|
+
callsCollapsed: typeof value.callsCollapsed === "boolean" ? value.callsCollapsed : fallback.callsCollapsed,
|
|
357
366
|
logsCollapsed: typeof value.logsCollapsed === "boolean" ? value.logsCollapsed : fallback.logsCollapsed,
|
|
358
367
|
};
|
|
359
368
|
} catch {
|
|
@@ -372,7 +381,7 @@
|
|
|
372
381
|
}
|
|
373
382
|
}
|
|
374
383
|
function saveSidebarCollapsed() { try { localStorage.setItem("traj-sidebar-collapsed", JSON.stringify([...state.sidebarCollapsed])); } catch { /* Preferences are optional. */ } }
|
|
375
|
-
const state = { publishers: [], currentPub: null, currentRun: null, currentTarget: null, currentAgent: null, pendingTarget: null, agentRange: null, agentRangeAgent: null, selectedEvent: null, timelineHighlight: null, eventsSig: null, toolPane: "summary", sysPane: "prompt", inspMode: "event", inspSig: null, socket: null, transcripts: {}, transcriptPending: {}, transcriptSources: {}, transcriptRefresh: {}, transcriptRevision: {}, transcriptStatus: {}, transcriptCacheOrder: [], actionTargets: new Map(), shareUrls: {}, runLayout: loadRunLayout(), sidebarCollapsed: loadSidebarCollapsed(), workflowArgumentsOpen: new Set() };
|
|
384
|
+
const state = { publishers: [], currentPub: null, currentRun: null, currentTarget: null, currentAgent: null, pendingTarget: null, agentRange: null, agentRangeAgent: null, selectedEvent: null, timelineHighlight: null, eventsSig: null, toolPane: "summary", sysPane: "prompt", inspMode: "event", inspSig: null, socket: null, transcripts: {}, transcriptPending: {}, transcriptSources: {}, transcriptRefresh: {}, transcriptRevision: {}, transcriptStatus: {}, transcriptCacheOrder: [], actionTargets: new Map(), shareUrls: {}, runLayout: loadRunLayout(), sidebarCollapsed: loadSidebarCollapsed(), workflowArgumentsOpen: new Set(), callsOpen: new Set() };
|
|
376
385
|
let hasAcceptedState = false;
|
|
377
386
|
let socketGeneration = 0;
|
|
378
387
|
let socketReconnect;
|
|
@@ -676,6 +685,8 @@
|
|
|
676
685
|
const style = running ? `left:${Math.max(0, left / span * 100)}%;width:0%` : barStyle(Math.max(0, left), agent.durationMs || 0, 0, span);
|
|
677
686
|
lanes.push({ key: `agent:${agent.id}`, html: `<div class="lane${agent.parentId ? " child" : ""}" data-agent="${esc(agent.id)}">${glyph(agent.state)}<div class="lab">${esc(agent.label || agent.name)}</div><div class="track"><i class="bar ${stateClass(agent.state)}" style="${style}"${running ? ` data-clock-bar-start="${agent.startedAt || start}" data-clock-bar-min="2"` : ""}></i>${toolBars}</div></div>` });
|
|
678
687
|
}
|
|
688
|
+
const calls = scriptCalls(record);
|
|
689
|
+
if (calls.length) lanes.push({ key: "calls", html: `<div class="lane crumb"><span></span><div class="lab">script</div><div class="track">${calls.map((call) => { const status = callState(record, call); const running = status === "running"; const title = `${callLabel(call)} · ${status}${running ? "" : ` · ${callDuration(call)}`}`; return `<i class="bar tool ${stateClass(status)}" style="${running ? `left:${Math.max(0, (call.startedAt - start) / span * 100)}%;width:0.5%` : barStyle(call.startedAt - start, (call.finishedAt ?? call.startedAt) - call.startedAt, 0.5, span)}"${running ? ` data-clock-bar-start="${call.startedAt}" data-clock-bar-min="0.5"` : ""} title="${esc(title)}"></i>`; }).join("")}</div></div>` });
|
|
679
690
|
return [{ key: "axis", html: `<div class="axis"><div></div><div class="ticks"><span>${fmtClock(start)}</span><span data-clock-axis="mid">${live ? "" : `+${fmtRuntime(span / 2)}`}</span><span data-clock-axis="end">${live ? "" : "end"}</span></div></div>` }, ...lanes];
|
|
680
691
|
}
|
|
681
692
|
// Only the lanes whose markup changed are re-parsed; the quantised span keeps the others byte-identical between frames.
|
|
@@ -721,15 +732,26 @@
|
|
|
721
732
|
const phaseAgents = phase.agents.flatMap((id) => { const agent = byId.get(id); return agent ? [agent] : []; });
|
|
722
733
|
const groups = agentGridGroups(phaseAgents);
|
|
723
734
|
const showScopes = groups.length > 1 || groups.some(({ label }) => label !== "Agents");
|
|
724
|
-
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"
|
|
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("")}`;
|
|
725
736
|
}).join("");
|
|
726
|
-
return `<div class="agent-grid"><div class="agent-grid-head"><span>AGENT</span><span>MODEL</span><span>
|
|
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>`;
|
|
727
738
|
}
|
|
728
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
|
+
const scriptCalls = (record) => (record.run.scriptCalls || []).filter((call) => call?.type !== "trajectory:truncated");
|
|
741
|
+
// The journal proves completion even if a crash prevented the finish metadata write.
|
|
742
|
+
const callState = (record, call) => call.output?.status === "completed" || call.output?.status === "available" || call.output?.status === "truncated" && call.output.kind === "result" ? "completed" : call.error ? (call.error.code === "CANCELLED" ? "stopped" : "failed") : call.finishedAt !== undefined ? "completed" : record.run.state === "running" || record.run.state === "pausing" ? "running" : "interrupted";
|
|
743
|
+
const callDuration = (call) => { if (call.finishedAt === undefined) return "—"; const ms = Math.max(0, call.finishedAt - call.startedAt); return ms < 1000 ? `${ms}ms` : fmtRuntime(ms); };
|
|
744
|
+
const callLabel = (call) => call.kind === "shell" ? "shell" : `tools.${call.name}`;
|
|
745
|
+
const callPreview = (call) => call.kind === "tool" ? call.error?.code || "" : typeof call.input === "string" ? call.input : JSON.stringify(call.input);
|
|
746
|
+
function renderCalls(record) {
|
|
747
|
+
const calls = scriptCalls(record); const omitted = (record.run.scriptCalls || []).find((call) => call?.type === "trajectory:truncated"); if (!calls.length && !omitted) return ""; const collapsed = state.runLayout.callsCollapsed;
|
|
748
|
+
const rows = calls.map((call) => { const key = `${state.currentRun}\t${call.path}`; const status = callState(record, call); const duration = status === "running" ? `<span data-clock-kind="runtime" data-clock-start="${call.startedAt}" data-clock-end="live"></span>` : call.finishedAt === undefined ? "—" : esc(callDuration(call)); const input = call.kind !== "shell" ? "" : call.inputBytes !== undefined ? `<div class="hint">Input truncated (${esc(call.inputBytes)} bytes).</div><div class="code">${esc(call.input)}</div>` : `<div class="code">${highlightCode(json(call.input), "json")}</div>`; return `<details class="call"${state.callsOpen.has(key) ? " open" : ""} data-call="${esc(key)}"><summary class="call-row">${glyph(status)}<span class="log-time">${esc(fmtClock(call.startedAt))}</span><b>${esc(callLabel(call))}</b><span class="call-preview">${esc(callPreview(call))}</span><span class="num">${duration}</span></summary><div class="call-body">${call.phase ? `<div class="kv"><div class="k">Phase</div><div>${esc(call.phase)}</div></div>` : ""}${call.kind === "shell" ? `<div class="sec">INPUT</div>${input}<div class="sec">OUTPUT</div>${renderOutputPane(call)}` : `<div class="kv"><div class="k">Status</div><div>${esc(status)}</div>${call.error ? `<div class="k">Error</div><div>${esc(call.error.code)}</div>` : ""}</div>`}</div></details>`; }).join("");
|
|
749
|
+
return `<section class="logs"><button type="button" class="sec section-toggle" data-toggle="calls" aria-expanded="${!collapsed}">${collapsed ? "▸" : "▾"} CALLS</button>${collapsed ? "" : `<div class="calls-list">${rows}${omitted ? `<div class="log-row"><span class="log-time">—</span><span class="log-message">${esc(omitted.omitted)} calls omitted from live state</span></div>` : ""}</div>`}</section>`;
|
|
750
|
+
}
|
|
729
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)}`; }
|
|
730
|
-
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)}${renderLogs(record)}</section>`; }
|
|
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>`; }
|
|
731
753
|
function actionButton(label, action, disabled) { return `<button class="btn-alt" data-act="${action}"${disabled ? " disabled" : ""}>${label}</button>`; }
|
|
732
|
-
function subagentAgent(subagent) { return { id: subagent.id, name: subagentLabel(subagent), label: subagentLabel(subagent),
|
|
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] : [] }; }
|
|
733
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" }; }
|
|
734
756
|
function renderAgentStats(found, agent, isSubagent) {
|
|
735
757
|
const offline = !found.publisher.connected; const stateText = agent.state || "—"; const value = isSubagent ? subagentAccounting(found.record) : accounting(agent);
|
|
@@ -739,7 +761,7 @@
|
|
|
739
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>' : ""}`;
|
|
740
762
|
}
|
|
741
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>`; }
|
|
742
|
-
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
|
|
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>`; }
|
|
743
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`); }
|
|
744
766
|
function clampSwimHeight() { renderRunLayout(); }
|
|
745
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(); }
|
|
@@ -764,9 +786,9 @@
|
|
|
764
786
|
const { skillFilters, skillResolved, extensionFilters, extensionResolved } = sessionResources(agent);
|
|
765
787
|
const attempt = agent.attemptDetails?.at(-1);
|
|
766
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 } : {}) }));
|
|
767
|
-
const environment = {
|
|
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 };
|
|
768
790
|
const panes = {
|
|
769
|
-
prompt: () => `<div class="kv"><div class="k">Tokens</div><div>est. ${estTokens(prompt)}</div></div><div class="
|
|
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>`,
|
|
770
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>',
|
|
771
793
|
skills: () => `<div class="sec">FILTERS</div>${nameList(skillFilters)}<div class="sec">RESOLVED</div>${resourceList(skillResolved)}`,
|
|
772
794
|
extensions: () => `<div class="sec">FILTERS</div>${nameList(extensionFilters)}<div class="sec">RESOLVED</div>${resourceList(extensionResolved)}`,
|
|
@@ -775,7 +797,7 @@
|
|
|
775
797
|
};
|
|
776
798
|
patch($("sys-pane"), (panes[state.sysPane] || panes.prompt)()); $("sys-tabs").querySelectorAll("span").forEach((tab) => tab.classList.toggle("on", tab.dataset.pane === state.sysPane));
|
|
777
799
|
}
|
|
778
|
-
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="
|
|
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>`); }
|
|
779
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>` : ""; }
|
|
780
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); }
|
|
781
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>`); }
|
|
@@ -862,8 +884,8 @@
|
|
|
862
884
|
});
|
|
863
885
|
}
|
|
864
886
|
$("script-close").addEventListener("click", closeScript); $("script-bg").addEventListener("click", closeScript); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeScript(); setAgentRange(null); if (document.body.dataset.view === "run") renderRun(); else renderAgent(); } });
|
|
865
|
-
document.addEventListener("toggle", (event) => { const target = event.target; if (!(target instanceof HTMLDetailsElement)
|
|
866
|
-
document.querySelector(".app").addEventListener("click", (event) => { const target = event.target.closest("[data-sidebar-group], [data-sidebar-section], [data-copy-share], [data-act], [data-run], [data-subagent], [data-agent], [data-agent-details], [data-script], [data-pane], [data-toggle]"); if (!target) return; if (target.dataset.copyShare) { const input = target.previousElementSibling; navigator.clipboard?.writeText(target.dataset.copyShare).then(() => showToast("copied"), () => { input?.select(); showToast("press Ctrl+C to copy"); }); return; } if (target.dataset.sidebarGroup !== undefined || target.dataset.sidebarSection !== undefined) { const key = target.dataset.sidebarGroup ?? target.dataset.sidebarSection; if (state.sidebarCollapsed.has(key)) state.sidebarCollapsed.delete(key); else state.sidebarCollapsed.add(key); saveSidebarCollapsed(); renderSidebar(); return; } if (target.dataset.toggle) { if (document.body.dataset.view !== "run") return; const key = target.dataset.toggle === "gantt" ? "ganttCollapsed" : target.dataset.toggle === "agents" ? "agentsCollapsed" : target.dataset.toggle === "logs" ? "logsCollapsed" : ""; if (!key) return; state.runLayout[key] = !state.runLayout[key]; saveRunLayout(); renderRun(); return; } if (target.dataset.act) { if (target.dataset.act === "steer") { const input = $("subagent-steer"); const message = input?.value.trim(); if (!message) return; sendAction("steer", { payload: { message } }); if (input) input.value = ""; return; } const name = target.closest("[data-name]")?.dataset.name; sendAction(target.dataset.act, name ? { name } : {}); return; } if (target.dataset.agentDetails !== undefined) { state.inspMode = "agent"; state.inspSig = null; renderAgent(); return; } if (target.dataset.script) { openScript(); return; } if (target.dataset.pane) { const found = selected(); const agent = found ? found.target.kind === "subagent" ? subagentAgent(found.record) : found.record.run.agents.find((candidate) => candidate.id === state.currentAgent) : undefined; const entries = found && agent ? agentEntries(found.record, agent) : []; if (target.closest("#tool-tabs")) { state.toolPane = target.dataset.pane; state.inspMode = "event"; renderInspector(entries[state.selectedEvent || 0] ? entryDetails(entries[state.selectedEvent || 0], agent, entries) : undefined, agent, entries); } else { state.sysPane = target.dataset.pane; state.inspMode = "agent"; if (agent) renderSystemPane(agent); } return; } if (target.dataset.run) { selectRun(target.dataset.run); return; } if (target.dataset.subagent) { selectSubagent(target.dataset.subagent); return; } if (target.dataset.agent && document.body.dataset.view === "run") { state.currentAgent = target.dataset.agent; state.selectedEvent = null; state.inspMode = "event"; setView("agent"); } });
|
|
887
|
+
document.addEventListener("toggle", (event) => { const target = event.target; if (!(target instanceof HTMLDetailsElement)) return; const key = target.dataset.workflowArgs ?? target.dataset.call; const open = target.dataset.workflowArgs !== undefined ? state.workflowArgumentsOpen : target.dataset.call !== undefined ? state.callsOpen : undefined; if (!open) return; if (target.open) open.add(key); else open.delete(key); }, true);
|
|
888
|
+
document.querySelector(".app").addEventListener("click", (event) => { const target = event.target.closest("[data-sidebar-group], [data-sidebar-section], [data-copy-share], [data-act], [data-run], [data-subagent], [data-agent], [data-agent-details], [data-script], [data-pane], [data-toggle]"); if (!target) return; if (target.dataset.copyShare) { const input = target.previousElementSibling; navigator.clipboard?.writeText(target.dataset.copyShare).then(() => showToast("copied"), () => { input?.select(); showToast("press Ctrl+C to copy"); }); return; } if (target.dataset.sidebarGroup !== undefined || target.dataset.sidebarSection !== undefined) { const key = target.dataset.sidebarGroup ?? target.dataset.sidebarSection; if (state.sidebarCollapsed.has(key)) state.sidebarCollapsed.delete(key); else state.sidebarCollapsed.add(key); saveSidebarCollapsed(); renderSidebar(); return; } if (target.dataset.toggle) { if (document.body.dataset.view !== "run") return; const key = target.dataset.toggle === "gantt" ? "ganttCollapsed" : target.dataset.toggle === "agents" ? "agentsCollapsed" : target.dataset.toggle === "calls" ? "callsCollapsed" : target.dataset.toggle === "logs" ? "logsCollapsed" : ""; if (!key) return; state.runLayout[key] = !state.runLayout[key]; saveRunLayout(); renderRun(); return; } if (target.dataset.act) { if (target.dataset.act === "steer") { const input = $("subagent-steer"); const message = input?.value.trim(); if (!message) return; sendAction("steer", { payload: { message } }); if (input) input.value = ""; return; } const name = target.closest("[data-name]")?.dataset.name; sendAction(target.dataset.act, name ? { name } : {}); return; } if (target.dataset.agentDetails !== undefined) { state.inspMode = "agent"; state.inspSig = null; renderAgent(); return; } if (target.dataset.script) { openScript(); return; } if (target.dataset.pane) { const found = selected(); const agent = found ? found.target.kind === "subagent" ? subagentAgent(found.record) : found.record.run.agents.find((candidate) => candidate.id === state.currentAgent) : undefined; const entries = found && agent ? agentEntries(found.record, agent) : []; if (target.closest("#tool-tabs")) { state.toolPane = target.dataset.pane; state.inspMode = "event"; renderInspector(entries[state.selectedEvent || 0] ? entryDetails(entries[state.selectedEvent || 0], agent, entries) : undefined, agent, entries); } else { state.sysPane = target.dataset.pane; state.inspMode = "agent"; if (agent) renderSystemPane(agent); } return; } if (target.dataset.run) { selectRun(target.dataset.run); return; } if (target.dataset.subagent) { selectSubagent(target.dataset.subagent); return; } if (target.dataset.agent && document.body.dataset.view === "run") { state.currentAgent = target.dataset.agent; state.selectedEvent = null; state.inspMode = "event"; setView("agent"); } });
|
|
867
889
|
document.addEventListener("visibilitychange", () => { sendFocus(); if (!document.hidden) scheduleRender(); });
|
|
868
890
|
// Phones show one sheet at a time, so picking something moves to the sheet that shows its result.
|
|
869
891
|
const phone = matchMedia("(max-width: 900px)");
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdir,
|
|
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 } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { clearTrajectoryHost, setTrajectoryHost } from "../../src/trajectory-host-handle.js";
|
|
9
|
-
import {
|
|
9
|
+
import { atomicJson } from "../../src/io.js";
|
|
10
10
|
import { errorText, isNodeError, object, positiveInteger } from "../../src/utils.js";
|
|
11
|
-
import { isTrajectoryAction, isTrajectoryTarget, trajectoryActionError, TRAJECTORY_MAX_TRANSCRIPT_BYTES } from "../../src/trajectory.js";
|
|
11
|
+
import { isTimingTranscriptEntry, isTrajectoryAction, isTrajectoryTarget, trajectoryActionError, TRAJECTORY_MAX_TRANSCRIPT_BYTES } 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
|
const DEFAULT_TRAJECTORY_PORT = 7432;
|
|
14
15
|
const TRAJECTORY_IDLE_EXIT_MS = 5 * 60 * 1000;
|
|
15
16
|
const TRAJECTORY_LOCK_NAME = "trajectory.lock";
|
|
@@ -54,127 +55,166 @@ function signalProcess(pid, signal) {
|
|
|
54
55
|
throw error;
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// Called right after /health named this pid; its start, read now, tells a later reuse of the pid apart before SIGKILL.
|
|
59
|
+
// Returns only once the server is gone: a server that may still serve keeps its lock and port, so nothing replaces it.
|
|
60
|
+
async function stopStaleServer(pid, port) {
|
|
61
|
+
const server = identityOf(pid);
|
|
62
|
+
if (!processLive(server))
|
|
60
63
|
return;
|
|
61
|
-
|
|
62
|
-
if (!await processAlive(lock.pid, lock.startedAt))
|
|
63
|
-
return;
|
|
64
|
-
signalProcess(lock.pid, "SIGTERM");
|
|
64
|
+
signalProcess(pid, "SIGTERM");
|
|
65
65
|
const deadline = Date.now() + 3000;
|
|
66
66
|
while (Date.now() < deadline) {
|
|
67
|
-
if (!await serverHealthy(
|
|
67
|
+
if (!await serverHealthy(port))
|
|
68
68
|
break;
|
|
69
69
|
const remaining = deadline - Date.now();
|
|
70
70
|
if (remaining <= 0)
|
|
71
71
|
break;
|
|
72
72
|
await delay(Math.min(50, remaining));
|
|
73
73
|
}
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
// Without a confirmed identity the pid may now be an unrelated process, so SIGTERM is never escalated.
|
|
75
|
+
if (processConfirmed(server))
|
|
76
|
+
signalProcess(pid, "SIGKILL");
|
|
77
|
+
for (let attempt = 0; attempt < 50 && processLive(server); attempt += 1)
|
|
78
|
+
await delay(20);
|
|
79
|
+
if (processLive(server))
|
|
80
|
+
throw new Error(`Stale Trajectory server ${String(pid)} on port ${String(port)} did not stop; stop it and retry`);
|
|
76
81
|
}
|
|
82
|
+
// Only a missing or malformed lock reads as absent; any other read error leaves its owner unknown, so it is thrown.
|
|
77
83
|
async function readLock(path) {
|
|
84
|
+
let text;
|
|
78
85
|
try {
|
|
79
|
-
|
|
80
|
-
if (!object(parsed) || !positiveInteger(parsed.pid) || !positiveInteger(parsed.port) || parsed.port > 65535)
|
|
81
|
-
return undefined;
|
|
82
|
-
const fingerprint = typeof parsed.fingerprint === "string" ? parsed.fingerprint : undefined;
|
|
83
|
-
const startedAt = positiveInteger(parsed.startedAt) ? parsed.startedAt : undefined;
|
|
84
|
-
return { pid: parsed.pid, port: parsed.port, ...(fingerprint === undefined ? {} : { fingerprint }), ...(startedAt === undefined ? {} : { startedAt }) };
|
|
86
|
+
text = await readFile(path, "utf8");
|
|
85
87
|
}
|
|
86
88
|
catch (error) {
|
|
87
89
|
if (isNodeError(error, "ENOENT"))
|
|
88
90
|
return undefined;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(text);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
89
98
|
return undefined;
|
|
90
99
|
}
|
|
100
|
+
if (!object(parsed) || !positiveInteger(parsed.pid) || !positiveInteger(parsed.port) || parsed.port > 65535)
|
|
101
|
+
return undefined;
|
|
102
|
+
const fingerprint = typeof parsed.fingerprint === "string" ? parsed.fingerprint : undefined;
|
|
103
|
+
const startedAt = positiveInteger(parsed.startedAt) ? parsed.startedAt : undefined;
|
|
104
|
+
const start = typeof parsed.start === "string" ? parsed.start : undefined;
|
|
105
|
+
return { pid: parsed.pid, port: parsed.port, ...(fingerprint === undefined ? {} : { fingerprint }), ...(startedAt === undefined ? {} : { startedAt }), ...(start === undefined ? {} : { start }) };
|
|
106
|
+
}
|
|
107
|
+
// 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).
|
|
108
|
+
const STARTUP_MUTEX_WAIT_MS = 2 * 60 * (300 + 50) + 3_000 + 1_000 + 1_000;
|
|
109
|
+
async function acquireStartupMutex(lockPath) {
|
|
110
|
+
const deadline = Date.now() + STARTUP_MUTEX_WAIT_MS;
|
|
111
|
+
for (;;) {
|
|
112
|
+
const token = await tryAcquireStartupMutex(lockPath);
|
|
113
|
+
if (token !== undefined)
|
|
114
|
+
return token;
|
|
115
|
+
if (Date.now() >= deadline)
|
|
116
|
+
throw new Error("Another Pi is still starting Trajectory; retry");
|
|
117
|
+
// Contenders that saw each other both back off; a random delay keeps them from retrying in step.
|
|
118
|
+
await delay(25 + Math.floor(Math.random() * 50));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Only a Trajectory server reports a fingerprint; any other service answering /health is never signalled.
|
|
122
|
+
function isTrajectoryServer(health) { return health?.pid !== undefined && health.fingerprint !== undefined; }
|
|
123
|
+
// A lock proves which process serves its port only when that process reports the same identity.
|
|
124
|
+
function ownsLock(health, lock) {
|
|
125
|
+
return isTrajectoryServer(health) && health.pid === lock.pid && (lock.startedAt === undefined || lock.startedAt === health.startedAt);
|
|
91
126
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let foreign = false;
|
|
95
|
-
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
127
|
+
async function awaitHealth(port, accept, stopped = () => false) {
|
|
128
|
+
for (let attempt = 0; attempt < 60 && !stopped(); attempt += 1) {
|
|
96
129
|
const health = await serverHealth(port);
|
|
97
|
-
if (health
|
|
98
|
-
return;
|
|
99
|
-
foreign = health !== undefined;
|
|
130
|
+
if (health !== undefined && accept(health))
|
|
131
|
+
return health;
|
|
100
132
|
await delay(50);
|
|
101
133
|
}
|
|
102
|
-
|
|
134
|
+
return undefined;
|
|
103
135
|
}
|
|
104
|
-
async function
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
136
|
+
async function startServer(lockPath, serverPath, fingerprint, port, startupToken) {
|
|
137
|
+
// 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.
|
|
138
|
+
const child = spawn(process.versions.bun ? "node" : process.execPath, [serverPath, "--port", String(port), "--lock", lockPath, "--fingerprint", fingerprint, "--startup", startupToken], { detached: true, stdio: "ignore" });
|
|
139
|
+
const startup = { ended: false };
|
|
140
|
+
const exited = new Promise((resolve) => {
|
|
141
|
+
child.once("error", (error) => { startup.error = error; startup.ended = true; resolve(); });
|
|
142
|
+
child.once("exit", () => { startup.ended = true; resolve(); });
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
if (child.pid !== undefined)
|
|
146
|
+
recordStartupServer(lockPath, startupToken, child.pid);
|
|
147
|
+
const health = await awaitHealth(port, (candidate) => candidate.pid === child.pid && candidate.fingerprint === fingerprint, () => startup.ended);
|
|
148
|
+
if (startup.error !== undefined)
|
|
149
|
+
throw startup.error;
|
|
150
|
+
// The server publishes its lock before it answers, so readiness is reported only once both agree.
|
|
151
|
+
const lock = health === undefined ? undefined : await readLock(lockPath);
|
|
152
|
+
// The detached server outlives this Pi only once it is ready; until then this Pi keeps waiting for its exit.
|
|
153
|
+
if (lock !== undefined && ownsLock(health, lock) && lock.port === port && lock.fingerprint === fingerprint) {
|
|
154
|
+
child.unref();
|
|
155
|
+
return { port };
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`Trajectory server did not start on port ${String(port)}`);
|
|
112
158
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
159
|
+
catch (error) {
|
|
160
|
+
// A server outliving its startup could later publish over another owner's lock, so it is stopped before the mutex is released.
|
|
161
|
+
if (!startup.ended) {
|
|
162
|
+
child.kill("SIGKILL");
|
|
163
|
+
await exited;
|
|
117
164
|
}
|
|
118
|
-
|
|
119
|
-
// A live lock can still name the attaching process during startup; after the bounded wait, replace the unrecoverable startup owner and retry normally.
|
|
120
|
-
await stopStaleServer(existing);
|
|
165
|
+
if ((await readLock(lockPath))?.pid === child.pid)
|
|
121
166
|
await rm(lockPath, { force: true });
|
|
122
|
-
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Runs under the startup mutex, so no other Pi or idle server changes the lock meanwhile.
|
|
171
|
+
async function startOrReuseServer(lockPath, serverPath, fingerprint, configuredPort, startupToken) {
|
|
172
|
+
const existing = await readLock(lockPath);
|
|
173
|
+
if (existing) {
|
|
174
|
+
let health = await serverHealth(existing.port);
|
|
175
|
+
// A live lock owner can be a server still starting or one too loaded to answer in time.
|
|
176
|
+
if (health === undefined && processLive(existing))
|
|
177
|
+
health = await awaitHealth(existing.port, (candidate) => candidate.pid === existing.pid);
|
|
178
|
+
const lock = health === undefined ? existing : await readLock(lockPath) ?? existing;
|
|
179
|
+
if (ownsLock(health, lock)) {
|
|
180
|
+
if (lock.fingerprint === fingerprint && health.fingerprint === fingerprint)
|
|
181
|
+
return { port: lock.port };
|
|
182
|
+
await stopStaleServer(health.pid, lock.port);
|
|
183
|
+
}
|
|
184
|
+
else if (!isTrajectoryServer(health) && lock.pid !== process.pid && processLive(lock)) {
|
|
185
|
+
// Unidentified is not dead: a suspended or overloaded owner can resume serving, so its lock stays and no second server is started beside it.
|
|
186
|
+
// Only a lock naming this Pi, left by an older release's startup reservation, is known not to name a server.
|
|
187
|
+
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`);
|
|
123
188
|
}
|
|
189
|
+
// Any other owner is dead, or another Trajectory server holds its port, so it is never signalled.
|
|
190
|
+
await rm(lockPath, { force: true });
|
|
124
191
|
}
|
|
125
|
-
|
|
126
|
-
|
|
192
|
+
const occupant = await serverHealth(configuredPort);
|
|
193
|
+
if (occupant !== undefined) {
|
|
194
|
+
if (!isTrajectoryServer(occupant))
|
|
195
|
+
throw new Error(`Trajectory port ${String(configuredPort)} is held by another Trajectory server; stop it and retry`);
|
|
196
|
+
if (occupant.fingerprint === fingerprint && occupant.startedAt !== undefined) {
|
|
197
|
+
// This server lost its lock; restoring it keeps every healthy server named by the lock.
|
|
198
|
+
//NOTE: its start is read just after /health named the pid; a reuse of that pid in between is not excluded.
|
|
199
|
+
await atomicJson(lockPath, { pid: occupant.pid, port: configuredPort, fingerprint, startedAt: occupant.startedAt, start: processStart(occupant.pid) });
|
|
200
|
+
return { port: configuredPort };
|
|
201
|
+
}
|
|
202
|
+
await stopStaleServer(occupant.pid, configuredPort);
|
|
203
|
+
}
|
|
204
|
+
return startServer(lockPath, serverPath, fingerprint, configuredPort, startupToken);
|
|
127
205
|
}
|
|
206
|
+
//NOTE: Pi releases without the startup mutex do not take it, so a concurrent start by one of them is not excluded.
|
|
128
207
|
async function ensureTrajectoryServer(agentDir, configuredPort) {
|
|
129
208
|
const lockPath = trajectoryLockPath(agentDir);
|
|
130
209
|
const serverPath = trajectoryServerPath();
|
|
131
210
|
const fingerprint = await trajectoryFingerprint(serverPath);
|
|
132
211
|
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
133
|
-
const
|
|
134
|
-
if (existing) {
|
|
135
|
-
const reused = await resolveExistingServer(lockPath, existing, fingerprint);
|
|
136
|
-
if (reused)
|
|
137
|
-
return reused;
|
|
138
|
-
}
|
|
139
|
-
let lockHandle;
|
|
212
|
+
const startupToken = await acquireStartupMutex(lockPath);
|
|
140
213
|
try {
|
|
141
|
-
|
|
142
|
-
await lockHandle.writeFile(`${JSON.stringify({ pid: process.pid, port: configuredPort, fingerprint, startedAt: Date.now() })}\n`, "utf8");
|
|
143
|
-
}
|
|
144
|
-
catch (error) {
|
|
145
|
-
if (isNodeError(error, "EEXIST")) {
|
|
146
|
-
const raced = await readLock(lockPath);
|
|
147
|
-
if (raced) {
|
|
148
|
-
const reused = await resolveExistingServer(lockPath, raced, fingerprint);
|
|
149
|
-
if (reused)
|
|
150
|
-
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
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
throw error;
|
|
158
|
-
}
|
|
214
|
+
return await startOrReuseServer(lockPath, serverPath, fingerprint, configuredPort, startupToken);
|
|
159
215
|
}
|
|
160
216
|
finally {
|
|
161
|
-
await
|
|
162
|
-
}
|
|
163
|
-
// 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.
|
|
164
|
-
const occupant = await serverHealth(configuredPort);
|
|
165
|
-
if (occupant?.pid !== undefined && occupant.fingerprint !== fingerprint)
|
|
166
|
-
await stopStaleServer({ pid: occupant.pid, port: configuredPort, ...(occupant.startedAt === undefined ? {} : { startedAt: occupant.startedAt }) });
|
|
167
|
-
try {
|
|
168
|
-
// 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.
|
|
169
|
-
const child = spawn(process.versions.bun ? "node" : process.execPath, [serverPath, "--port", String(configuredPort), "--lock", lockPath, "--fingerprint", fingerprint], { detached: true, stdio: "ignore" });
|
|
170
|
-
const startupError = new Promise((_resolve, reject) => { child.once("error", reject); });
|
|
171
|
-
child.unref();
|
|
172
|
-
await Promise.race([waitForServer(configuredPort, fingerprint), startupError]);
|
|
173
|
-
return { port: configuredPort };
|
|
174
|
-
}
|
|
175
|
-
catch (error) {
|
|
176
|
-
await rm(lockPath, { force: true });
|
|
177
|
-
throw error;
|
|
217
|
+
await releaseStartupMutex(lockPath, startupToken);
|
|
178
218
|
}
|
|
179
219
|
}
|
|
180
220
|
function trajectoryWebSocket() {
|
|
@@ -202,7 +242,6 @@ const ACTIVE_POLL_MS = 1_000;
|
|
|
202
242
|
const IDLE_POLL_MS = 10_000;
|
|
203
243
|
const RECONNECT_INITIAL_DELAY_MS = 100;
|
|
204
244
|
const RECONNECT_MAX_DELAY_MS = 5_000;
|
|
205
|
-
function isTimingEntry(value) { return object(value) && value.type === "custom" && value.customType === "pi-workflows:tool-timing"; }
|
|
206
245
|
function boundedString(value, maxLength = 1024) {
|
|
207
246
|
if (typeof value !== "string")
|
|
208
247
|
return value;
|
|
@@ -213,7 +252,7 @@ const MAX_LIVE_STRING_BYTES = 64 * 1024;
|
|
|
213
252
|
const MAX_LIVE_ARRAY_ENTRIES = 256;
|
|
214
253
|
const MAX_LIVE_OBJECT_KEYS = 64;
|
|
215
254
|
const LIVE_METADATA_ARRAY_KEYS = new Set(["agents", "runs", "subagents"]);
|
|
216
|
-
const LIVE_TRUNCATABLE_ARRAY_KEYS = new Set(["events", "phaseHistory"]);
|
|
255
|
+
const LIVE_TRUNCATABLE_ARRAY_KEYS = new Set(["events", "phaseHistory", "scriptCalls"]);
|
|
217
256
|
const LIVE_METADATA_OBJECT_KEYS = new Set(["transcripts"]);
|
|
218
257
|
function liveValueWillBeBounded(value, key = "", depth = 0) {
|
|
219
258
|
if (typeof value === "string")
|
|
@@ -238,7 +277,7 @@ function liveValueWillBeBounded(value, key = "", depth = 0) {
|
|
|
238
277
|
const MAX_LIVE_TIMING_BYTES = 64 * 1024;
|
|
239
278
|
/** Keeps the newest timing entries: dropping the oldest leaves a live agent's gantt growing instead of frozen. */
|
|
240
279
|
function boundedTiming(value) {
|
|
241
|
-
const entries = Array.isArray(value) ? value.filter(
|
|
280
|
+
const entries = Array.isArray(value) ? value.filter(isTimingTranscriptEntry) : [];
|
|
242
281
|
const retained = [];
|
|
243
282
|
let bytes = 2;
|
|
244
283
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
@@ -513,23 +552,14 @@ export function createTrajectoryController(agentDir) {
|
|
|
513
552
|
}
|
|
514
553
|
};
|
|
515
554
|
const transcriptFromFallback = async (input, request) => {
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
if (result.status === "available" && Buffer.byteLength(JSON.stringify(result.entries)) > TRAJECTORY_MAX_TRANSCRIPT_BYTES)
|
|
519
|
-
return { status: "oversized", revision: result.revision, entries: [], error: "Transcript is too large" };
|
|
520
|
-
}
|
|
521
|
-
catch {
|
|
522
|
-
return { status: "failed", revision: result.revision, entries: [], error: "Transcript failed" };
|
|
523
|
-
}
|
|
524
|
-
return result;
|
|
525
|
-
};
|
|
526
|
-
const boundedResult = (entries, revision) => { try {
|
|
527
|
-
if (Buffer.byteLength(JSON.stringify(entries)) > TRAJECTORY_MAX_TRANSCRIPT_BYTES)
|
|
528
|
-
return { status: "oversized", revision, entries: [], error: "Transcript is too large" };
|
|
555
|
+
const oversized = (entries, revision) => { try {
|
|
556
|
+
return Buffer.byteLength(JSON.stringify(entries)) > TRAJECTORY_MAX_TRANSCRIPT_BYTES ? { status: "oversized", revision, entries: [], error: "Transcript is too large" } : undefined;
|
|
529
557
|
}
|
|
530
558
|
catch {
|
|
531
559
|
return { status: "failed", revision, entries: [], error: "Transcript failed" };
|
|
532
|
-
}
|
|
560
|
+
} };
|
|
561
|
+
const boundedTranscriptResult = (result) => (result.status === "available" ? oversized(result.entries, result.revision) : undefined) ?? result;
|
|
562
|
+
const boundedResult = (entries, revision) => oversized(entries, revision) ?? { status: entries.length ? "available" : "empty", revision, entries };
|
|
533
563
|
if (input.loadTranscript) {
|
|
534
564
|
const result = await input.loadTranscript(request);
|
|
535
565
|
const revisionChanged = request.revision !== undefined && result.revision !== request.revision && result.status !== "missing" && result.status !== "failed" && result.status !== "oversized" && result.status !== "disconnected";
|