@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.
- package/LICENSE +21 -0
- package/README.md +44 -43
- package/dist/cli/auth.d.ts +26 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +39 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +163 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/consent.d.ts +15 -0
- package/dist/cli/consent.d.ts.map +1 -0
- package/dist/cli/consent.js +81 -0
- package/dist/cli/consent.js.map +1 -0
- package/dist/cli/doctor.d.ts +27 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +74 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/machine-id.d.ts +19 -0
- package/dist/cli/machine-id.d.ts.map +1 -0
- package/dist/cli/machine-id.js +75 -0
- package/dist/cli/machine-id.js.map +1 -0
- package/dist/cli/paths.d.ts +3 -0
- package/dist/cli/paths.d.ts.map +1 -0
- package/dist/cli/paths.js +11 -0
- package/dist/cli/paths.js.map +1 -0
- package/dist/cli/settings.d.ts +26 -0
- package/dist/cli/settings.d.ts.map +1 -0
- package/dist/cli/settings.js +123 -0
- package/dist/cli/settings.js.map +1 -0
- package/dist/core/adapters/claude-code.d.ts +41 -0
- package/dist/core/adapters/claude-code.d.ts.map +1 -0
- package/dist/core/adapters/claude-code.js +238 -0
- package/dist/core/adapters/claude-code.js.map +1 -0
- package/dist/core/adapters/codex.d.ts +59 -0
- package/dist/core/adapters/codex.d.ts.map +1 -0
- package/dist/core/adapters/codex.js +319 -0
- package/dist/core/adapters/codex.js.map +1 -0
- package/dist/core/canvas-body.d.ts +45 -0
- package/dist/core/canvas-body.d.ts.map +1 -0
- package/dist/core/canvas-body.js +94 -0
- package/dist/core/canvas-body.js.map +1 -0
- package/dist/core/canvas-cache.d.ts +23 -0
- package/dist/core/canvas-cache.d.ts.map +1 -0
- package/dist/core/canvas-cache.js +64 -0
- package/dist/core/canvas-cache.js.map +1 -0
- package/dist/core/canvas-enrich.d.ts +106 -0
- package/dist/core/canvas-enrich.d.ts.map +1 -0
- package/dist/core/canvas-enrich.js +178 -0
- package/dist/core/canvas-enrich.js.map +1 -0
- package/dist/core/canvas-enrichment.d.ts +144 -0
- package/dist/core/canvas-enrichment.d.ts.map +1 -0
- package/dist/core/canvas-enrichment.js +192 -0
- package/dist/core/canvas-enrichment.js.map +1 -0
- package/dist/core/canvas-graph.d.ts +71 -0
- package/dist/core/canvas-graph.d.ts.map +1 -0
- package/dist/core/canvas-graph.js +111 -0
- package/dist/core/canvas-graph.js.map +1 -0
- package/dist/core/canvas-index-classify.d.ts +41 -0
- package/dist/core/canvas-index-classify.d.ts.map +1 -0
- package/dist/core/canvas-index-classify.js +63 -0
- package/dist/core/canvas-index-classify.js.map +1 -0
- package/dist/core/canvas-interconnections.d.ts +24 -0
- package/dist/core/canvas-interconnections.d.ts.map +1 -0
- package/dist/core/canvas-interconnections.js +96 -0
- package/dist/core/canvas-interconnections.js.map +1 -0
- package/dist/core/canvas-manifest-check.d.ts +15 -0
- package/dist/core/canvas-manifest-check.d.ts.map +1 -0
- package/dist/core/canvas-manifest-check.js +90 -0
- package/dist/core/canvas-manifest-check.js.map +1 -0
- package/dist/core/canvas-render.d.ts +73 -0
- package/dist/core/canvas-render.d.ts.map +1 -0
- package/dist/core/canvas-render.js +145 -0
- package/dist/core/canvas-render.js.map +1 -0
- package/dist/core/canvas-svg.d.ts +50 -0
- package/dist/core/canvas-svg.d.ts.map +1 -0
- package/dist/core/canvas-svg.js +240 -0
- package/dist/core/canvas-svg.js.map +1 -0
- package/dist/core/canvas-template.d.ts +43 -0
- package/dist/core/canvas-template.d.ts.map +1 -0
- package/dist/core/canvas-template.js +365 -0
- package/dist/core/canvas-template.js.map +1 -0
- package/dist/core/canvas-watcher.d.ts +25 -0
- package/dist/core/canvas-watcher.d.ts.map +1 -0
- package/dist/core/canvas-watcher.js +144 -0
- package/dist/core/canvas-watcher.js.map +1 -0
- package/dist/core/collector/batcher.d.ts +57 -0
- package/dist/core/collector/batcher.d.ts.map +1 -0
- package/dist/core/collector/batcher.js +143 -0
- package/dist/core/collector/batcher.js.map +1 -0
- package/dist/core/collector/codex-tailer.d.ts +85 -0
- package/dist/core/collector/codex-tailer.d.ts.map +1 -0
- package/dist/core/collector/codex-tailer.js +270 -0
- package/dist/core/collector/codex-tailer.js.map +1 -0
- package/dist/core/collector/normalizer.d.ts +33 -0
- package/dist/core/collector/normalizer.d.ts.map +1 -0
- package/dist/core/collector/normalizer.js +94 -0
- package/dist/core/collector/normalizer.js.map +1 -0
- package/dist/core/collector/seq.d.ts +14 -0
- package/dist/core/collector/seq.d.ts.map +1 -0
- package/dist/core/collector/seq.js +20 -0
- package/dist/core/collector/seq.js.map +1 -0
- package/dist/core/collector/store.d.ts +16 -0
- package/dist/core/collector/store.d.ts.map +1 -0
- package/dist/core/collector/store.js +30 -0
- package/dist/core/collector/store.js.map +1 -0
- package/dist/core/collector/transcript.d.ts +38 -0
- package/dist/core/collector/transcript.d.ts.map +1 -0
- package/dist/core/collector/transcript.js +110 -0
- package/dist/core/collector/transcript.js.map +1 -0
- package/dist/core/event-bus.d.ts +9 -0
- package/dist/core/event-bus.d.ts.map +1 -0
- package/dist/core/event-bus.js +25 -0
- package/dist/core/event-bus.js.map +1 -0
- package/dist/core/example-seed.d.ts +34 -0
- package/dist/core/example-seed.d.ts.map +1 -0
- package/dist/core/example-seed.js +277 -0
- package/dist/core/example-seed.js.map +1 -0
- package/dist/core/inject/claude-settings.d.ts +34 -0
- package/dist/core/inject/claude-settings.d.ts.map +1 -0
- package/dist/core/inject/claude-settings.js +152 -0
- package/dist/core/inject/claude-settings.js.map +1 -0
- package/dist/core/inject/mcp-config.d.ts +26 -0
- package/dist/core/inject/mcp-config.d.ts.map +1 -0
- package/dist/core/inject/mcp-config.js +39 -0
- package/dist/core/inject/mcp-config.js.map +1 -0
- package/dist/core/inject/retention.d.ts +66 -0
- package/dist/core/inject/retention.d.ts.map +1 -0
- package/dist/core/inject/retention.js +119 -0
- package/dist/core/inject/retention.js.map +1 -0
- package/dist/core/inject/system-prompt.d.ts +11 -0
- package/dist/core/inject/system-prompt.d.ts.map +1 -0
- package/dist/core/inject/system-prompt.js +22 -0
- package/dist/core/inject/system-prompt.js.map +1 -0
- package/dist/core/macro-runner.d.ts +36 -0
- package/dist/core/macro-runner.d.ts.map +1 -0
- package/dist/core/macro-runner.js +66 -0
- package/dist/core/macro-runner.js.map +1 -0
- package/dist/core/macros.d.ts +10 -0
- package/dist/core/macros.d.ts.map +1 -0
- package/dist/core/macros.js +59 -0
- package/dist/core/macros.js.map +1 -0
- package/dist/core/path-safety.d.ts +33 -0
- package/dist/core/path-safety.d.ts.map +1 -0
- package/dist/core/path-safety.js +63 -0
- package/dist/core/path-safety.js.map +1 -0
- package/dist/core/paths.d.ts +27 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +43 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/port-detector.d.ts +69 -0
- package/dist/core/port-detector.d.ts.map +1 -0
- package/dist/core/port-detector.js +121 -0
- package/dist/core/port-detector.js.map +1 -0
- package/dist/core/session-manager.d.ts +275 -0
- package/dist/core/session-manager.d.ts.map +1 -0
- package/dist/core/session-manager.js +729 -0
- package/dist/core/session-manager.js.map +1 -0
- package/dist/core/task-manager.d.ts +138 -0
- package/dist/core/task-manager.d.ts.map +1 -0
- package/dist/core/task-manager.js +286 -0
- package/dist/core/task-manager.js.map +1 -0
- package/dist/core/task-stream.d.ts +32 -0
- package/dist/core/task-stream.d.ts.map +1 -0
- package/dist/core/task-stream.js +87 -0
- package/dist/core/task-stream.js.map +1 -0
- package/dist/core/workflow-registry.d.ts +36 -0
- package/dist/core/workflow-registry.d.ts.map +1 -0
- package/dist/core/workflow-registry.js +220 -0
- package/dist/core/workflow-registry.js.map +1 -0
- package/dist/core/workspace-context.d.ts +43 -0
- package/dist/core/workspace-context.d.ts.map +1 -0
- package/dist/core/workspace-context.js +108 -0
- package/dist/core/workspace-context.js.map +1 -0
- package/dist/core/workspace-watcher.d.ts +26 -0
- package/dist/core/workspace-watcher.d.ts.map +1 -0
- package/dist/core/workspace-watcher.js +174 -0
- package/dist/core/workspace-watcher.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/profiles/canvas-guidelines.d.ts +20 -0
- package/dist/profiles/canvas-guidelines.d.ts.map +1 -0
- package/dist/profiles/canvas-guidelines.js +48 -0
- package/dist/profiles/canvas-guidelines.js.map +1 -0
- package/dist/profiles/default.d.ts +10 -0
- package/dist/profiles/default.d.ts.map +1 -0
- package/dist/profiles/default.js +70 -0
- package/dist/profiles/default.js.map +1 -0
- package/dist/server/auth.d.ts +9 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/auth.js +28 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/canvas-render.d.ts +18 -0
- package/dist/server/canvas-render.d.ts.map +1 -0
- package/dist/server/canvas-render.js +24 -0
- package/dist/server/canvas-render.js.map +1 -0
- package/dist/server/canvas.d.ts +12 -0
- package/dist/server/canvas.d.ts.map +1 -0
- package/dist/server/canvas.js +174 -0
- package/dist/server/canvas.js.map +1 -0
- package/dist/server/events-ws.d.ts +11 -0
- package/dist/server/events-ws.d.ts.map +1 -0
- package/dist/server/events-ws.js +24 -0
- package/dist/server/events-ws.js.map +1 -0
- package/dist/server/fs.d.ts +4 -0
- package/dist/server/fs.d.ts.map +1 -0
- package/dist/server/fs.js +88 -0
- package/dist/server/fs.js.map +1 -0
- package/dist/server/index.d.ts +83 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +0 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/ingest.d.ts +74 -0
- package/dist/server/ingest.d.ts.map +1 -0
- package/dist/server/ingest.js +74 -0
- package/dist/server/ingest.js.map +1 -0
- package/dist/server/macros.d.ts +32 -0
- package/dist/server/macros.d.ts.map +1 -0
- package/dist/server/macros.js +76 -0
- package/dist/server/macros.js.map +1 -0
- package/dist/server/rest.d.ts +75 -0
- package/dist/server/rest.d.ts.map +1 -0
- package/dist/server/rest.js +235 -0
- package/dist/server/rest.js.map +1 -0
- package/dist/server/static.d.ts +8 -0
- package/dist/server/static.d.ts.map +1 -0
- package/dist/server/static.js +36 -0
- package/dist/server/static.js.map +1 -0
- package/dist/server/terminal-ws.d.ts +9 -0
- package/dist/server/terminal-ws.d.ts.map +1 -0
- package/dist/server/terminal-ws.js +59 -0
- package/dist/server/terminal-ws.js.map +1 -0
- package/dist/server/ws-router.d.ts +16 -0
- package/dist/server/ws-router.d.ts.map +1 -0
- package/dist/server/ws-router.js +26 -0
- package/dist/server/ws-router.js.map +1 -0
- package/dist/shared/types.d.ts +485 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +80 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/assets/index-B9R4TzH7.css +32 -0
- package/dist/web/assets/index-Dh97QAgq.js +192 -0
- package/dist/web/index.html +27 -0
- 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
|