@sapiom/harness 0.0.0 → 0.1.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 (246) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -43
  3. package/dist/cli/auth.d.ts +26 -0
  4. package/dist/cli/auth.d.ts.map +1 -0
  5. package/dist/cli/auth.js +39 -0
  6. package/dist/cli/auth.js.map +1 -0
  7. package/dist/cli/bin.d.ts +3 -0
  8. package/dist/cli/bin.d.ts.map +1 -0
  9. package/dist/cli/bin.js +163 -0
  10. package/dist/cli/bin.js.map +1 -0
  11. package/dist/cli/consent.d.ts +15 -0
  12. package/dist/cli/consent.d.ts.map +1 -0
  13. package/dist/cli/consent.js +81 -0
  14. package/dist/cli/consent.js.map +1 -0
  15. package/dist/cli/doctor.d.ts +27 -0
  16. package/dist/cli/doctor.d.ts.map +1 -0
  17. package/dist/cli/doctor.js +74 -0
  18. package/dist/cli/doctor.js.map +1 -0
  19. package/dist/cli/machine-id.d.ts +19 -0
  20. package/dist/cli/machine-id.d.ts.map +1 -0
  21. package/dist/cli/machine-id.js +75 -0
  22. package/dist/cli/machine-id.js.map +1 -0
  23. package/dist/cli/paths.d.ts +3 -0
  24. package/dist/cli/paths.d.ts.map +1 -0
  25. package/dist/cli/paths.js +11 -0
  26. package/dist/cli/paths.js.map +1 -0
  27. package/dist/cli/settings.d.ts +26 -0
  28. package/dist/cli/settings.d.ts.map +1 -0
  29. package/dist/cli/settings.js +123 -0
  30. package/dist/cli/settings.js.map +1 -0
  31. package/dist/core/adapters/claude-code.d.ts +41 -0
  32. package/dist/core/adapters/claude-code.d.ts.map +1 -0
  33. package/dist/core/adapters/claude-code.js +238 -0
  34. package/dist/core/adapters/claude-code.js.map +1 -0
  35. package/dist/core/adapters/codex.d.ts +59 -0
  36. package/dist/core/adapters/codex.d.ts.map +1 -0
  37. package/dist/core/adapters/codex.js +319 -0
  38. package/dist/core/adapters/codex.js.map +1 -0
  39. package/dist/core/canvas-body.d.ts +45 -0
  40. package/dist/core/canvas-body.d.ts.map +1 -0
  41. package/dist/core/canvas-body.js +94 -0
  42. package/dist/core/canvas-body.js.map +1 -0
  43. package/dist/core/canvas-cache.d.ts +23 -0
  44. package/dist/core/canvas-cache.d.ts.map +1 -0
  45. package/dist/core/canvas-cache.js +64 -0
  46. package/dist/core/canvas-cache.js.map +1 -0
  47. package/dist/core/canvas-enrich.d.ts +106 -0
  48. package/dist/core/canvas-enrich.d.ts.map +1 -0
  49. package/dist/core/canvas-enrich.js +178 -0
  50. package/dist/core/canvas-enrich.js.map +1 -0
  51. package/dist/core/canvas-enrichment.d.ts +144 -0
  52. package/dist/core/canvas-enrichment.d.ts.map +1 -0
  53. package/dist/core/canvas-enrichment.js +192 -0
  54. package/dist/core/canvas-enrichment.js.map +1 -0
  55. package/dist/core/canvas-graph.d.ts +71 -0
  56. package/dist/core/canvas-graph.d.ts.map +1 -0
  57. package/dist/core/canvas-graph.js +111 -0
  58. package/dist/core/canvas-graph.js.map +1 -0
  59. package/dist/core/canvas-index-classify.d.ts +41 -0
  60. package/dist/core/canvas-index-classify.d.ts.map +1 -0
  61. package/dist/core/canvas-index-classify.js +63 -0
  62. package/dist/core/canvas-index-classify.js.map +1 -0
  63. package/dist/core/canvas-interconnections.d.ts +24 -0
  64. package/dist/core/canvas-interconnections.d.ts.map +1 -0
  65. package/dist/core/canvas-interconnections.js +96 -0
  66. package/dist/core/canvas-interconnections.js.map +1 -0
  67. package/dist/core/canvas-manifest-check.d.ts +15 -0
  68. package/dist/core/canvas-manifest-check.d.ts.map +1 -0
  69. package/dist/core/canvas-manifest-check.js +90 -0
  70. package/dist/core/canvas-manifest-check.js.map +1 -0
  71. package/dist/core/canvas-render.d.ts +73 -0
  72. package/dist/core/canvas-render.d.ts.map +1 -0
  73. package/dist/core/canvas-render.js +145 -0
  74. package/dist/core/canvas-render.js.map +1 -0
  75. package/dist/core/canvas-svg.d.ts +50 -0
  76. package/dist/core/canvas-svg.d.ts.map +1 -0
  77. package/dist/core/canvas-svg.js +240 -0
  78. package/dist/core/canvas-svg.js.map +1 -0
  79. package/dist/core/canvas-template.d.ts +43 -0
  80. package/dist/core/canvas-template.d.ts.map +1 -0
  81. package/dist/core/canvas-template.js +365 -0
  82. package/dist/core/canvas-template.js.map +1 -0
  83. package/dist/core/canvas-watcher.d.ts +25 -0
  84. package/dist/core/canvas-watcher.d.ts.map +1 -0
  85. package/dist/core/canvas-watcher.js +144 -0
  86. package/dist/core/canvas-watcher.js.map +1 -0
  87. package/dist/core/collector/batcher.d.ts +57 -0
  88. package/dist/core/collector/batcher.d.ts.map +1 -0
  89. package/dist/core/collector/batcher.js +143 -0
  90. package/dist/core/collector/batcher.js.map +1 -0
  91. package/dist/core/collector/codex-tailer.d.ts +85 -0
  92. package/dist/core/collector/codex-tailer.d.ts.map +1 -0
  93. package/dist/core/collector/codex-tailer.js +270 -0
  94. package/dist/core/collector/codex-tailer.js.map +1 -0
  95. package/dist/core/collector/normalizer.d.ts +33 -0
  96. package/dist/core/collector/normalizer.d.ts.map +1 -0
  97. package/dist/core/collector/normalizer.js +94 -0
  98. package/dist/core/collector/normalizer.js.map +1 -0
  99. package/dist/core/collector/seq.d.ts +14 -0
  100. package/dist/core/collector/seq.d.ts.map +1 -0
  101. package/dist/core/collector/seq.js +20 -0
  102. package/dist/core/collector/seq.js.map +1 -0
  103. package/dist/core/collector/store.d.ts +16 -0
  104. package/dist/core/collector/store.d.ts.map +1 -0
  105. package/dist/core/collector/store.js +30 -0
  106. package/dist/core/collector/store.js.map +1 -0
  107. package/dist/core/collector/transcript.d.ts +38 -0
  108. package/dist/core/collector/transcript.d.ts.map +1 -0
  109. package/dist/core/collector/transcript.js +110 -0
  110. package/dist/core/collector/transcript.js.map +1 -0
  111. package/dist/core/event-bus.d.ts +9 -0
  112. package/dist/core/event-bus.d.ts.map +1 -0
  113. package/dist/core/event-bus.js +25 -0
  114. package/dist/core/event-bus.js.map +1 -0
  115. package/dist/core/example-seed.d.ts +34 -0
  116. package/dist/core/example-seed.d.ts.map +1 -0
  117. package/dist/core/example-seed.js +277 -0
  118. package/dist/core/example-seed.js.map +1 -0
  119. package/dist/core/inject/claude-settings.d.ts +34 -0
  120. package/dist/core/inject/claude-settings.d.ts.map +1 -0
  121. package/dist/core/inject/claude-settings.js +152 -0
  122. package/dist/core/inject/claude-settings.js.map +1 -0
  123. package/dist/core/inject/mcp-config.d.ts +26 -0
  124. package/dist/core/inject/mcp-config.d.ts.map +1 -0
  125. package/dist/core/inject/mcp-config.js +39 -0
  126. package/dist/core/inject/mcp-config.js.map +1 -0
  127. package/dist/core/inject/retention.d.ts +66 -0
  128. package/dist/core/inject/retention.d.ts.map +1 -0
  129. package/dist/core/inject/retention.js +119 -0
  130. package/dist/core/inject/retention.js.map +1 -0
  131. package/dist/core/inject/system-prompt.d.ts +11 -0
  132. package/dist/core/inject/system-prompt.d.ts.map +1 -0
  133. package/dist/core/inject/system-prompt.js +22 -0
  134. package/dist/core/inject/system-prompt.js.map +1 -0
  135. package/dist/core/macro-runner.d.ts +36 -0
  136. package/dist/core/macro-runner.d.ts.map +1 -0
  137. package/dist/core/macro-runner.js +66 -0
  138. package/dist/core/macro-runner.js.map +1 -0
  139. package/dist/core/macros.d.ts +10 -0
  140. package/dist/core/macros.d.ts.map +1 -0
  141. package/dist/core/macros.js +59 -0
  142. package/dist/core/macros.js.map +1 -0
  143. package/dist/core/path-safety.d.ts +33 -0
  144. package/dist/core/path-safety.d.ts.map +1 -0
  145. package/dist/core/path-safety.js +63 -0
  146. package/dist/core/path-safety.js.map +1 -0
  147. package/dist/core/paths.d.ts +27 -0
  148. package/dist/core/paths.d.ts.map +1 -0
  149. package/dist/core/paths.js +43 -0
  150. package/dist/core/paths.js.map +1 -0
  151. package/dist/core/port-detector.d.ts +69 -0
  152. package/dist/core/port-detector.d.ts.map +1 -0
  153. package/dist/core/port-detector.js +121 -0
  154. package/dist/core/port-detector.js.map +1 -0
  155. package/dist/core/session-manager.d.ts +275 -0
  156. package/dist/core/session-manager.d.ts.map +1 -0
  157. package/dist/core/session-manager.js +729 -0
  158. package/dist/core/session-manager.js.map +1 -0
  159. package/dist/core/task-manager.d.ts +138 -0
  160. package/dist/core/task-manager.d.ts.map +1 -0
  161. package/dist/core/task-manager.js +286 -0
  162. package/dist/core/task-manager.js.map +1 -0
  163. package/dist/core/task-stream.d.ts +32 -0
  164. package/dist/core/task-stream.d.ts.map +1 -0
  165. package/dist/core/task-stream.js +87 -0
  166. package/dist/core/task-stream.js.map +1 -0
  167. package/dist/core/workflow-registry.d.ts +36 -0
  168. package/dist/core/workflow-registry.d.ts.map +1 -0
  169. package/dist/core/workflow-registry.js +220 -0
  170. package/dist/core/workflow-registry.js.map +1 -0
  171. package/dist/core/workspace-context.d.ts +43 -0
  172. package/dist/core/workspace-context.d.ts.map +1 -0
  173. package/dist/core/workspace-context.js +108 -0
  174. package/dist/core/workspace-context.js.map +1 -0
  175. package/dist/core/workspace-watcher.d.ts +26 -0
  176. package/dist/core/workspace-watcher.d.ts.map +1 -0
  177. package/dist/core/workspace-watcher.js +174 -0
  178. package/dist/core/workspace-watcher.js.map +1 -0
  179. package/dist/index.d.ts +6 -0
  180. package/dist/index.d.ts.map +1 -0
  181. package/dist/index.js +6 -0
  182. package/dist/index.js.map +1 -0
  183. package/dist/profiles/canvas-guidelines.d.ts +20 -0
  184. package/dist/profiles/canvas-guidelines.d.ts.map +1 -0
  185. package/dist/profiles/canvas-guidelines.js +48 -0
  186. package/dist/profiles/canvas-guidelines.js.map +1 -0
  187. package/dist/profiles/default.d.ts +10 -0
  188. package/dist/profiles/default.d.ts.map +1 -0
  189. package/dist/profiles/default.js +70 -0
  190. package/dist/profiles/default.js.map +1 -0
  191. package/dist/server/auth.d.ts +9 -0
  192. package/dist/server/auth.d.ts.map +1 -0
  193. package/dist/server/auth.js +28 -0
  194. package/dist/server/auth.js.map +1 -0
  195. package/dist/server/canvas-render.d.ts +18 -0
  196. package/dist/server/canvas-render.d.ts.map +1 -0
  197. package/dist/server/canvas-render.js +24 -0
  198. package/dist/server/canvas-render.js.map +1 -0
  199. package/dist/server/canvas.d.ts +12 -0
  200. package/dist/server/canvas.d.ts.map +1 -0
  201. package/dist/server/canvas.js +174 -0
  202. package/dist/server/canvas.js.map +1 -0
  203. package/dist/server/events-ws.d.ts +11 -0
  204. package/dist/server/events-ws.d.ts.map +1 -0
  205. package/dist/server/events-ws.js +24 -0
  206. package/dist/server/events-ws.js.map +1 -0
  207. package/dist/server/fs.d.ts +4 -0
  208. package/dist/server/fs.d.ts.map +1 -0
  209. package/dist/server/fs.js +88 -0
  210. package/dist/server/fs.js.map +1 -0
  211. package/dist/server/index.d.ts +83 -0
  212. package/dist/server/index.d.ts.map +1 -0
  213. package/dist/server/index.js +0 -0
  214. package/dist/server/index.js.map +1 -0
  215. package/dist/server/ingest.d.ts +74 -0
  216. package/dist/server/ingest.d.ts.map +1 -0
  217. package/dist/server/ingest.js +74 -0
  218. package/dist/server/ingest.js.map +1 -0
  219. package/dist/server/macros.d.ts +32 -0
  220. package/dist/server/macros.d.ts.map +1 -0
  221. package/dist/server/macros.js +76 -0
  222. package/dist/server/macros.js.map +1 -0
  223. package/dist/server/rest.d.ts +75 -0
  224. package/dist/server/rest.d.ts.map +1 -0
  225. package/dist/server/rest.js +235 -0
  226. package/dist/server/rest.js.map +1 -0
  227. package/dist/server/static.d.ts +8 -0
  228. package/dist/server/static.d.ts.map +1 -0
  229. package/dist/server/static.js +36 -0
  230. package/dist/server/static.js.map +1 -0
  231. package/dist/server/terminal-ws.d.ts +9 -0
  232. package/dist/server/terminal-ws.d.ts.map +1 -0
  233. package/dist/server/terminal-ws.js +59 -0
  234. package/dist/server/terminal-ws.js.map +1 -0
  235. package/dist/server/ws-router.d.ts +16 -0
  236. package/dist/server/ws-router.d.ts.map +1 -0
  237. package/dist/server/ws-router.js +26 -0
  238. package/dist/server/ws-router.js.map +1 -0
  239. package/dist/shared/types.d.ts +485 -0
  240. package/dist/shared/types.d.ts.map +1 -0
  241. package/dist/shared/types.js +80 -0
  242. package/dist/shared/types.js.map +1 -0
  243. package/dist/web/assets/index-B9R4TzH7.css +32 -0
  244. package/dist/web/assets/index-Dh97QAgq.js +192 -0
  245. package/dist/web/index.html +27 -0
  246. package/package.json +84 -7
@@ -0,0 +1,729 @@
1
+ /**
2
+ * SessionManager — node-pty registry implementing the session lifecycle from
3
+ * the shared contract: create, resume, kill, list. Persists HarnessSession[]
4
+ * to disk (HARNESS_PATHS.sessions) so the SPA's session dropdown survives
5
+ * server restarts, even though the ptys themselves do not.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { EventEmitter } from "node:events";
9
+ import { mkdir, readFile, rename, writeFile, chmod } from "node:fs/promises";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { createRequire } from "node:module";
12
+ import { ENV, HARNESS_PATHS, } from "../shared/types.js";
13
+ import { expandHome } from "./paths.js";
14
+ let defaultSpawn;
15
+ let defaultSpawnError;
16
+ /**
17
+ * node-pty ships prebuilt native binaries (including a tiny `spawn-helper`
18
+ * on macOS/Linux) rather than compiling from source. Observed in the wild:
19
+ * a pnpm-managed install can extract that helper without its executable bit
20
+ * set, which fails every single spawn with an opaque "posix_spawnp failed"
21
+ * — nothing to do with the harness's own code, but fatal to every session
22
+ * launch. Best-effort self-heal before the first real spawn; silently a
23
+ * no-op if the file's missing (wrong platform/arch) or already executable.
24
+ * Exported so scripts/e2e-live.ts's preflight check shares this exact fix
25
+ * instead of duplicating it.
26
+ */
27
+ export async function ensureSpawnHelperExecutable() {
28
+ if (process.platform === "win32")
29
+ return;
30
+ try {
31
+ const nodePtyPkgJson = createRequire(import.meta.url).resolve("node-pty/package.json");
32
+ const helperPath = join(dirname(nodePtyPkgJson), "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper");
33
+ await chmod(helperPath, 0o755);
34
+ }
35
+ catch {
36
+ // Not present for this platform/arch — nothing to fix.
37
+ }
38
+ }
39
+ async function loadDefaultSpawn() {
40
+ if (defaultSpawn)
41
+ return defaultSpawn;
42
+ if (defaultSpawnError)
43
+ throw defaultSpawnError;
44
+ try {
45
+ await ensureSpawnHelperExecutable();
46
+ const nodePty = await import("node-pty");
47
+ defaultSpawn = nodePty.spawn;
48
+ return defaultSpawn;
49
+ }
50
+ catch (err) {
51
+ defaultSpawnError = err instanceof Error ? err : new Error(String(err));
52
+ throw defaultSpawnError;
53
+ }
54
+ }
55
+ const DEFAULT_COLS = 80;
56
+ const DEFAULT_ROWS = 24;
57
+ /** Bytes of terminal output retained per session for replay on WS (re)attach. */
58
+ const SCROLLBACK_BYTES = 131072;
59
+ /**
60
+ * Delay between writing prompt text and a trailing Enter, when submitting
61
+ * non-empty text in one call (see `submitInput`). Claude Code — like many
62
+ * bracketed-paste-aware TUIs — treats a single write containing both text
63
+ * and a newline as one paste event: the newline lands inside the pasted
64
+ * content instead of registering as a separate "submit" keypress, so the
65
+ * prompt sits in the input box and is never sent. Splitting the write with
66
+ * a short delay makes the terminal see two distinct input events instead —
67
+ * a paste, then a separate Enter.
68
+ */
69
+ const SUBMIT_DELAY_MS = 300;
70
+ /** See `kill()`: how long to wait for a graceful exit before escalating to SIGKILL. */
71
+ const KILL_ESCALATION_MS = 2000;
72
+ /** See `kill()`: how long after escalating to give node-pty one last chance
73
+ * to report the exit itself before synthesizing it from an OS-level check. */
74
+ const KILL_ESCALATION_CONFIRM_MS = 500;
75
+ /**
76
+ * See `isReadyEnough()`: for a harness with `detectBlockingPrompt`, how long
77
+ * to give the pty to render its first real frame before trusting a clean
78
+ * scrollback (no known blocking prompt) as a genuine "no prompt showing"
79
+ * rather than just "hasn't drawn anything yet".
80
+ */
81
+ const READY_SETTLE_MS = 700;
82
+ /** See `isReadyEnough()`: how much of the tail of retained scrollback to
83
+ * scan for a blocking prompt — recent output only, not the full history a
84
+ * full-screen TUI never truly clears. Generous relative to one redraw
85
+ * frame (confirmed against a real capture: a single Codex trust-prompt
86
+ * frame is well under 2KB) without re-scanning unbounded history. */
87
+ const BLOCKING_PROMPT_SCAN_BYTES = 4096;
88
+ /**
89
+ * See `submitInput()`: how long to wait for a not-yet-ready session to
90
+ * become ready before giving up and throwing `SessionNotReadyError`. Covers
91
+ * the ordinary "macro fired a beat before onboarding finished" case without
92
+ * making a genuinely stuck session (real trust prompt sitting unanswered)
93
+ * hang the caller for long before surfacing something actionable.
94
+ */
95
+ const READY_GRACE_MS = 8000;
96
+ /** Poll interval while waiting out READY_GRACE_MS. */
97
+ const READY_POLL_MS = 150;
98
+ /** See `recordActivity()`: minimum gap between two `onActivity` broadcasts
99
+ * for the same session — pty.onData fires per chunk (often many times a
100
+ * second for a busy TUI), but the SPA's busy indicator only needs "this
101
+ * session produced output recently", not every individual chunk. */
102
+ const ACTIVITY_BROADCAST_THROTTLE_MS = 2000;
103
+ /**
104
+ * See `sweepDeadSessions()`: how long a non-exited session record may sit
105
+ * with no pty handle at all before the sweep declares it dead. There is one
106
+ * legitimate window where that state exists — inside `create()`/`resume()`,
107
+ * between persisting the record and attaching the freshly-spawned pty (a few
108
+ * awaited config-file writes plus the spawn itself, normally well under a
109
+ * second) — so this just needs to comfortably exceed that window, not be
110
+ * fast: the sweep is a backstop, not the primary reconciliation.
111
+ */
112
+ const NO_PTY_SWEEP_GRACE_MS = 30000;
113
+ /**
114
+ * OS-level "does this process exist" check — the same probe `kill()`'s
115
+ * missed-exit fallback has always used, factored out so the liveness sweep
116
+ * shares it. EPERM means the process exists but isn't ours to signal, i.e.
117
+ * alive; anything else (ESRCH) means it's gone.
118
+ */
119
+ const defaultIsPidAlive = (pid) => {
120
+ try {
121
+ process.kill(pid, 0);
122
+ return true;
123
+ }
124
+ catch (err) {
125
+ return err.code === "EPERM";
126
+ }
127
+ };
128
+ /**
129
+ * Thrown by `submitInput()` when a session's pty is alive but never became
130
+ * ready (see `HarnessSession.ready`) within `READY_GRACE_MS` — the trust-
131
+ * dialog race this whole readiness mechanism exists to catch. Callers
132
+ * (rest.ts, macros.ts) catch this specifically and surface it as a 409 with
133
+ * a UI-visible reason, rather than letting it fall into a generic 500 or
134
+ * (worse, the bug this fixes) silently writing into a non-interactive TUI.
135
+ */
136
+ export class SessionNotReadyError extends Error {
137
+ constructor(id) {
138
+ super(`Session "${id}" is not ready yet — check the terminal, it may be asking to trust the folder.`);
139
+ this.name = "SessionNotReadyError";
140
+ }
141
+ }
142
+ function sleep(ms) {
143
+ return new Promise((resolve) => setTimeout(resolve, ms));
144
+ }
145
+ const defaultBuildLaunchOpts = () => ({});
146
+ export class SessionManager {
147
+ constructor(options) {
148
+ this.sessions = new Map();
149
+ this.ptys = new Map();
150
+ this.statusEmitter = new EventEmitter();
151
+ this.activityEmitter = new EventEmitter();
152
+ /** Epoch ms of the last `onActivity` broadcast per session — see `recordActivity()`. */
153
+ this.lastActivityBroadcast = new Map();
154
+ this.writeQueue = Promise.resolve();
155
+ this.writeSeq = 0;
156
+ this.initialized = false;
157
+ this.adapters = options.adapters;
158
+ this.ingestUrl = options.ingestUrl;
159
+ this.ingestToken = options.ingestToken;
160
+ this.collectorUrl = options.collectorUrl;
161
+ this.sessionsPath = expandHome(options.sessionsPath ?? HARNESS_PATHS.sessions);
162
+ this.spawnPty = options.spawnPty;
163
+ this.buildLaunchOpts = options.buildLaunchOpts ?? defaultBuildLaunchOpts;
164
+ this.now = options.now ?? (() => new Date().toISOString());
165
+ this.generateId = options.generateId ?? randomUUID;
166
+ this.writeWorkspaceContext = options.writeWorkspaceContext ?? (async () => { });
167
+ this.workspaceContextExists = options.workspaceContextExists ?? (async () => true);
168
+ this.ensureCanvasTemplate = options.ensureCanvasTemplate ?? (async () => { });
169
+ this.isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
170
+ // Many WS clients (terminal + events) can subscribe over a long-running process.
171
+ this.statusEmitter.setMaxListeners(0);
172
+ this.activityEmitter.setMaxListeners(0);
173
+ }
174
+ /**
175
+ * Loads the persisted registry. Any session left "starting"/"running" from
176
+ * a previous process is marked "exited" — ptys don't survive a restart.
177
+ */
178
+ async init() {
179
+ if (this.initialized)
180
+ return;
181
+ this.initialized = true;
182
+ let persisted = [];
183
+ try {
184
+ const raw = await readFile(this.sessionsPath, "utf8");
185
+ persisted = JSON.parse(raw);
186
+ }
187
+ catch (err) {
188
+ if (err.code !== "ENOENT")
189
+ throw err;
190
+ }
191
+ let dirty = false;
192
+ for (const session of persisted) {
193
+ if (session.status !== "exited") {
194
+ session.status = "exited";
195
+ session.exitCode = session.exitCode ?? null;
196
+ dirty = true;
197
+ }
198
+ this.sessions.set(session.id, session);
199
+ }
200
+ if (dirty)
201
+ await this.persist();
202
+ }
203
+ list() {
204
+ return Array.from(this.sessions.values());
205
+ }
206
+ get(id) {
207
+ return this.sessions.get(id);
208
+ }
209
+ getAdapter(harness) {
210
+ const adapter = this.adapters[harness];
211
+ if (!adapter)
212
+ throw new Error(`No adapter registered for harness "${harness}"`);
213
+ return adapter;
214
+ }
215
+ async create(req) {
216
+ const id = this.generateId();
217
+ const adapter = this.getAdapter(req.harness);
218
+ const opts = {
219
+ harnessSessionId: id,
220
+ cwd: req.cwd,
221
+ ...(await this.buildLaunchOpts(id, req)),
222
+ };
223
+ const spec = adapter.launch(opts);
224
+ const session = {
225
+ id,
226
+ agentSessionId: null,
227
+ harness: req.harness,
228
+ cwd: req.cwd,
229
+ title: basename(req.cwd) || req.cwd,
230
+ status: "starting",
231
+ createdAt: this.now(),
232
+ lastActiveAt: this.now(),
233
+ exitCode: null,
234
+ boundWorkflowPath: null,
235
+ ready: false,
236
+ };
237
+ this.sessions.set(id, session);
238
+ await this.persist();
239
+ try {
240
+ // Before spawning, not fire-and-forget: the agent's very first read of
241
+ // HARNESS_CONTEXT_FILE must never race session creation with an ENOENT,
242
+ // regardless of which entry point called create() (REST, autoCreateSession).
243
+ await this.writeWorkspaceContext(session);
244
+ // Same reasoning: the canvas pane opens immediately once the session is
245
+ // "running" — it must never show a bare empty iframe because nothing's
246
+ // been written to .sapiom/canvas/index.html yet.
247
+ await this.ensureCanvasTemplate(session.cwd);
248
+ await this.spawn(session, spec);
249
+ }
250
+ catch (err) {
251
+ // The record was already persisted as "starting" above; a failure
252
+ // anywhere before the pty is live must reconcile it to "exited" or it
253
+ // lingers forever as a ghost tab (non-exited status, no pty behind it).
254
+ await this.transitionExited(session, null);
255
+ throw err;
256
+ }
257
+ return session;
258
+ }
259
+ /**
260
+ * Registers a purely historical (never-launched-by-this-harness) session so
261
+ * it can subsequently be resumed via `resume()`. Used by the server layer
262
+ * when a user picks a past session out of transcript-scanned history.
263
+ */
264
+ registerHistorical(input) {
265
+ const id = this.generateId();
266
+ const session = {
267
+ id,
268
+ agentSessionId: input.agentSessionId,
269
+ harness: input.harness,
270
+ cwd: input.cwd,
271
+ title: input.title,
272
+ status: "exited",
273
+ createdAt: input.lastActiveAt,
274
+ lastActiveAt: input.lastActiveAt,
275
+ exitCode: null,
276
+ boundWorkflowPath: null,
277
+ ready: false,
278
+ };
279
+ this.sessions.set(id, session);
280
+ void this.persist();
281
+ return session;
282
+ }
283
+ async resume(id) {
284
+ const session = this.sessions.get(id);
285
+ if (!session)
286
+ throw new Error(`Unknown session "${id}"`);
287
+ if (!session.agentSessionId) {
288
+ throw new Error(`Session "${id}" has no agentSessionId to resume from`);
289
+ }
290
+ if (this.ptys.has(id)) {
291
+ throw new Error(`Session "${id}" already has a live pty`);
292
+ }
293
+ const adapter = this.getAdapter(session.harness);
294
+ const opts = {
295
+ harnessSessionId: id,
296
+ cwd: session.cwd,
297
+ ...(await this.buildLaunchOpts(id, session)),
298
+ };
299
+ const spec = adapter.resume(session.agentSessionId, opts);
300
+ session.status = "starting";
301
+ session.exitCode = null;
302
+ session.lastActiveAt = this.now();
303
+ await this.persist();
304
+ this.emitStatus(session);
305
+ try {
306
+ // Backfill only — never overwrite a file that could already reflect a
307
+ // real binding. The caller resolves session.boundWorkflowPath against
308
+ // the live registry, so unlike the old cwd-only signature this actually
309
+ // reconstructs the real binding on backfill, not just `null`.
310
+ if (!(await this.workspaceContextExists(session.cwd))) {
311
+ await this.writeWorkspaceContext(session);
312
+ }
313
+ // Also backfill-only (ensureCanvasTemplate does its own existence check)
314
+ // — a session from before the canvas kit existed, or one whose canvas
315
+ // file was somehow deleted, still gets a live pane on resume.
316
+ await this.ensureCanvasTemplate(session.cwd);
317
+ await this.spawn(session, spec);
318
+ }
319
+ catch (err) {
320
+ // Same reconciliation as create(): the record just went back to
321
+ // "starting" and was persisted — a failure before the new pty is live
322
+ // must not leave it stranded there with nothing behind it.
323
+ await this.transitionExited(session, null);
324
+ throw err;
325
+ }
326
+ return session;
327
+ }
328
+ kill(id) {
329
+ const handle = this.ptys.get(id);
330
+ if (!handle) {
331
+ // A non-exited record with no pty behind it has nothing left to kill —
332
+ // it's a ghost (its pty died without the exit ever being recorded).
333
+ // Reconcile it here so closing the tab actually closes it, instead of
334
+ // returning false and leaving an unclosable non-exited record.
335
+ const session = this.sessions.get(id);
336
+ if (session && session.status !== "exited") {
337
+ void this.transitionExited(session, null);
338
+ return true;
339
+ }
340
+ return false;
341
+ }
342
+ handle.pty.kill();
343
+ // Root-caused via instrumented real-process runs: node-pty's `onExit`
344
+ // can simply never fire for a pty killed within milliseconds of being
345
+ // spawned — confirmed by `process.kill(pid, 0)` throwing ESRCH (no such
346
+ // process) well after the graceful signal, i.e. the OS process really
347
+ // is already gone; node-pty's own exit-reporting just missed it. So a
348
+ // stronger signal alone doesn't help (there's nothing left to signal) —
349
+ // the fallback below re-checks after a grace period, and if the pty is
350
+ // still "running" in our registry but the OS confirms the process no
351
+ // longer exists, synthesizes the exit ourselves rather than waiting on
352
+ // an event that isn't coming. If the process genuinely is still alive
353
+ // (the ordinary case: kill() just hasn't taken effect yet), send SIGKILL
354
+ // as a real escalation before the same check.
355
+ const escalate = setTimeout(() => {
356
+ if (this.ptys.get(id) !== handle)
357
+ return;
358
+ const pid = handle.pty.pid;
359
+ if (this.isPidAlive(pid))
360
+ handle.pty.kill("SIGKILL");
361
+ setTimeout(() => {
362
+ if (this.ptys.get(id) === handle && !this.isPidAlive(pid))
363
+ this.markExited(id, handle, null);
364
+ }, KILL_ESCALATION_CONFIRM_MS).unref?.();
365
+ }, KILL_ESCALATION_MS);
366
+ escalate.unref?.();
367
+ return true;
368
+ }
369
+ /** Kills every currently-live pty. Call this on server shutdown — without
370
+ * it, spawned agent processes (claude/codex) outlive the harness server
371
+ * itself, e.g. after Ctrl+C, since closing the HTTP/WS server doesn't
372
+ * touch unrelated child processes on its own. */
373
+ killAll() {
374
+ for (const id of [...this.ptys.keys()])
375
+ this.kill(id);
376
+ }
377
+ /**
378
+ * Defensive liveness backstop, run periodically by the server: any
379
+ * non-exited session whose pty process is provably gone gets its exit
380
+ * synthesized. The specific transitions are already reconciled at their
381
+ * source (create/resume pre-pty failures, kill()'s missed-exit fallback),
382
+ * but node-pty has been observed to simply never fire `onExit` for a
383
+ * process that died moments after spawning (see `kill()`) — and a process
384
+ * that dies *on its own* that way has no kill()-style fallback watching
385
+ * it. This sweep is the catch-all for that and any transition not yet
386
+ * root-caused: a stale record shows as a ghost tab (non-exited status, no
387
+ * live pty) until something reconciles it.
388
+ */
389
+ sweepDeadSessions() {
390
+ for (const session of [...this.sessions.values()]) {
391
+ if (session.status === "exited")
392
+ continue;
393
+ const handle = this.ptys.get(session.id);
394
+ if (handle) {
395
+ // Guard against non-numeric pids (test fakes) — never probe the OS
396
+ // with a garbage value, and never declare a session dead on one.
397
+ if (typeof handle.pty.pid === "number" && !this.isPidAlive(handle.pty.pid)) {
398
+ this.markExited(session.id, handle, null);
399
+ }
400
+ continue;
401
+ }
402
+ // No pty handle at all. Within create()/resume() there's a legitimate
403
+ // pre-spawn window where the persisted record briefly looks like this,
404
+ // so only sweep records older than the grace period (an unparseable
405
+ // lastActiveAt is garbage and sweeps immediately).
406
+ const ageMs = Date.now() - Date.parse(session.lastActiveAt);
407
+ if (!(ageMs < NO_PTY_SWEEP_GRACE_MS))
408
+ void this.transitionExited(session, null);
409
+ }
410
+ }
411
+ write(id, data) {
412
+ const handle = this.ptys.get(id);
413
+ if (!handle)
414
+ return false;
415
+ handle.pty.write(data);
416
+ const session = this.sessions.get(id);
417
+ if (session) {
418
+ session.lastActiveAt = this.now();
419
+ void this.persist();
420
+ }
421
+ return true;
422
+ }
423
+ /**
424
+ * Inject a discrete prompt (macros, the Visualize button, `/api/sessions/:id/input`)
425
+ * with proper submit semantics — distinct from `write()`, which is a raw
426
+ * passthrough for live keystrokes from the terminal WS and must never add
427
+ * this delay/splitting behavior. See `SUBMIT_DELAY_MS` for why non-empty
428
+ * submitted text can't just be written as `${text}\r` in one call.
429
+ *
430
+ * Gated on readiness (see `HarnessSession.ready` / `isReadyEnough`): a
431
+ * "running" pty can still be sitting on a blocking prompt that swallows
432
+ * whatever's written to it. Briefly waits out `READY_GRACE_MS` for the
433
+ * ordinary "fired a beat too early" case; throws `SessionNotReadyError`
434
+ * — never silently proceeds — if the session still isn't ready after
435
+ * that, so the caller (rest.ts, macros.ts) can surface a clear reason
436
+ * instead of the input just vanishing.
437
+ */
438
+ async submitInput(id, text, submit = true) {
439
+ const handle = this.ptys.get(id);
440
+ if (!handle)
441
+ return false;
442
+ const session = this.sessions.get(id);
443
+ if (!session)
444
+ return false;
445
+ if (!this.isReadyEnough(session, handle)) {
446
+ const becameReady = await this.waitUntilReady(id, READY_GRACE_MS);
447
+ if (!becameReady)
448
+ throw new SessionNotReadyError(id);
449
+ // waitUntilReady only confirms readiness, not that the same pty is
450
+ // still the live one — re-fetch in case it was killed/replaced (e.g.
451
+ // a resume) while we were waiting, same as the mid-write race below.
452
+ if (this.ptys.get(id) !== handle)
453
+ return false;
454
+ }
455
+ if (!submit) {
456
+ handle.pty.write(text);
457
+ }
458
+ else if (text.length === 0) {
459
+ handle.pty.write("\r");
460
+ }
461
+ else {
462
+ handle.pty.write(text);
463
+ await sleep(SUBMIT_DELAY_MS);
464
+ // The pty may have been killed/replaced while we were waiting.
465
+ if (this.ptys.get(id) !== handle)
466
+ return false;
467
+ handle.pty.write("\r");
468
+ }
469
+ session.lastActiveAt = this.now();
470
+ void this.persist();
471
+ return true;
472
+ }
473
+ resize(id, cols, rows) {
474
+ const handle = this.ptys.get(id);
475
+ if (!handle)
476
+ return false;
477
+ handle.pty.resize(cols, rows);
478
+ return true;
479
+ }
480
+ /**
481
+ * Subscribe to a session's output. Replays the retained scrollback buffer
482
+ * synchronously before returning so a reconnecting WS client sees recent
483
+ * output immediately. Returns undefined if the session has no live pty.
484
+ */
485
+ attach(id, listener) {
486
+ const handle = this.ptys.get(id);
487
+ if (!handle)
488
+ return undefined;
489
+ if (handle.buffer)
490
+ listener(handle.buffer);
491
+ handle.emitter.on("data", listener);
492
+ return () => handle.emitter.off("data", listener);
493
+ }
494
+ onStatusChange(listener) {
495
+ this.statusEmitter.on("status", listener);
496
+ return () => {
497
+ this.statusEmitter.off("status", listener);
498
+ };
499
+ }
500
+ /**
501
+ * Subscribe to a session producing terminal output — throttled (see
502
+ * `ACTIVITY_BROADCAST_THROTTLE_MS`), not one event per pty.onData chunk.
503
+ * Fires for every session regardless of whether anything has its
504
+ * /ws/terminal socket open, unlike `attach()`.
505
+ */
506
+ onActivity(listener) {
507
+ this.activityEmitter.on("activity", listener);
508
+ return () => {
509
+ this.activityEmitter.off("activity", listener);
510
+ };
511
+ }
512
+ /**
513
+ * Leading-edge throttle: broadcasts immediately on the first byte after a
514
+ * quiet period, then drops everything else for this session until
515
+ * `ACTIVITY_BROADCAST_THROTTLE_MS` has elapsed — a busy TUI's onData fires
516
+ * far more often than that, and callers (the SPA's per-tab pulse) only
517
+ * care that the session is active right now, not each individual chunk.
518
+ */
519
+ recordActivity(id) {
520
+ const now = Date.now();
521
+ const last = this.lastActivityBroadcast.get(id) ?? 0;
522
+ if (now - last < ACTIVITY_BROADCAST_THROTTLE_MS)
523
+ return;
524
+ this.lastActivityBroadcast.set(id, now);
525
+ this.activityEmitter.emit("activity", id);
526
+ }
527
+ setAgentSessionId(id, agentSessionId) {
528
+ const session = this.sessions.get(id);
529
+ if (!session || session.agentSessionId === agentSessionId)
530
+ return;
531
+ session.agentSessionId = agentSessionId;
532
+ void this.persist();
533
+ this.emitStatus(session);
534
+ }
535
+ /**
536
+ * Marks a session's TUI as genuinely interactive — see `HarnessSession.ready`.
537
+ * Called from the ingest pipeline when a SessionStart(-equivalent) event
538
+ * is processed for this session (real hook for Claude Code, tailer-
539
+ * translated for Codex). Idempotent; a session that's exited or already
540
+ * ready is a silent no-op.
541
+ */
542
+ setReady(id) {
543
+ const session = this.sessions.get(id);
544
+ if (!session || session.ready)
545
+ return;
546
+ session.ready = true;
547
+ void this.persist();
548
+ this.emitStatus(session);
549
+ }
550
+ /**
551
+ * Whether `id` should be treated as ready to receive programmatic input
552
+ * right now — `session.ready` (the real signal), OR, for a harness that
553
+ * declares `detectBlockingPrompt` (currently: Codex, whose rollout file —
554
+ * and therefore its SessionStart-equivalent — isn't written until the
555
+ * *first* turn is submitted, so the real signal can never arrive before
556
+ * the very first injection that needs it): the pty has had a moment to
557
+ * render its first real frame (`READY_SETTLE_MS`) and that frame doesn't
558
+ * show a known blocking prompt. A harness without `detectBlockingPrompt`
559
+ * (Claude Code) gets no such fallback — its SessionStart hook is reliable
560
+ * standalone, and a scrollback guess would just reopen the exact race
561
+ * this mechanism exists to close.
562
+ */
563
+ isReadyEnough(session, handle) {
564
+ if (session.ready)
565
+ return true;
566
+ const adapter = this.adapters[session.harness];
567
+ if (!adapter?.detectBlockingPrompt)
568
+ return false;
569
+ if (Date.now() - handle.spawnedAt < READY_SETTLE_MS)
570
+ return false;
571
+ // Only the tail: `handle.buffer` is the full retained scrollback
572
+ // (up to SCROLLBACK_BYTES) and full-screen TUIs never truly "clear" it
573
+ // — a dismissed prompt's text sits in there forever. A full-screen
574
+ // redraw re-touches its whole visible frame on every update though
575
+ // (confirmed against a real capture), so recent output alone reflects
576
+ // what's actually on screen right now; checking the whole history would
577
+ // make a session that dismissed its trust prompt minutes ago look
578
+ // permanently stuck.
579
+ return !adapter.detectBlockingPrompt(handle.buffer.slice(-BLOCKING_PROMPT_SCAN_BYTES));
580
+ }
581
+ /**
582
+ * Polls `isReadyEnough` until it's true, the session's pty goes away
583
+ * (killed/exited — it's never going to become ready now), or `timeoutMs`
584
+ * elapses. Used by `submitInput()` only — `write()` (raw terminal
585
+ * keystrokes) must never wait on this, since a human answering the very
586
+ * prompt this is waiting out is exactly how a session becomes ready.
587
+ */
588
+ async waitUntilReady(id, timeoutMs) {
589
+ const deadline = Date.now() + timeoutMs;
590
+ for (;;) {
591
+ const handle = this.ptys.get(id);
592
+ const session = this.sessions.get(id);
593
+ if (!handle || !session)
594
+ return false;
595
+ if (this.isReadyEnough(session, handle))
596
+ return true;
597
+ if (Date.now() >= deadline)
598
+ return false;
599
+ await sleep(Math.min(READY_POLL_MS, Math.max(0, deadline - Date.now())));
600
+ }
601
+ }
602
+ /** Waits for all in-flight registry writes to settle. Useful before process
603
+ * shutdown (and in tests that assert against the on-disk registry). */
604
+ async flush() {
605
+ await this.writeQueue;
606
+ }
607
+ setTitle(id, title) {
608
+ const session = this.sessions.get(id);
609
+ if (!session || !title || session.title === title)
610
+ return;
611
+ session.title = title;
612
+ void this.persist();
613
+ this.emitStatus(session);
614
+ }
615
+ /** Binds (or, with `null`, unbinds) the session's current workflow
616
+ * selection. The caller (rest.ts) owns validating `workflowPath` against
617
+ * the workflow registry and mirroring the binding into
618
+ * HARNESS_CONTEXT_FILE — this only updates the in-memory/persisted
619
+ * registry entry and broadcasts the change like any other status update. */
620
+ setBoundWorkflowPath(id, workflowPath) {
621
+ const session = this.sessions.get(id);
622
+ if (!session || session.boundWorkflowPath === workflowPath)
623
+ return;
624
+ session.boundWorkflowPath = workflowPath;
625
+ void this.persist();
626
+ this.emitStatus(session);
627
+ }
628
+ async spawn(session, spec) {
629
+ const spawnFn = this.spawnPty ?? (await loadDefaultSpawn());
630
+ const env = {};
631
+ for (const [key, value] of Object.entries(process.env)) {
632
+ if (value !== undefined)
633
+ env[key] = value;
634
+ }
635
+ for (const [key, value] of Object.entries(spec.env)) {
636
+ if (value === null)
637
+ delete env[key];
638
+ else
639
+ env[key] = value;
640
+ }
641
+ env[ENV.ingestUrl] = `${this.ingestUrl.replace(/\/$/, "")}/ingest`;
642
+ env[ENV.ingestToken] = this.ingestToken;
643
+ env[ENV.sessionId] = session.id;
644
+ if (this.collectorUrl)
645
+ env[ENV.collectorUrl] = this.collectorUrl;
646
+ // A throw here — spawnFn itself, or loadDefaultSpawn() above (a broken
647
+ // node-pty prebuild surfaces there, not at import time) — propagates to
648
+ // create()/resume(), which own reconciling the session record to
649
+ // "exited" for every pre-pty failure, not just this one.
650
+ const pty = spawnFn(spec.command, spec.args, {
651
+ name: "xterm-256color",
652
+ cols: DEFAULT_COLS,
653
+ rows: DEFAULT_ROWS,
654
+ cwd: spec.cwd,
655
+ env,
656
+ });
657
+ const emitter = new EventEmitter();
658
+ emitter.setMaxListeners(0);
659
+ const handle = { pty, buffer: "", emitter, spawnedAt: Date.now() };
660
+ this.ptys.set(session.id, handle);
661
+ session.status = "running";
662
+ // A resumed session may carry `ready: true` from its previous life —
663
+ // this is a fresh pty that hasn't proven itself interactive yet either
664
+ // way (trust dialogs can reappear, e.g. under different sandbox flags).
665
+ session.ready = false;
666
+ session.lastActiveAt = this.now();
667
+ await this.persist();
668
+ this.emitStatus(session);
669
+ pty.onData((chunk) => {
670
+ handle.buffer = (handle.buffer + chunk).slice(-SCROLLBACK_BYTES);
671
+ handle.emitter.emit("data", chunk);
672
+ this.recordActivity(session.id);
673
+ });
674
+ pty.onExit(({ exitCode }) => this.markExited(session.id, handle, exitCode));
675
+ }
676
+ /**
677
+ * Transitions a session to "exited". Shared by node-pty's own `onExit`
678
+ * callback and `kill()`'s missed-event fallback (see `kill()`) — both are
679
+ * racing to be the one that reports a given pty's death, so this is
680
+ * idempotent: a stale/duplicate call (`this.ptys.get(id) !== handle`,
681
+ * i.e. this handle was already replaced or already reported exited) is a
682
+ * silent no-op rather than double-transitioning or clobbering a newer
683
+ * session/handle that's since taken its place (e.g. a resume).
684
+ */
685
+ markExited(id, handle, exitCode) {
686
+ if (this.ptys.get(id) !== handle)
687
+ return;
688
+ this.ptys.delete(id);
689
+ this.lastActivityBroadcast.delete(id);
690
+ const session = this.sessions.get(id);
691
+ if (!session)
692
+ return;
693
+ void this.transitionExited(session, exitCode);
694
+ }
695
+ /**
696
+ * The single place a session record flips to "exited" — shared by
697
+ * `markExited()` (live-pty deaths), `create()`/`resume()`'s pre-pty
698
+ * failure reconciliation, `kill()`'s stale-record path, and
699
+ * `sweepDeadSessions()`. Returns the persist promise so callers that need
700
+ * the registry durably updated before rethrowing (create/resume) can
701
+ * await it; event-driven callers fire-and-forget it like any other write.
702
+ */
703
+ transitionExited(session, exitCode) {
704
+ session.status = "exited";
705
+ session.exitCode = exitCode;
706
+ session.lastActiveAt = this.now();
707
+ const persisted = this.persist();
708
+ this.emitStatus(session);
709
+ return persisted;
710
+ }
711
+ emitStatus(session) {
712
+ this.statusEmitter.emit("status", { ...session });
713
+ }
714
+ /** Serializes writes so overlapping persist() calls can't interleave and
715
+ * corrupt the registry file; a failed write doesn't poison later ones. */
716
+ persist() {
717
+ const run = async () => {
718
+ const list = this.list();
719
+ await mkdir(dirname(this.sessionsPath), { recursive: true });
720
+ const tmpPath = `${this.sessionsPath}.tmp-${process.pid}-${this.writeSeq++}`;
721
+ await writeFile(tmpPath, JSON.stringify(list, null, 2) + "\n", "utf8");
722
+ await rename(tmpPath, this.sessionsPath);
723
+ };
724
+ const next = this.writeQueue.catch(() => { }).then(run);
725
+ this.writeQueue = next.catch(() => { });
726
+ return next;
727
+ }
728
+ }
729
+ //# sourceMappingURL=session-manager.js.map