runwork 0.10.2 → 0.10.4

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 (49) hide show
  1. package/dist/agents/__tests__/claude-code-stats.test.js +1 -0
  2. package/dist/agents/claude-code.js +1 -1
  3. package/dist/agents/cursor.js +1 -1
  4. package/dist/commands/clone.js +1 -1
  5. package/dist/commands/deploy.js +1 -1
  6. package/dist/commands/dev.d.ts +3 -0
  7. package/dist/commands/dev.js +628 -11
  8. package/dist/commands/info.d.ts +31 -0
  9. package/dist/commands/info.js +37 -0
  10. package/dist/commands/init.js +1 -1
  11. package/dist/dev/__tests__/attach.test.d.ts +1 -0
  12. package/dist/dev/__tests__/attach.test.js +296 -0
  13. package/dist/dev/__tests__/detach.test.d.ts +1 -0
  14. package/dist/dev/__tests__/detach.test.js +404 -0
  15. package/dist/dev/__tests__/preview-url-poller.test.d.ts +1 -0
  16. package/dist/dev/__tests__/preview-url-poller.test.js +149 -0
  17. package/dist/dev/__tests__/session.test.d.ts +1 -0
  18. package/dist/dev/__tests__/session.test.js +347 -0
  19. package/dist/dev/__tests__/stop.test.d.ts +1 -0
  20. package/dist/dev/__tests__/stop.test.js +172 -0
  21. package/dist/dev/attach.d.ts +120 -0
  22. package/dist/dev/attach.js +269 -0
  23. package/dist/dev/detach.d.ts +187 -0
  24. package/dist/dev/detach.js +292 -0
  25. package/dist/dev/preview-url-poller.d.ts +35 -0
  26. package/dist/dev/preview-url-poller.js +50 -0
  27. package/dist/dev/session.d.ts +158 -0
  28. package/dist/dev/session.js +252 -0
  29. package/dist/dev/stop.d.ts +52 -0
  30. package/dist/dev/stop.js +101 -0
  31. package/dist/generated/version.d.ts +1 -1
  32. package/dist/generated/version.js +1 -1
  33. package/dist/git/__tests__/credentials.test.js +1 -1
  34. package/dist/git/auto-commit.js +1 -1
  35. package/dist/git/credentials.js +1 -1
  36. package/dist/git/identity.js +1 -1
  37. package/dist/git/preflight.js +1 -1
  38. package/dist/git/sync.js +1 -1
  39. package/dist/health/checks.js +1 -1
  40. package/dist/template/manifest.js +1 -1
  41. package/dist/ui/__tests__/keyboard.test.js +4 -0
  42. package/dist/ui/keyboard.d.ts +1 -1
  43. package/dist/ui/keyboard.js +4 -0
  44. package/dist/utils/agent-guidance.d.ts +13 -0
  45. package/dist/utils/agent-guidance.js +22 -7
  46. package/dist/utils/subprocess.d.ts +19 -0
  47. package/dist/utils/subprocess.js +27 -0
  48. package/dist/utils/which.js +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,292 @@
1
+ /**
2
+ * `runwork dev --detach` orchestration.
3
+ *
4
+ * The detach contract:
5
+ *
6
+ * 1. Parent spawns the child via `process.execPath` with the same args
7
+ * plus `--internal-detached-child`. Child stdout/stderr are routed to
8
+ * log files in `.runwork/`. Parent calls `child.unref()` and exits as
9
+ * soon as the rendezvous signal arrives.
10
+ *
11
+ * 2. Child runs the same `runwork dev` code path as foreground, but in
12
+ * "detached" mode: no TUI, no keypress listener, JSON event stream
13
+ * to stdout (which is the log file).
14
+ *
15
+ * 3. Rendezvous channel is the session file, NOT a pipe. The child
16
+ * writes `.runwork/dev-session.json` once it has a `previewUrl`. The
17
+ * parent polls. When the parent observes a file whose `pid` matches
18
+ * the spawned child AND `previewUrl` is non-empty, parent prints +
19
+ * emits the URL and exits 0.
20
+ *
21
+ * 4. On timeout, parent attempts a single kill of the child, removes
22
+ * any partial session file, and exits 1 with a structured error.
23
+ *
24
+ * The file-as-IPC choice is intentional: pipes don't survive parent exit,
25
+ * and Node IPC has historical Bun-on-Windows quirks. The session file is
26
+ * already our durable lifecycle truth; reusing it for the handshake is
27
+ * free.
28
+ */
29
+ import * as fs from 'fs';
30
+ import * as child_process from 'child_process';
31
+ import { getSessionPaths, readSessionFile, removeSessionFileIfOwned, } from './session.js';
32
+ /**
33
+ * Internal flag passed to the child. Hidden from `--help`. The child uses
34
+ * its presence to skip the parent-spawn branch and run the actual dev
35
+ * work in detached mode.
36
+ */
37
+ export const INTERNAL_DETACHED_CHILD_FLAG = '--internal-detached-child';
38
+ const realPollDeps = {
39
+ now: () => Date.now(),
40
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
41
+ };
42
+ function resolvePollDeps(deps) {
43
+ if (!deps)
44
+ return realPollDeps;
45
+ return {
46
+ now: deps.now ?? realPollDeps.now,
47
+ sleep: deps.sleep ?? realPollDeps.sleep,
48
+ };
49
+ }
50
+ /**
51
+ * Poll the session file until either:
52
+ * - a file appears whose pid matches `expectedPid` AND has a non-empty
53
+ * `previewUrl` -> `ready`
54
+ * - the timeout elapses -> `timeout`
55
+ *
56
+ * If a file appears with a different pid (some other process won a write
57
+ * race), we treat it as the user's intent being satisfied and return
58
+ * `wrong-pid` so the caller can decide how to handle it -- usually that's
59
+ * "another `runwork dev` invocation already had a session running, fine."
60
+ *
61
+ * The function does not validate `bootTime` or `appId` -- those are the
62
+ * caller's job once they know which file matters. We only care about the
63
+ * shape of the file and the pid match here.
64
+ */
65
+ export async function pollForSession(appDir, expectedPid, expectedAppId, opts = {}) {
66
+ const intervalMs = opts.intervalMs ?? 250;
67
+ const timeoutMs = opts.timeoutMs ?? 90_000;
68
+ const d = resolvePollDeps(opts.deps);
69
+ const deadline = d.now() + timeoutMs;
70
+ while (d.now() < deadline) {
71
+ if (opts.isChildAlive && !opts.isChildAlive()) {
72
+ return { result: 'child-exited' };
73
+ }
74
+ const file = readSessionFile(appDir);
75
+ if (file && file.previewUrl && file.appId === expectedAppId) {
76
+ if (file.pid === expectedPid) {
77
+ return { result: 'ready', file };
78
+ }
79
+ // Another process wrote a session file before our child did. The
80
+ // user's intent ("have a dev session running") is satisfied. Caller
81
+ // decides whether to log this and exit success or kill our child.
82
+ return { result: 'wrong-pid', file };
83
+ }
84
+ await d.sleep(intervalMs);
85
+ }
86
+ // One last child-alive probe before declaring timeout: a child that
87
+ // exited just before the deadline would otherwise be reported as
88
+ // "timeout" instead of "child-exited", losing diagnostic information.
89
+ if (opts.isChildAlive && !opts.isChildAlive()) {
90
+ return { result: 'child-exited' };
91
+ }
92
+ return { result: 'timeout' };
93
+ }
94
+ /**
95
+ * Open the stdout/stderr log files in truncating mode and return their
96
+ * descriptors. We truncate (not append) so each new dev session starts
97
+ * fresh -- log rotation across sessions is out of scope.
98
+ *
99
+ * The descriptors must be closed by the caller after `spawn` so the
100
+ * parent process doesn't keep them open (which would prevent the child
101
+ * from being the sole owner).
102
+ */
103
+ export function openLogFds(appDir) {
104
+ const paths = getSessionPaths(appDir);
105
+ fs.mkdirSync(paths.dir, { recursive: true });
106
+ const stdoutFd = fs.openSync(paths.stdoutLog, 'w');
107
+ const stderrFd = fs.openSync(paths.stderrLog, 'w');
108
+ return { stdoutFd, stderrFd };
109
+ }
110
+ /**
111
+ * The parent half of `runwork dev --detach`. Returns a structured outcome
112
+ * the caller can translate into stdout text and an exit code.
113
+ *
114
+ * IMPORTANT: this function does NOT print anything. It only orchestrates.
115
+ * The caller (in `dev.ts`) decides how to render the outcome -- human
116
+ * banner vs. JSON event vs. error stream.
117
+ */
118
+ export async function runAsDetachedParent(opts) {
119
+ let child;
120
+ try {
121
+ child = (opts.spawn ?? defaultSpawnDetachedChild)(opts.childArgs);
122
+ }
123
+ catch (err) {
124
+ return { result: 'spawn-failed', error: err };
125
+ }
126
+ if (!child.pid) {
127
+ return { result: 'spawn-failed', error: new Error('Spawned child has no PID') };
128
+ }
129
+ const outcome = await pollForSession(opts.appDir, child.pid, opts.expectedAppId, {
130
+ intervalMs: opts.intervalMs,
131
+ timeoutMs: opts.timeoutMs,
132
+ deps: opts.pollDeps,
133
+ isChildAlive: child.isAlive,
134
+ });
135
+ if (outcome.result === 'ready') {
136
+ return { result: 'started', file: outcome.file };
137
+ }
138
+ if (outcome.result === 'wrong-pid') {
139
+ // Another process wrote a session file with a different PID before
140
+ // our child did. Our child is still happily running its own dev
141
+ // setup -- left alone, it will eventually overwrite the winner's
142
+ // session file with its own PID, defeating the duplicate-session
143
+ // guard. SIGTERM our child so the winner remains the unique owner.
144
+ // Best-effort: if the kill fails (race, EPERM), we still surface
145
+ // wrong-pid -- the user/agent will see two sessions briefly and
146
+ // can resolve via `runwork dev stop`.
147
+ try {
148
+ child.kill('SIGTERM');
149
+ }
150
+ catch { /* best-effort */ }
151
+ return { result: 'wrong-pid', file: outcome.file, ourPid: child.pid };
152
+ }
153
+ // For both timeout and child-exited, kill the child (no-op if already
154
+ // gone), clean up any partial session file we own, and surface the
155
+ // stderr tail for diagnostics.
156
+ try {
157
+ child.kill('SIGTERM');
158
+ }
159
+ catch { /* best-effort */ }
160
+ // Only remove the session file if it's ours -- a wrong-pid race could
161
+ // have replaced it with the winner's file between our last probe and
162
+ // here. removeSessionFileIfOwned guards against that.
163
+ try {
164
+ removeSessionFileIfOwned(opts.appDir, child.pid);
165
+ }
166
+ catch { /* best-effort */ }
167
+ const childLogTail = readChildStderrTail(opts.appDir);
168
+ if (outcome.result === 'child-exited') {
169
+ return { result: 'child-exited', ourPid: child.pid, childLogTail };
170
+ }
171
+ return { result: 'timeout', ourPid: child.pid, childLogTail };
172
+ }
173
+ function readChildStderrTail(appDir) {
174
+ try {
175
+ const paths = getSessionPaths(appDir);
176
+ if (!fs.existsSync(paths.stderrLog))
177
+ return undefined;
178
+ const raw = fs.readFileSync(paths.stderrLog, 'utf-8');
179
+ return raw.slice(-2000); // last ~2KB of stderr
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ }
185
+ /**
186
+ * Cross-platform detached self-spawn. Uses `process.execPath` so we never
187
+ * depend on PATH lookup -- a known failure mode for Bun standalone on
188
+ * Windows. The child gets the user-supplied args plus the internal child
189
+ * marker.
190
+ */
191
+ export function defaultSpawnDetachedChild(childArgs) {
192
+ const cwd = process.cwd();
193
+ const { stdoutFd, stderrFd } = openLogFds(cwd);
194
+ try {
195
+ const proc = child_process.spawn(process.execPath, childArgs, {
196
+ cwd,
197
+ env: process.env,
198
+ detached: true,
199
+ stdio: ['ignore', stdoutFd, stderrFd],
200
+ windowsHide: true,
201
+ });
202
+ if (proc.pid !== undefined) {
203
+ proc.unref();
204
+ }
205
+ // Track exit via the 'exit' event. We can't depend on `proc.exitCode`
206
+ // alone because that's null until the process actually exits.
207
+ let exited = proc.exitCode !== null || proc.signalCode !== null;
208
+ proc.on('exit', () => { exited = true; });
209
+ proc.on('error', () => { exited = true; });
210
+ return {
211
+ pid: proc.pid ?? 0,
212
+ kill: (signal) => proc.kill(signal),
213
+ isAlive: () => !exited,
214
+ };
215
+ }
216
+ finally {
217
+ // The child has dup'd these descriptors; the parent should close its
218
+ // copies so it isn't holding onto the log files. Failure here is
219
+ // non-fatal -- the FDs will be reaped on parent exit anyway.
220
+ try {
221
+ fs.closeSync(stdoutFd);
222
+ }
223
+ catch { /* ignore */ }
224
+ try {
225
+ fs.closeSync(stderrFd);
226
+ }
227
+ catch { /* ignore */ }
228
+ }
229
+ }
230
+ /**
231
+ * Detect whether the current `runwork dev` invocation is the detached
232
+ * child. Used by `dev.ts` to pick between the parent-spawn branch and
233
+ * the actual dev work.
234
+ */
235
+ export function isInternalDetachedChild(argv = process.argv) {
236
+ return argv.includes(INTERNAL_DETACHED_CHILD_FLAG);
237
+ }
238
+ /**
239
+ * Strip the internal marker from a list of args. Used when constructing
240
+ * the child's args from the parent's own args -- the parent already has
241
+ * the marker, the child needs it, but we want to avoid duplicates if for
242
+ * any reason the parent was itself launched with the marker (e.g., a
243
+ * misconfigured wrapper).
244
+ */
245
+ export function stripInternalChildFlag(args) {
246
+ return args.filter((a) => a !== INTERNAL_DETACHED_CHILD_FLAG);
247
+ }
248
+ /**
249
+ * Detect a Bun standalone virtual-filesystem path. Bun's compile mode
250
+ * on Windows injects the in-bundle script path as `process.argv[1]`
251
+ * (e.g., `B:/~BUN/root/runwork-windows-x64.exe`). When we self-spawn,
252
+ * the child Bun runtime re-injects an equivalent entry on its own --
253
+ * forwarding ours causes a duplicate that downstream parsers (commander
254
+ * here) misread as a stray positional command. macOS and Linux Bun
255
+ * standalone do NOT inject this entry, but the prefix is documented in
256
+ * Bun source as `/$bunfs/` if it ever appears, so we detect that too
257
+ * defensively.
258
+ */
259
+ export function looksLikeBunStandaloneArtifact(p) {
260
+ // Windows: drive-letter paths under \~BUN\, e.g. "B:/~BUN/root/..." or "B:\~BUN\root\..."
261
+ if (/^[a-z]:[/\\]~BUN[/\\]/i.test(p))
262
+ return true;
263
+ // Linux/macOS bunfs prefix (documented; not currently emitted in user-visible argv).
264
+ if (p.startsWith('/$bunfs/'))
265
+ return true;
266
+ return false;
267
+ }
268
+ /**
269
+ * Construct the args we should forward to the spawned child so it
270
+ * re-runs the same `runwork dev` invocation as the parent. Drops
271
+ * elements that the child runtime will re-inject on its own (notably
272
+ * the Bun-on-Windows virtual-FS path) and drops any pre-existing copy
273
+ * of the internal-child marker before we re-add exactly one.
274
+ *
275
+ * Pure function for testability -- accepts the parent's argv and returns
276
+ * what to hand to `spawn`. Real callers pass `process.argv`.
277
+ */
278
+ export function buildChildArgs(parentArgv) {
279
+ // argv[0] is always the binary; the child gets it back via spawn's
280
+ // execPath argument. We start scanning from argv[1].
281
+ const rest = [];
282
+ for (let i = 1; i < parentArgv.length; i++) {
283
+ if (i === 1 && looksLikeBunStandaloneArtifact(parentArgv[i])) {
284
+ // Skip Bun-on-Windows's auto-injected virtual-FS path; the child
285
+ // runtime injects its own equivalent entry. Forwarding ours would
286
+ // duplicate it in the child's argv.
287
+ continue;
288
+ }
289
+ rest.push(parentArgv[i]);
290
+ }
291
+ return [...stripInternalChildFlag(rest), INTERNAL_DETACHED_CHILD_FLAG];
292
+ }
@@ -0,0 +1,35 @@
1
+ import type { ApiClient } from '../api/client.js';
2
+ export interface PreviewUrlPollerOptions {
3
+ client: ApiClient;
4
+ appId: string;
5
+ initialUrl: string;
6
+ /**
7
+ * Polling interval in ms. Defaults to 15s -- low enough to catch a
8
+ * sandbox URL rotation within one keypress's worth of attention, high
9
+ * enough that an idle dev session isn't generating noticeable API load.
10
+ */
11
+ intervalMs?: number;
12
+ onChange: (next: string, prev: string) => void;
13
+ onError?: (err: unknown) => void;
14
+ }
15
+ export interface PreviewUrlPoller {
16
+ getCurrent(): string;
17
+ stop(): void;
18
+ }
19
+ /**
20
+ * Periodically refreshes the preview URL from `GET /api/dev/status` so the
21
+ * `runwork dev` UI stays in agreement with `runwork info` even when the
22
+ * sandbox tunnel rotates mid-session.
23
+ *
24
+ * Two intentional behaviours:
25
+ *
26
+ * 1. Empty server responses are NOT propagated. The DO clears its
27
+ * preview-URL cache to "" while a sandbox is being replaced; flipping
28
+ * the status line to empty and back would just be flicker. We keep
29
+ * the last known good URL until the server reports a real new one.
30
+ *
31
+ * 2. Overlapping ticks are skipped. If a fetch takes longer than the
32
+ * interval (slow network, sandbox spin-up), we don't pile up requests
33
+ * -- the next tick simply no-ops until the in-flight one resolves.
34
+ */
35
+ export declare function startPreviewUrlPoller(opts: PreviewUrlPollerOptions): PreviewUrlPoller;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Periodically refreshes the preview URL from `GET /api/dev/status` so the
3
+ * `runwork dev` UI stays in agreement with `runwork info` even when the
4
+ * sandbox tunnel rotates mid-session.
5
+ *
6
+ * Two intentional behaviours:
7
+ *
8
+ * 1. Empty server responses are NOT propagated. The DO clears its
9
+ * preview-URL cache to "" while a sandbox is being replaced; flipping
10
+ * the status line to empty and back would just be flicker. We keep
11
+ * the last known good URL until the server reports a real new one.
12
+ *
13
+ * 2. Overlapping ticks are skipped. If a fetch takes longer than the
14
+ * interval (slow network, sandbox spin-up), we don't pile up requests
15
+ * -- the next tick simply no-ops until the in-flight one resolves.
16
+ */
17
+ export function startPreviewUrlPoller(opts) {
18
+ let current = opts.initialUrl;
19
+ const interval = opts.intervalMs ?? 15000;
20
+ let stopped = false;
21
+ let inFlight = false;
22
+ const tick = async () => {
23
+ if (stopped || inFlight)
24
+ return;
25
+ inFlight = true;
26
+ try {
27
+ const status = await opts.client.getDevStatus(opts.appId);
28
+ const next = status.previewUrl ?? '';
29
+ if (next && next !== current) {
30
+ const prev = current;
31
+ current = next;
32
+ opts.onChange(next, prev);
33
+ }
34
+ }
35
+ catch (err) {
36
+ opts.onError?.(err);
37
+ }
38
+ finally {
39
+ inFlight = false;
40
+ }
41
+ };
42
+ const handle = setInterval(() => { void tick(); }, interval);
43
+ return {
44
+ getCurrent: () => current,
45
+ stop: () => {
46
+ stopped = true;
47
+ clearInterval(handle);
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Per-app `runwork dev` session lifecycle primitives.
3
+ *
4
+ * The session file at `<app-dir>/.runwork/dev-session.json` is the durable
5
+ * truth about whether a dev session is running for the current app, on the
6
+ * current machine. Every dev-related command reads from it, writes to it,
7
+ * or treats it as the IPC channel during detach.
8
+ *
9
+ * This module is intentionally pure: it does not start, stop, or signal
10
+ * processes. It only reads/writes the file and reports whether what it
11
+ * finds is alive, stale, or absent. Callers (dev.ts, dev stop, etc.) act
12
+ * on that information.
13
+ *
14
+ * See `docs/plans/2026-05-06-runwork-dev-lifecycle-design.md` for the full
15
+ * lifecycle contract.
16
+ */
17
+ export declare const SESSION_FILE_SCHEMA_VERSION = 1;
18
+ /**
19
+ * Tolerance (ms) when comparing the stored bootTime to the currently
20
+ * computed one. NTP corrections can shift `Date.now()` by a few seconds
21
+ * relative to `os.uptime()`. A reboot is always orders of magnitude
22
+ * larger than this tolerance.
23
+ */
24
+ export declare const BOOT_TIME_TOLERANCE_MS = 60000;
25
+ export type SessionMode = 'foreground' | 'detached';
26
+ export interface SessionFile {
27
+ version: number;
28
+ pid: number;
29
+ sessionId: string;
30
+ appId: string;
31
+ previewUrl: string;
32
+ startedAt: number;
33
+ bootTime: number;
34
+ cliVersion: string;
35
+ mode: SessionMode;
36
+ }
37
+ export type SessionStaleReason = 'malformed' | 'version-mismatch' | 'app-id-mismatch' | 'boot-time-mismatch' | 'pid-dead';
38
+ export type SessionState = {
39
+ state: 'none';
40
+ } | {
41
+ state: 'alive';
42
+ file: SessionFile;
43
+ } | {
44
+ state: 'stale';
45
+ reason: SessionStaleReason;
46
+ file?: SessionFile;
47
+ };
48
+ export interface SessionPaths {
49
+ /** `<app-dir>/.runwork` */
50
+ dir: string;
51
+ /** `<app-dir>/.runwork/dev-session.json` */
52
+ file: string;
53
+ /** `<app-dir>/.runwork/dev-session.json.tmp` -- staging path for atomic writes */
54
+ tmpFile: string;
55
+ /** `<app-dir>/.runwork/dev-stdout.log` -- detached child's stdout sink */
56
+ stdoutLog: string;
57
+ /** `<app-dir>/.runwork/dev-stderr.log` -- detached child's stderr sink */
58
+ stderrLog: string;
59
+ }
60
+ /**
61
+ * Inject-able dependencies for testing. The defaults call into real OS
62
+ * primitives. Tests pass overrides to simulate dead PIDs, reboots, or
63
+ * arbitrary clocks without monkey-patching `process` or `os`.
64
+ */
65
+ export interface SessionDeps {
66
+ bootTime?: () => number;
67
+ pidAlive?: (pid: number) => boolean;
68
+ }
69
+ export declare function getSessionPaths(appDir: string): SessionPaths;
70
+ /**
71
+ * System boot timestamp in ms. Computed purely from Node's `os.uptime()`
72
+ * (monotonic seconds since boot, available on every platform Node runs
73
+ * on) and the wall clock. No spawning, no native modules.
74
+ *
75
+ * Drift between `Date.now()` and `os.uptime()` from NTP corrections is
76
+ * absorbed by `BOOT_TIME_TOLERANCE_MS` at the comparison site.
77
+ */
78
+ export declare function currentBootTime(): number;
79
+ export declare function isBootTimeStale(stored: number, deps?: SessionDeps): boolean;
80
+ /**
81
+ * Cross-platform PID liveness probe.
82
+ *
83
+ * - Unix: `process.kill(pid, 0)` is signal-zero, a permissions+existence
84
+ * probe. ESRCH for dead, EPERM for "alive but not ours."
85
+ * - Windows: Node maps signal 0 to `OpenProcess(PROCESS_QUERY_LIMITED_-`
86
+ * `INFORMATION)`. Returns success iff the PID exists and the caller
87
+ * has rights, throws otherwise.
88
+ *
89
+ * On any error we return `false`. EPERM in particular ("alive but
90
+ * unprivileged") means the PID belongs to someone else now, so it's not
91
+ * our session even if it's a real process. The bootTime guard catches the
92
+ * rare "PID got reused after a reboot, we still own it" case.
93
+ */
94
+ export declare function isPidAlive(pid: number): boolean;
95
+ /**
96
+ * Read and parse the session file. Returns `null` for a missing file or
97
+ * any IO/parse error. The caller decides what "missing" vs "malformed"
98
+ * means -- use `getSessionState()` for that distinction.
99
+ */
100
+ export declare function readSessionFile(appDir: string): SessionFile | null;
101
+ /**
102
+ * Write the session file atomically. Writes to `<file>.tmp` first then
103
+ * renames into place. POSIX `rename()` and Windows `MoveFileEx` are both
104
+ * atomic on the same volume, which a sibling temp file always is.
105
+ *
106
+ * Creates `.runwork/` if it doesn't exist. The directory is gitignored at
107
+ * the project-template level.
108
+ */
109
+ export declare function writeSessionFile(appDir: string, data: SessionFile): void;
110
+ /**
111
+ * Conditional removal: only deletes the session file when its `pid`
112
+ * field matches the caller's. Use this from per-process cleanup paths
113
+ * where another process (e.g., the winner of a startup race) may have
114
+ * overwritten the file with a different owner. Returns `true` when we
115
+ * actually removed it.
116
+ *
117
+ * `removeSessionFile` is the unconditional primitive; this is the
118
+ * "be-a-good-citizen" wrapper for in-process cleanup handlers.
119
+ */
120
+ export declare function removeSessionFileIfOwned(appDir: string, pid: number): boolean;
121
+ /**
122
+ * Idempotent removal. Missing file is not an error. We also clear any
123
+ * leftover temp file from a crashed mid-write -- those aren't load-bearing
124
+ * but they're noise and they confuse `ls -la .runwork/`.
125
+ */
126
+ export declare function removeSessionFile(appDir: string): void;
127
+ /**
128
+ * Determine the live state of the session file in the given app directory.
129
+ *
130
+ * Checks are ordered cheap-to-expensive. The `pidAlive` syscall is last
131
+ * so a malformed or wrong-version file short-circuits before we touch
132
+ * the OS at all.
133
+ *
134
+ * 1. File missing -> `none`
135
+ * 2. File malformed / truncated -> `stale: malformed`
136
+ * 3. Schema version mismatch -> `stale: version-mismatch`
137
+ * 4. App ID mismatch -> `stale: app-id-mismatch`
138
+ * 5. Boot time outside tolerance -> `stale: boot-time-mismatch`
139
+ * 6. PID not alive -> `stale: pid-dead`
140
+ * 7. Otherwise -> `alive`
141
+ */
142
+ export declare function getSessionState(appDir: string, expectedAppId: string, deps?: SessionDeps): SessionState;
143
+ /**
144
+ * Convenience constructor for a fresh session file. Fills in version,
145
+ * startedAt, and bootTime from the runtime; everything else is supplied
146
+ * by the caller. Test code can pass a `deps.bootTime` override to inject
147
+ * a deterministic value.
148
+ */
149
+ export declare function buildSessionFile(input: {
150
+ pid: number;
151
+ sessionId: string;
152
+ appId: string;
153
+ previewUrl: string;
154
+ cliVersion: string;
155
+ mode: SessionMode;
156
+ startedAt?: number;
157
+ deps?: SessionDeps;
158
+ }): SessionFile;