@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.
Files changed (128) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +2 -2
  3. package/dist/src/agent-execution.d.ts +43 -10
  4. package/dist/src/agent-execution.js +442 -162
  5. package/dist/src/background-widget.d.ts +5 -1
  6. package/dist/src/background-widget.js +5 -10
  7. package/dist/src/decoders.d.ts +2 -3
  8. package/dist/src/decoders.js +59 -37
  9. package/dist/src/execution.d.ts +6 -2
  10. package/dist/src/execution.js +48 -7
  11. package/dist/src/host-delivery.js +5 -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 +11 -8
  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 +6 -1
  18. package/dist/src/host-view.js +46 -20
  19. package/dist/src/host.d.ts +2 -2
  20. package/dist/src/host.js +246 -118
  21. package/dist/src/index.d.ts +1 -2
  22. package/dist/src/index.js +1985 -1690
  23. package/dist/src/index.js.map +3 -3
  24. package/dist/src/paths.d.ts +8 -5
  25. package/dist/src/paths.js +24 -13
  26. package/dist/src/persistence.d.ts +1 -0
  27. package/dist/src/persistence.js +1 -0
  28. package/dist/src/pi-runtime-runner.js +4 -1
  29. package/dist/src/registry.d.ts +9 -7
  30. package/dist/src/registry.js +29 -71
  31. package/dist/src/runtime/agent-runner.d.ts +2 -0
  32. package/dist/src/script-tools.d.ts +27 -0
  33. package/dist/src/script-tools.js +87 -0
  34. package/dist/src/session-lease.d.ts +1 -1
  35. package/dist/src/session-lease.js +45 -11
  36. package/dist/src/settings.d.ts +19 -3
  37. package/dist/src/settings.js +63 -50
  38. package/dist/src/store.d.ts +3 -1
  39. package/dist/src/store.js +17 -1
  40. package/dist/src/trajectory.d.ts +12 -3
  41. package/dist/src/trajectory.js +32 -11
  42. package/dist/src/types.d.ts +129 -45
  43. package/dist/src/types.js +5 -8
  44. package/dist/src/utils.d.ts +21 -1
  45. package/dist/src/utils.js +46 -6
  46. package/dist/src/validation.d.ts +14 -3
  47. package/dist/src/validation.js +143 -87
  48. package/dist/starter/index.js +8 -11
  49. package/dist/starter/index.js.map +1 -1
  50. package/dist/starter/review-loop.d.ts +0 -13
  51. package/dist/starter/review-loop.js +8 -9
  52. package/dist/subagents/index.js +904 -956
  53. package/dist/subagents/index.js.map +3 -3
  54. package/dist/subagents/src/contracts.d.ts +48 -31
  55. package/dist/subagents/src/contracts.js +20 -9
  56. package/dist/subagents/src/index.d.ts +1 -1
  57. package/dist/subagents/src/index.js +14 -8
  58. package/dist/subagents/src/manager.js +52 -73
  59. package/dist/subagents/src/navigator.js +3 -14
  60. package/dist/subagents/src/view.d.ts +1 -1
  61. package/dist/subagents/src/view.js +15 -68
  62. package/dist/subagents/src/worktree.js +1 -1
  63. package/dist/trajectory/assets/index.html +35 -13
  64. package/dist/trajectory/index.js +576 -932
  65. package/dist/trajectory/index.js.map +3 -3
  66. package/dist/trajectory/src/assets/index.html +35 -13
  67. package/dist/trajectory/src/index.js +134 -104
  68. package/dist/trajectory/src/server.js +268 -27
  69. package/dist/trajectory/src/server.js.map +3 -3
  70. package/dist/trajectory/src/startup-mutex.d.ts +17 -0
  71. package/dist/trajectory/src/startup-mutex.js +172 -0
  72. package/examples/workflow-extension-template/README.md +4 -7
  73. package/examples/workflow-extension-template/extension.test.mjs +4 -8
  74. package/examples/workflow-extension-template/index.js +0 -3
  75. package/package.json +10 -10
  76. package/skills/pi-extensible-workflows/SKILL.md +12 -8
  77. package/src/agent-execution.ts +412 -164
  78. package/src/background-widget.ts +5 -11
  79. package/src/decoders.ts +51 -37
  80. package/src/execution.ts +46 -9
  81. package/src/host-delivery.ts +5 -8
  82. package/src/host-navigator.ts +4 -2
  83. package/src/host-phases.ts +1 -1
  84. package/src/host-recovery.ts +23 -28
  85. package/src/host-runtime.ts +44 -6
  86. package/src/host-view.ts +41 -16
  87. package/src/host.ts +229 -142
  88. package/src/index.ts +1 -2
  89. package/src/paths.ts +18 -9
  90. package/src/persistence.ts +1 -0
  91. package/src/pi-runtime-runner.ts +3 -1
  92. package/src/registry.ts +28 -64
  93. package/src/runtime/agent-runner.ts +2 -0
  94. package/src/script-tools.ts +76 -0
  95. package/src/session-lease.ts +34 -6
  96. package/src/settings.ts +56 -50
  97. package/src/store.ts +18 -2
  98. package/src/trajectory.ts +32 -15
  99. package/src/types.ts +82 -34
  100. package/src/utils.ts +42 -5
  101. package/src/validation.ts +121 -80
  102. package/starter/index.ts +0 -1
  103. package/starter/review-loop.ts +8 -9
  104. package/starter/roles/developer.md +0 -1
  105. package/starter/roles/oracle.md +0 -1
  106. package/starter/roles/researcher.md +0 -1
  107. package/starter/roles/reviewer.md +0 -1
  108. package/starter/roles/scout.md +0 -1
  109. package/subagents/README.md +10 -16
  110. package/subagents/src/contracts.ts +21 -11
  111. package/subagents/src/index.ts +17 -8
  112. package/subagents/src/manager.ts +48 -128
  113. package/subagents/src/navigator.ts +4 -13
  114. package/subagents/src/view.ts +16 -63
  115. package/subagents/src/worktree.ts +1 -1
  116. package/trajectory/src/assets/index.html +35 -13
  117. package/trajectory/src/index.ts +112 -91
  118. package/trajectory/src/server.ts +46 -10
  119. package/trajectory/src/startup-mutex.ts +127 -0
  120. package/dist/src/roles.d.ts +0 -70
  121. package/dist/src/roles.js +0 -325
  122. package/dist/starter/roles/developer.md +0 -6
  123. package/dist/starter/roles/oracle.md +0 -18
  124. package/dist/starter/roles/researcher.md +0 -20
  125. package/dist/starter/roles/reviewer.md +0 -19
  126. package/dist/starter/roles/scout.md +0 -18
  127. package/examples/workflow-extension-template/roles/reviewer.md +0 -4
  128. package/src/roles.ts +0 -326
@@ -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
- import { isTrajectoryAction, isTrajectoryTarget, trajectoryActionError, TRAJECTORY_MAX_TRANSCRIPT_BYTES, type TrajectoryPublisherInput, type TrajectoryPublisherMetadata, type TrajectoryTranscriptRequest, type TrajectoryTranscriptResult } from "../../src/trajectory.js";
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;
128
+ }
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
+ }
107
153
  }
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);
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 {
@@ -202,7 +226,6 @@ const RECONNECT_MAX_DELAY_MS = 5_000;
202
226
  type LiveStateRecord = Record<string, unknown>;
203
227
  type TranscriptRevision = { signature: string; revision: number };
204
228
  type PendingTranscript = { generation: number; timer: ReturnType<typeof setTimeout>; publisherId: string; runId?: string; agentId?: string; subagentId?: string; revision?: number };
205
- function isTimingEntry(value: unknown): boolean { return object(value) && value.type === "custom" && value.customType === "pi-workflows:tool-timing"; }
206
229
  function boundedString(value: unknown, maxLength = 1024): unknown {
207
230
  if (typeof value !== "string") return value;
208
231
  const bytes = Buffer.from(value);
@@ -212,7 +235,7 @@ const MAX_LIVE_STRING_BYTES = 64 * 1024;
212
235
  const MAX_LIVE_ARRAY_ENTRIES = 256;
213
236
  const MAX_LIVE_OBJECT_KEYS = 64;
214
237
  const LIVE_METADATA_ARRAY_KEYS = new Set(["agents", "runs", "subagents"]);
215
- const LIVE_TRUNCATABLE_ARRAY_KEYS = new Set(["events", "phaseHistory"]);
238
+ const LIVE_TRUNCATABLE_ARRAY_KEYS = new Set(["events", "phaseHistory", "scriptCalls"]);
216
239
  const LIVE_METADATA_OBJECT_KEYS = new Set(["transcripts"]);
217
240
  function liveValueWillBeBounded(value: unknown, key = "", depth = 0): boolean {
218
241
  if (typeof value === "string") return Buffer.byteLength(value) > MAX_LIVE_STRING_BYTES;
@@ -231,7 +254,7 @@ function liveValueWillBeBounded(value: unknown, key = "", depth = 0): boolean {
231
254
  const MAX_LIVE_TIMING_BYTES = 64 * 1024;
232
255
  /** Keeps the newest timing entries: dropping the oldest leaves a live agent's gantt growing instead of frozen. */
233
256
  function boundedTiming(value: unknown): unknown[] {
234
- const entries = Array.isArray(value) ? value.filter(isTimingEntry) : [];
257
+ const entries = Array.isArray(value) ? value.filter(isTimingTranscriptEntry) : [];
235
258
  const retained: unknown[] = [];
236
259
  let bytes = 2;
237
260
  for (let index = entries.length - 1; index >= 0; index -= 1) {
@@ -446,11 +469,9 @@ export function createTrajectoryController(agentDir: string): TrajectoryControll
446
469
  }
447
470
  };
448
471
  const transcriptFromFallback = async (input: TrajectoryPublisherInput, request: TrajectoryTranscriptRequest): Promise<TrajectoryTranscriptResult> => {
449
- const boundedTranscriptResult = (result: TrajectoryTranscriptResult): TrajectoryTranscriptResult => {
450
- try { if (result.status === "available" && Buffer.byteLength(JSON.stringify(result.entries)) > TRAJECTORY_MAX_TRANSCRIPT_BYTES) return { status: "oversized", revision: result.revision, entries: [], error: "Transcript is too large" }; } catch { return { status: "failed", revision: result.revision, entries: [], error: "Transcript failed" }; }
451
- return result;
452
- };
453
- const boundedResult = (entries: readonly unknown[], revision: number): TrajectoryTranscriptResult => { try { if (Buffer.byteLength(JSON.stringify(entries)) > TRAJECTORY_MAX_TRANSCRIPT_BYTES) return { status: "oversized", revision, entries: [], error: "Transcript is too large" }; } catch { return { status: "failed", revision, entries: [], error: "Transcript failed" }; } return { status: entries.length ? "available" : "empty", revision, entries }; };
472
+ const oversized = (entries: readonly unknown[], revision: number): TrajectoryTranscriptResult | undefined => { try { return Buffer.byteLength(JSON.stringify(entries)) > TRAJECTORY_MAX_TRANSCRIPT_BYTES ? { status: "oversized", revision, entries: [], error: "Transcript is too large" } : undefined; } catch { return { status: "failed", revision, entries: [], error: "Transcript failed" }; } };
473
+ const boundedTranscriptResult = (result: TrajectoryTranscriptResult): TrajectoryTranscriptResult => (result.status === "available" ? oversized(result.entries, result.revision) : undefined) ?? result;
474
+ const boundedResult = (entries: readonly unknown[], revision: number): TrajectoryTranscriptResult => oversized(entries, revision) ?? { status: entries.length ? "available" : "empty", revision, entries };
454
475
  if (input.loadTranscript) {
455
476
  const result = await input.loadTranscript(request);
456
477
  const revisionChanged = request.revision !== undefined && result.revision !== request.revision && result.status !== "missing" && result.status !== "failed" && result.status !== "oversized" && result.status !== "disconnected";
@@ -1,10 +1,14 @@
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";
11
+ import { TOOL_TIMING_ENTRY_TYPE } from "../../src/tool-timing.js";
8
12
  const TRAJECTORY_IDLE_EXIT_MS = 5 * 60 * 1000;
9
13
 
10
14
  type Socket = import("node:stream").Duplex;
@@ -17,9 +21,8 @@ const MAX_FRAME_BYTES = 32 * 1024 * 1024;
17
21
  const MAX_PENDING_REQUESTS = 128;
18
22
  const TRANSCRIPT_REQUEST_TIMEOUT_MS = 10_000;
19
23
  const ACTION_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
20
- const TIMING_ENTRY_TYPE = "pi-workflows:tool-timing";
21
24
  function isTimingEntry(value: unknown): boolean {
22
- return Boolean(value && typeof value === "object" && !Array.isArray(value) && (value as { type?: unknown }).type === "custom" && (value as { customType?: unknown }).customType === TIMING_ENTRY_TYPE);
25
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && (value as { type?: unknown }).type === "custom" && (value as { customType?: unknown }).customType === TOOL_TIMING_ENTRY_TYPE);
23
26
  }
24
27
  function compactTranscript(value: unknown): unknown {
25
28
  if (Array.isArray(value)) return value.filter(isTimingEntry);
@@ -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
  }
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { mkdir, readdir, readFile, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { atomicWriteFile } from "../../src/io.js";
6
+ import { isNodeError, object, positiveInteger } from "../../src/utils.js";
7
+
8
+ // Every change to the Trajectory lock happens while holding this mutex: a Pi checking, replacing, or starting a server, and a server publishing or removing its lock.
9
+ // Each contender writes its own holder file, then looks for any other live holder and backs off if it finds one. Of two contenders at least one sees the other, so both never proceed,
10
+ // and a dead holder's file is unique to it, so removing it needs no compare-and-delete.
11
+ type ProcessIdentity = { pid: number; start?: string };
12
+ type StartupHolder = ProcessIdentity & { token: string; server?: ProcessIdentity };
13
+
14
+ let bootId: string | undefined;
15
+ // The boot id and the start time in clock ticks never change for a process and differ for any later process reusing its pid.
16
+ // The /proc/<pid> ctime does not qualify: it records when that entry was looked up, and the kernel can recreate the entry.
17
+ export function processStart(pid: number): string | undefined {
18
+ if (process.platform !== "linux") return undefined;
19
+ try {
20
+ bootId ??= readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
21
+ const stat = readFileSync(`/proc/${String(pid)}/stat`, "utf8");
22
+ // The command name can contain spaces and parentheses, so fields are counted after its last closing parenthesis: field 22 is the start time.
23
+ const startTicks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
24
+ return startTicks === undefined || !/^\d+$/.test(startTicks) ? undefined : `${bootId}:${startTicks}`;
25
+ } catch { return undefined; }
26
+ }
27
+ // Unverified is not dead: an existing pid whose start was never recorded or cannot be read counts as alive.
28
+ //NOTE: without /proc (macOS, Windows) only the pid is checked, so a pid reused by an unrelated process keeps blocking until that process exits.
29
+ export function processLive(identity: ProcessIdentity): boolean {
30
+ try { process.kill(identity.pid, 0); } catch (error) { if (isNodeError(error, "ESRCH")) return false; }
31
+ if (identity.start === undefined) return true;
32
+ const current = processStart(identity.pid);
33
+ return current === undefined || current === identity.start;
34
+ }
35
+ // Only a recorded start that still matches proves the pid is that process; nothing less authorises a signal.
36
+ export function processConfirmed(identity: ProcessIdentity): boolean {
37
+ return identity.start !== undefined && processStart(identity.pid) === identity.start;
38
+ }
39
+
40
+ function holderDirectory(lockPath: string): string { return `${lockPath}.startup`; }
41
+ function holderPath(lockPath: string, token: string): string { return join(holderDirectory(lockPath), `${token}.json`); }
42
+ export function identityOf(pid: number): ProcessIdentity {
43
+ const start = processStart(pid);
44
+ return start === undefined ? { pid } : { pid, start };
45
+ }
46
+ function processIdentity(value: unknown): ProcessIdentity | undefined {
47
+ if (!object(value) || !positiveInteger(value.pid)) return undefined;
48
+ return typeof value.start === "string" ? { pid: value.pid, start: value.start } : { pid: value.pid };
49
+ }
50
+ function decodeHolder(text: string): StartupHolder | undefined {
51
+ try {
52
+ const parsed: unknown = JSON.parse(text);
53
+ const holder = processIdentity(parsed);
54
+ if (holder === undefined || !object(parsed) || typeof parsed.token !== "string") return undefined;
55
+ const server = processIdentity(parsed.server);
56
+ return { ...holder, token: parsed.token, ...(server === undefined ? {} : { server }) };
57
+ } catch { return undefined; }
58
+ }
59
+
60
+ export function readStartupHolder(lockPath: string, token: string): StartupHolder | undefined {
61
+ try { return decodeHolder(readFileSync(holderPath(lockPath, token), "utf8")); }
62
+ catch (error) { if (isNodeError(error, "ENOENT")) return undefined; throw error; }
63
+ }
64
+
65
+ // A holder's server can still publish its lock until it has done so or exited; a lock that cannot be read leaves that unknown, so the error is thrown.
66
+ async function serverPending(lockPath: string, server: ProcessIdentity): Promise<boolean> {
67
+ if (!processLive(server)) return false;
68
+ let text: string;
69
+ try { text = await readFile(lockPath, "utf8"); }
70
+ catch (error) { if (isNodeError(error, "ENOENT")) return true; throw error; }
71
+ try {
72
+ const lock: unknown = JSON.parse(text);
73
+ return !(object(lock) && lock.pid === server.pid);
74
+ } catch { return true; }
75
+ }
76
+ async function holderLive(lockPath: string, path: string, read: StartupHolder): Promise<boolean> {
77
+ if (processLive(read)) return true;
78
+ // The holder may have recorded its server after it was read; once its Pi is dead the file can no longer change, so this read is final.
79
+ let holder: StartupHolder | undefined;
80
+ try { holder = decodeHolder(await readFile(path, "utf8")); }
81
+ catch (error) { if (isNodeError(error, "ENOENT")) return false; throw error; }
82
+ if (holder?.server === undefined || !await serverPending(lockPath, holder.server)) return false;
83
+ // Its Pi died mid-startup; the server it recorded is stopped so it can never publish over the next holder's lock.
84
+ // An unconfirmed pid may now be an unrelated process, so it is left alone and the holder stays held until that pid exits.
85
+ if (!processConfirmed(holder.server)) return true;
86
+ try { process.kill(holder.server.pid, "SIGKILL"); }
87
+ catch (error) { if (!isNodeError(error, "ESRCH")) throw error; }
88
+ return true;
89
+ }
90
+
91
+ export async function tryAcquireStartupMutex(lockPath: string): Promise<string | undefined> {
92
+ const directory = holderDirectory(lockPath);
93
+ await mkdir(directory, { recursive: true, mode: 0o700 });
94
+ const token = randomUUID();
95
+ const own = `${token}.json`;
96
+ await atomicWriteFile(join(directory, own), `${JSON.stringify({ ...identityOf(process.pid), token })}\n`);
97
+ let contended = false;
98
+ try {
99
+ for (const name of await readdir(directory)) {
100
+ if (name === own || !name.endsWith(".json")) continue;
101
+ const path = join(directory, name);
102
+ let holder: StartupHolder | undefined;
103
+ try { holder = decodeHolder(await readFile(path, "utf8")); }
104
+ catch (error) { if (isNodeError(error, "ENOENT")) continue; throw error; }
105
+ if (holder !== undefined && await holderLive(lockPath, path, holder)) contended = true;
106
+ else await rm(path, { force: true });
107
+ }
108
+ } catch (error) {
109
+ // A holder file left by a failed attempt would block every later start until this Pi exits.
110
+ await rm(join(directory, own), { force: true });
111
+ throw error;
112
+ }
113
+ if (!contended) return token;
114
+ await rm(join(directory, own), { force: true });
115
+ return undefined;
116
+ }
117
+
118
+ // Called right after spawning, so the mutex stays held while that server can still publish, even if this Pi dies.
119
+ export function recordStartupServer(lockPath: string, token: string, pid: number): void {
120
+ const holder = readStartupHolder(lockPath, token);
121
+ if (holder === undefined) throw new Error("Trajectory startup mutex was lost");
122
+ atomicWriteFile(holderPath(lockPath, token), `${JSON.stringify({ ...holder, server: identityOf(pid) })}\n`, true);
123
+ }
124
+
125
+ export async function releaseStartupMutex(lockPath: string, token: string): Promise<void> {
126
+ await rm(holderPath(lockPath, token), { force: true });
127
+ }
@@ -1,70 +0,0 @@
1
- import type { AgentDefinition, AgentResourceSelectorSources, ContextFileScope, ModelSpec, WorkflowExtensionSettings, WorkflowRoleDirectoryRegistration } from "./types.js";
2
- export declare function parseRoleMarkdown(content: string, strict?: boolean, rolePath?: string): AgentDefinition;
3
- export declare function workflowRoleDirectories(agentDir?: string): readonly string[];
4
- export type WorkflowRoleDirectoryInput = string | WorkflowRoleDirectoryRegistration;
5
- export interface RoleDiscoveryOptions {
6
- cwd: string;
7
- agentDir?: string;
8
- projectTrusted?: boolean;
9
- extensionRoleDirectories?: readonly WorkflowRoleDirectoryInput[];
10
- }
11
- export interface RoleResourceCandidates {
12
- skills?: readonly string[];
13
- extensions?: readonly string[];
14
- tools?: readonly string[];
15
- }
16
- export interface RoleResolutionOptions extends RoleDiscoveryOptions {
17
- definition?: AgentDefinition;
18
- definitions?: Readonly<Record<string, AgentDefinition>>;
19
- model?: string;
20
- modelOverride?: ModelSpec;
21
- rootModel?: ModelSpec;
22
- rootTools?: ReadonlySet<string> | readonly string[];
23
- inheritedTools?: readonly string[];
24
- availableModels?: ReadonlySet<string>;
25
- knownModels?: ReadonlySet<string>;
26
- modelAliases?: Readonly<Record<string, string>>;
27
- blockedAliases?: ReadonlySet<string>;
28
- blockedAliasTargets?: Readonly<Record<string, string>>;
29
- settingsPath?: string;
30
- selectorSources?: AgentResourceSelectorSources;
31
- resources?: RoleResourceCandidates;
32
- tools?: readonly string[];
33
- skills?: readonly string[];
34
- extensions?: readonly string[];
35
- contextFiles?: readonly ContextFileScope[];
36
- effectiveTools?: readonly string[];
37
- }
38
- export interface ResolvedRole {
39
- name?: string;
40
- definition?: AgentDefinition;
41
- model?: ModelSpec;
42
- requestedModel?: string;
43
- tools?: readonly string[];
44
- prompt: string;
45
- overrideSystemPrompt: boolean;
46
- systemPrompt: {
47
- mode: "override" | "append";
48
- text: string;
49
- };
50
- contextFiles?: readonly ContextFileScope[];
51
- extensionSettings?: Readonly<WorkflowExtensionSettings>;
52
- selectorSources: AgentResourceSelectorSources;
53
- selectorLayers: {
54
- skills: readonly (readonly string[] | undefined)[];
55
- extensions: readonly (readonly string[] | undefined)[];
56
- tools: readonly (readonly string[] | undefined)[];
57
- };
58
- selectedSkills?: readonly string[];
59
- selectedExtensions?: readonly string[];
60
- unmatchedSkills?: readonly string[];
61
- unmatchedExtensions?: readonly string[];
62
- unmatchedTools?: readonly string[];
63
- }
64
- export declare function loadProjectAgentDefinitions(cwd: string): Readonly<Record<string, AgentDefinition>>;
65
- export declare function discoverRoles(input: RoleDiscoveryOptions): Readonly<Record<string, AgentDefinition>>;
66
- export declare function loadRole(name: string, input: RoleDiscoveryOptions): AgentDefinition;
67
- export declare function loadAgentDefinitions(cwd: string, agentDir?: string, projectTrusted?: boolean, extensionRoleDirectories?: readonly WorkflowRoleDirectoryInput[]): Readonly<Record<string, AgentDefinition>>;
68
- export declare function canonicalExtensionSelector(selector: string, base?: string): string;
69
- /** Without a supplied root/tool/resource boundary, this API reports selector layers but does not infer available resources; tools and model remain undefined when no candidates are supplied. When selector sources are supplied, model aliases default to none and blocked aliases are not applied unless provided. */
70
- export declare function resolveRole(name: string | undefined, options: RoleResolutionOptions): ResolvedRole;