cursor-opencode-provider 0.8.1 → 0.8.3

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 (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
@@ -0,0 +1,20 @@
1
+ import type { BidiStream } from "./transport/connect.js";
2
+ import { CursorTransportError } from "./errors.js";
3
+ /**
4
+ * `write()` returned false (bytes already buffered) and the later drain failed
5
+ * or was unavailable. Callers must not replay that frame.
6
+ */
7
+ export declare class StreamWriteDrainError extends CursorTransportError {
8
+ readonly accepted = true;
9
+ constructor(message: string, cause?: unknown);
10
+ }
11
+ /** True while a write on `stream` waits for `drain`; anything written next queues behind it. */
12
+ export declare function isStreamWriteBlocked(stream: BidiStream): boolean;
13
+ export declare function waitForStreamWrites(stream: BidiStream): Promise<void>;
14
+ /**
15
+ * Keep awaited protocol writes ordered per Run stream. A large reply must
16
+ * drain before another reply or heartbeat is queued; otherwise one writer
17
+ * observes the backlog created by ignored `false` write() results.
18
+ * `shouldWrite` is asked when the message's turn comes; false skips it.
19
+ */
20
+ export declare function writeStreamMessage(stream: BidiStream, message: Uint8Array, operation: string, shouldWrite?: () => boolean): Promise<boolean>;
@@ -0,0 +1,87 @@
1
+ import { trace } from "./debug.js";
2
+ import { CursorTransportError, toCursorProviderError } from "./errors.js";
3
+ /**
4
+ * `write()` returned false (bytes already buffered) and the later drain failed
5
+ * or was unavailable. Callers must not replay that frame.
6
+ */
7
+ export class StreamWriteDrainError extends CursorTransportError {
8
+ accepted = true;
9
+ constructor(message, cause) {
10
+ super(message, {
11
+ transient: false,
12
+ replaySafe: false,
13
+ code: "CURSOR_WRITE_BACKPRESSURE",
14
+ cause,
15
+ });
16
+ this.name = "StreamWriteDrainError";
17
+ }
18
+ }
19
+ const streamWriteChains = new WeakMap();
20
+ /** Streams whose current write is buffered and waiting for `drain`. */
21
+ const drainingStreams = new WeakSet();
22
+ /** True while a write on `stream` waits for `drain`; anything written next queues behind it. */
23
+ export function isStreamWriteBlocked(stream) {
24
+ return drainingStreams.has(stream);
25
+ }
26
+ export async function waitForStreamWrites(stream) {
27
+ const pending = streamWriteChains.get(stream);
28
+ if (pending)
29
+ await pending.catch(() => undefined);
30
+ }
31
+ /**
32
+ * Keep awaited protocol writes ordered per Run stream. A large reply must
33
+ * drain before another reply or heartbeat is queued; otherwise one writer
34
+ * observes the backlog created by ignored `false` write() results.
35
+ * `shouldWrite` is asked when the message's turn comes; false skips it.
36
+ */
37
+ export async function writeStreamMessage(stream, message, operation, shouldWrite) {
38
+ const previous = streamWriteChains.get(stream);
39
+ const current = (previous ? previous.catch(() => undefined) : Promise.resolve())
40
+ .then(async () => {
41
+ if (shouldWrite && !shouldWrite())
42
+ return false;
43
+ await writeStreamMessageNow(stream, message, operation);
44
+ return true;
45
+ });
46
+ streamWriteChains.set(stream, current);
47
+ try {
48
+ return await current;
49
+ }
50
+ finally {
51
+ if (streamWriteChains.get(stream) === current)
52
+ streamWriteChains.delete(stream);
53
+ }
54
+ }
55
+ async function writeStreamMessageNow(stream, message, operation) {
56
+ let accepted;
57
+ try {
58
+ accepted = stream.write(message);
59
+ }
60
+ catch (cause) {
61
+ throw toCursorProviderError(cause, {
62
+ replaySafe: false,
63
+ fallback: `Cursor ${operation} write failed`,
64
+ });
65
+ }
66
+ if (accepted !== false)
67
+ return;
68
+ trace(`stream write backpressured: operation=${operation} bytes=${message.length}`);
69
+ if (!stream.waitForDrain) {
70
+ throw new StreamWriteDrainError(`Cursor ${operation} write was backpressured`);
71
+ }
72
+ // No deadline: HTTP/2 emits `drain` only once the whole buffered message is
73
+ // out, so a timeout here would cap the upload, not detect a stall. Cursor CLI
74
+ // (connect-node) waits the same way; a closed or idle stream settles it.
75
+ const startedAt = Date.now();
76
+ drainingStreams.add(stream);
77
+ try {
78
+ await stream.waitForDrain();
79
+ trace(`stream write drained: operation=${operation} bytes=${message.length} ms=${Date.now() - startedAt}`);
80
+ }
81
+ catch (cause) {
82
+ throw new StreamWriteDrainError(`Cursor ${operation} backpressure drain failed`, cause);
83
+ }
84
+ finally {
85
+ drainingStreams.delete(stream);
86
+ }
87
+ }
@@ -0,0 +1,15 @@
1
+ import type { CursorShellPolicy } from "./shell-timeout.js";
2
+ export type ManagedTerminal = {
3
+ command: string;
4
+ directory: string;
5
+ file: string;
6
+ shellId: number;
7
+ };
8
+ /** A detached supervisor owns its command group and durable terminal record. */
9
+ export declare function createManagedTerminal(policy: CursorShellPolicy, root: string): ManagedTerminal;
10
+ /**
11
+ * Denied/unstarted commands leave no reserved record; running jobs are durable.
12
+ * A launcher claims its job before it starts anything and hands the claim back
13
+ * when startup fails, so only a release that takes the claim removes files.
14
+ */
15
+ export declare function releaseUnstartedTerminal(directory: string | undefined): void;
@@ -0,0 +1,528 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomBytes, randomInt, randomUUID } from "node:crypto";
4
+ import { MAX_RUNTIME_WRITE_BYTES, maintainRuntimeFiles, runtimeLayout } from "./runtime-files.js";
5
+ const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
6
+ const MAX_LAUNCHER_OUTPUT_BYTES = 1024 * 1024;
7
+ /** Startup budget: the launcher must report well inside the host's timeout. */
8
+ const START_DEADLINE_SECONDS = 10;
9
+ /** Launcher check interval; deadlines come from their own timers, not counts. */
10
+ const LAUNCHER_POLL_SECONDS = 0.05;
11
+ /**
12
+ * How long a hard-timed-out command may keep the launcher past its window:
13
+ * the supervisor's three-second TERM grace plus its one-second cancellation
14
+ * drain. With START_DEADLINE_SECONDS this stays inside the 15 s the host's
15
+ * own timeout adds to the window.
16
+ */
17
+ const TIMEOUT_SETTLE_SECONDS = 4;
18
+ /**
19
+ * Soft-background launcher tail: wait out Cursor's foreground window, then
20
+ * return the finished result, a hard timeout, or the output so far plus a
21
+ * background handle.
22
+ */
23
+ function softBackgroundWindow(policy, shellId) {
24
+ return [
25
+ 'cursor_header=$(cat "$cursor_job/header-bytes")',
26
+ `cursor_deadline ${Math.max(0, policy.timeoutMs) / 1000} window-expired`,
27
+ 'while [ ! -f "$cursor_job/done" ] && [ ! -f "$cursor_job/window-expired" ]; do',
28
+ // A supervisor that exited after writing its record is a finished command.
29
+ ' if ! kill -0 "$cursor_supervisor" 2>/dev/null; then',
30
+ ' if [ -f "$cursor_job/done" ] || [ -f "$cursor_job/timedout" ]; then break; fi',
31
+ ' cursor_cancel_deadline',
32
+ ' printf "Terminal supervisor stopped without a final record\\n\\n__CURSOR_SHELL_EXIT__1\\n"; exit 1',
33
+ " fi",
34
+ ` sleep ${LAUNCHER_POLL_SECONDS}`, "done",
35
+ 'cursor_cancel_deadline',
36
+ // A hard-timed-out command is being stopped, not running on: never hand it
37
+ // off. Wait a bounded time for the supervisor to finish its record.
38
+ 'if [ ! -f "$cursor_job/done" ] && [ -f "$cursor_job/timedout" ]; then',
39
+ ` cursor_deadline ${TIMEOUT_SETTLE_SECONDS} settle-expired`,
40
+ ' while [ ! -f "$cursor_job/done" ] && [ ! -f "$cursor_job/settle-expired" ] && kill -0 "$cursor_supervisor" 2>/dev/null; do',
41
+ ` sleep ${LAUNCHER_POLL_SECONDS}`, " done",
42
+ ' cursor_cancel_deadline',
43
+ "fi",
44
+ 'if [ -f "$cursor_job/done" ]; then',
45
+ ...capturedOutput('$(cat "$cursor_job/output-bytes")'),
46
+ ' if [ -f "$cursor_job/timedout" ]; then',
47
+ ` printf '\\n__CURSOR_SHELL_TIMEOUT__%s\\n' ${policy.hardTimeoutMs ?? 0}`,
48
+ ' else printf "\\n__CURSOR_SHELL_EXIT__%s\\n" "$(cat "$cursor_job/done")"; fi',
49
+ 'elif [ -f "$cursor_job/timedout" ]; then',
50
+ ...capturedOutput('$(( $(wc -c <"$cursor_terminal") - cursor_header ))'),
51
+ ` printf '\\n__CURSOR_SHELL_TIMEOUT__%s\\n' ${policy.hardTimeoutMs ?? 0}`,
52
+ "else",
53
+ ' if ! kill -0 "$cursor_supervisor" 2>/dev/null || [ -f "$cursor_job/error" ]; then cat "$cursor_job/error" 2>/dev/null; printf "\\n__CURSOR_SHELL_EXIT__1\\n"; exit 1; fi',
54
+ // A soft handoff returns what the foreground window produced, as the
55
+ // command would have shown before it moved to the background.
56
+ ...capturedOutput('$(( $(wc -c <"$cursor_terminal") - cursor_header ))'),
57
+ ` printf '\\n__CURSOR_SHELL_BACKGROUND__%s:%s:%s\\n' ${shellId} "$cursor_supervisor" "$cursor_terminal"`,
58
+ "fi",
59
+ ];
60
+ }
61
+ /**
62
+ * Host results carry command output only; the terminal file keeps the
63
+ * header/footer, and its reads are served by the runtime store.
64
+ */
65
+ function capturedOutput(bytes) {
66
+ return [
67
+ ` cursor_output=${bytes}`,
68
+ // BSD head rejects `-c 0` ("illegal byte count") and its stderr reaches the result.
69
+ ` if [ "$cursor_output" -gt 0 ]; then tail -c +$((cursor_header + 1)) "$cursor_terminal" | head -c "$cursor_output" | head -c ${MAX_LAUNCHER_OUTPUT_BYTES}; fi`,
70
+ ` if [ "$cursor_output" -gt ${MAX_LAUNCHER_OUTPUT_BYTES} ]; then printf "\\n[Partial output: terminal file contains more output]\\n"; fi`,
71
+ ];
72
+ }
73
+ /** A heredoc delimiter that cannot terminate the embedded command early. */
74
+ function commandDelimiter(command) {
75
+ for (;;) {
76
+ const delimiter = `CURSOR_COMMAND_${randomBytes(16).toString("hex")}`;
77
+ if (!command.split("\n").includes(delimiter))
78
+ return delimiter;
79
+ }
80
+ }
81
+ /**
82
+ * True when `$1` is a live process whose command line contains `$2`. Linux
83
+ * procfs is authoritative when present; `ps -p … -o command=` covers macOS;
84
+ * a host with neither (e.g. a minimal BusyBox image) falls back to liveness.
85
+ */
86
+ const IS_OURS = String.raw `is_ours() {
87
+ if [ -r "/proc/$1/cmdline" ]; then
88
+ case "$(tr '\000' ' ' <"/proc/$1/cmdline" 2>/dev/null)" in *"$2"*) return 0 ;; esac
89
+ return 1
90
+ fi
91
+ [ -d /proc/self ] && return 1
92
+ if identity=$(ps -p "$1" -o command= 2>/dev/null) && [ -n "$identity" ]; then
93
+ case "$identity" in *"$2"*) return 0 ;; esac
94
+ return 1
95
+ fi
96
+ kill -0 "$1" 2>/dev/null
97
+ }`;
98
+ /** A detached supervisor owns its command group and durable terminal record. */
99
+ export function createManagedTerminal(policy, root) {
100
+ if (process.platform === "win32")
101
+ throw new Error("Managed background terminals require a POSIX shell");
102
+ const layout = runtimeLayout(root, true);
103
+ maintainRuntimeFiles(layout);
104
+ const jobs = path.join(layout.terminals, ".jobs");
105
+ try {
106
+ fs.mkdirSync(jobs, { mode: 0o700 });
107
+ }
108
+ catch (error) {
109
+ if (error.code !== "EEXIST")
110
+ throw error;
111
+ }
112
+ if (!fs.lstatSync(jobs).isDirectory() || fs.lstatSync(jobs).isSymbolicLink())
113
+ throw new Error("Unsafe terminal job directory");
114
+ let file = "";
115
+ let shellId = 0;
116
+ for (let attempt = 0; attempt < 16; attempt++) {
117
+ shellId = randomInt(1000, 1_000_000);
118
+ file = path.join(layout.terminals, `${shellId}.txt`);
119
+ if (fs.existsSync(path.join(jobs, `${shellId}.json`))) {
120
+ file = "";
121
+ continue;
122
+ }
123
+ try {
124
+ fs.closeSync(fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600));
125
+ break;
126
+ }
127
+ catch (error) {
128
+ if (error.code !== "EEXIST")
129
+ throw error;
130
+ file = "";
131
+ }
132
+ }
133
+ if (!file)
134
+ throw new Error("Could not reserve a unique terminal id");
135
+ const directory = path.join(jobs, randomUUID());
136
+ let ownerCreated = false;
137
+ try {
138
+ fs.mkdirSync(directory, { mode: 0o700 });
139
+ // Reuse the provider's JavaScript runtime for a byte-bounded pipe drain.
140
+ // Polling a plain cat's file size permits arbitrarily large overshoots.
141
+ fs.writeFileSync(path.join(directory, "collector.cjs"), COLLECTOR, { mode: 0o600 });
142
+ fs.writeFileSync(path.join(directory, "group.sh"), [
143
+ "#!/bin/sh", `job=${quote(directory)}`,
144
+ // The supervisor naps between checks; USR1 wakes it when state changes.
145
+ 'supervisor="$1"',
146
+ // Keep the leader alive until completion or cancellation so a reused
147
+ // PID/PGID can never receive the supervisor's cancellation signal.
148
+ "trap ':' TERM INT HUP",
149
+ // The supervisor holds the lifeline's only write end. Its exit, however
150
+ // abrupt (KILL, OOM, crash), is EOF here and stops an unfinished group.
151
+ 'exec 3<"$job/lifeline" || exit 1',
152
+ '"$CURSOR_SHELL_EXECUTABLE" -c "$(cat "$job/command.sh")" 3<&- &', "payload=$!",
153
+ "(",
154
+ " trap '' TERM INT HUP",
155
+ ' while read -r state; do [ "$state" != completed ] || exit 0; done <&3',
156
+ ' [ ! -f "$job/done" ] || exit 0',
157
+ // Ubuntu's /bin/sh (dash) rejects `kill -TERM -- -PGID`; the negative
158
+ // numeric operand works in dash and macOS sh without `--`.
159
+ ' kill -TERM "-$$" 2>/dev/null',
160
+ " sleep 3",
161
+ ' kill -KILL "-$$" 2>/dev/null',
162
+ ") >/dev/null 2>&1 &",
163
+ "watcher=$!",
164
+ "exec 3<&-",
165
+ // The payload holds its own copy of the pipe. This shell's stderr would
166
+ // now carry only job notices: dash prints "Terminated" when `wait` reaps
167
+ // a signalled payload, and that text is not command output.
168
+ "exec 2>/dev/null",
169
+ 'printf ready >"$job/group-ready"',
170
+ 'kill -USR1 "$supervisor" 2>/dev/null',
171
+ // A trapped TERM interrupts wait; keep the payload's real exit status.
172
+ "while :; do",
173
+ ' wait "$payload"; code=$?',
174
+ ' kill -0 "$payload" 2>/dev/null || break',
175
+ "done",
176
+ 'printf "%s\\n" "$code" >"$job/command-exit"',
177
+ 'kill -USR1 "$supervisor" 2>/dev/null',
178
+ "exec >/dev/null 2>&1",
179
+ 'while kill -0 "$watcher" 2>/dev/null; do wait "$watcher"; done',
180
+ 'exit "$code"', "",
181
+ ].join("\n"), { mode: 0o700 });
182
+ fs.writeFileSync(path.join(directory, "owner.json"), JSON.stringify({ shellId, file, workspace: policy.workingDirectory }), { mode: 0o600 });
183
+ fs.writeFileSync(path.join(jobs, `${shellId}.json`), JSON.stringify({ shellId, job: path.basename(directory) }), { mode: 0o600, flag: "wx" });
184
+ ownerCreated = true;
185
+ const setup = [
186
+ "#!/bin/sh", "umask 077",
187
+ `job=${quote(directory)}`, `terminal=${quote(file)}`,
188
+ `root=${quote(root)}`, `terminals=${quote(layout.terminals)}`,
189
+ `cwd_value=${quote(JSON.stringify(policy.workingDirectory))}`,
190
+ `workdir=${quote(policy.workingDirectory)}`,
191
+ `command_value=${quote(JSON.stringify(policy.command))}`,
192
+ `title_value=${quote(JSON.stringify(policy.description ?? ""))}`,
193
+ `hard_seconds=${quote(String((policy.hardTimeoutMs ?? 0) / 1000))}`,
194
+ `max_output=${MAX_RUNTIME_WRITE_BYTES}`,
195
+ `runtime=${quote(process.execPath)}`,
196
+ `payload_shell=${quote(policy.shellPath ?? process.env.SHELL ?? "/bin/sh")}`,
197
+ '[ -z "$CURSOR_SHELL_EXECUTABLE" ] || payload_shell="$CURSOR_SHELL_EXECUTABLE"',
198
+ 'export CURSOR_SHELL_EXECUTABLE="$payload_shell"',
199
+ ].join("\n");
200
+ fs.writeFileSync(path.join(directory, "runner.sh"), setup + "\n" + IS_OURS + "\n" + SUPERVISOR, { mode: 0o700 });
201
+ const delimiter = commandDelimiter(policy.command);
202
+ const body = policy.command.endsWith("\n") ? policy.command : policy.command + "\n";
203
+ const command = [
204
+ // The approved launcher writes the command itself: an approval prompt
205
+ // that shows this launcher shows exactly what the supervisor will run.
206
+ `cursor_job=${quote(directory)}`,
207
+ // Claim the reservation before writing or starting anything: a release
208
+ // either takes the claim first and removes it, or leaves it to this run.
209
+ 'mkdir "$cursor_job/claimed" 2>/dev/null || { printf "Background terminal was released before it started\\n\\n__CURSOR_SHELL_EXIT__1\\n"; exit 1; }',
210
+ `cat >"$cursor_job/command.sh" <<'${delimiter}' || { rmdir "$cursor_job/claimed"; exit 1; }`,
211
+ body + delimiter,
212
+ `cursor_terminal=${quote(file)}`,
213
+ IS_OURS,
214
+ // Bash job control gives the command its own process group on macOS,
215
+ // where setsid is not a standard executable. Else require setsid.
216
+ 'cursor_runner=$(command -v bash 2>/dev/null || true)',
217
+ 'if [ -z "$cursor_runner" ]; then cursor_runner=/bin/sh; fi',
218
+ 'nohup "$cursor_runner" "$cursor_job/runner.sh" >"$cursor_job/launcher.log" 2>&1 </dev/null &',
219
+ "cursor_supervisor=$!",
220
+ // Each deadline is one sleep that marks a file when it elapses. Counting
221
+ // short polls instead drifts with process-start cost (over 50% on
222
+ // macOS) and lets the host's own timeout kill this launcher, and with
223
+ // it the supervisor in the same process group, before it can report.
224
+ "cursor_deadline() {",
225
+ ' ( cursor_nap=""; cursor_stopping=0',
226
+ // Until it execs, the sleep still has this subshell's TERM trap, which
227
+ // swallows a TERM. KILL cannot be caught.
228
+ ' cursor_stop_sleep() { trap "" TERM INT HUP; kill -KILL "$cursor_nap" 2>/dev/null; wait "$cursor_nap" 2>/dev/null; }',
229
+ // TERM can arrive after fork but before $! is assigned. Defer exit
230
+ // until that assignment so the child remains owned and can be reaped.
231
+ ` trap 'cursor_stopping=1; if [ -n "$cursor_nap" ]; then cursor_stop_sleep; exit 0; fi' TERM INT HUP`,
232
+ ' sleep "$1" & cursor_nap=$!',
233
+ ' if [ "$cursor_stopping" = 1 ]; then cursor_stop_sleep; exit 0; fi',
234
+ ' wait "$cursor_nap" && : >"$cursor_job/$2" ) >/dev/null 2>&1 </dev/null &',
235
+ " cursor_timer=$!",
236
+ "}",
237
+ 'cursor_cancel_deadline() { [ -n "$cursor_timer" ] || return 0; kill "$cursor_timer" 2>/dev/null; wait "$cursor_timer" 2>/dev/null; cursor_timer=""; }',
238
+ 'trap cursor_cancel_deadline 0',
239
+ `cursor_deadline ${START_DEADLINE_SECONDS} start-expired`,
240
+ 'while [ ! -f "$cursor_job/ready" ] && [ ! -f "$cursor_job/error" ] && [ ! -f "$cursor_job/start-expired" ] && kill -0 "$cursor_supervisor" 2>/dev/null; do',
241
+ ` sleep ${LAUNCHER_POLL_SECONDS}`, "done",
242
+ 'cursor_cancel_deadline',
243
+ 'if [ ! -f "$cursor_job/ready" ]; then',
244
+ ' if is_ours "$cursor_supervisor" "$cursor_job/runner.sh"; then kill -TERM "$cursor_supervisor" 2>/dev/null || true; fi',
245
+ ' cat "$cursor_job/error" 2>/dev/null || printf "Background terminal failed to initialize\\n"',
246
+ // Hand the claim back so releasing the call removes the failed start.
247
+ ' rmdir "$cursor_job/claimed" 2>/dev/null',
248
+ ' printf "\\n__CURSOR_SHELL_EXIT__1\\n"; exit 1', "fi",
249
+ ...(policy.backgroundSpawn
250
+ // A spawn reports its handle as soon as the command is running. A
251
+ // command that already finished is still a successful spawn; its
252
+ // output and exit status are in the terminal record.
253
+ ? [`printf '\\n__CURSOR_BACKGROUND_SHELL__%s:%s:%s\\n' ${shellId} "$cursor_supervisor" "$cursor_terminal"`]
254
+ : softBackgroundWindow(policy, shellId)),
255
+ ].join("\n");
256
+ return { command, directory, file, shellId };
257
+ }
258
+ catch (error) {
259
+ fs.rmSync(directory, { recursive: true, force: true });
260
+ fs.rmSync(file, { force: true });
261
+ if (ownerCreated)
262
+ fs.rmSync(path.join(jobs, `${shellId}.json`), { force: true });
263
+ throw error;
264
+ }
265
+ }
266
+ /**
267
+ * Denied/unstarted commands leave no reserved record; running jobs are durable.
268
+ * A launcher claims its job before it starts anything and hands the claim back
269
+ * when startup fails, so only a release that takes the claim removes files.
270
+ */
271
+ export function releaseUnstartedTerminal(directory) {
272
+ if (!directory || fs.existsSync(path.join(directory, "ready")))
273
+ return;
274
+ try {
275
+ const owner = JSON.parse(fs.readFileSync(path.join(directory, "owner.json"), "utf8"));
276
+ const jobs = path.dirname(directory);
277
+ if (path.basename(jobs) !== ".jobs" || !/^[0-9a-f-]{36}$/.test(path.basename(directory)))
278
+ return;
279
+ const file = path.join(path.dirname(jobs), `${owner.shellId}.txt`);
280
+ if (!Number.isSafeInteger(owner.shellId) || owner.shellId < 1000 || owner.shellId >= 1_000_000 || owner.file !== file)
281
+ return;
282
+ if (fs.lstatSync(jobs).isSymbolicLink() || fs.lstatSync(directory).isSymbolicLink())
283
+ return;
284
+ fs.mkdirSync(path.join(directory, "claimed"));
285
+ // A supervisor still stopping after a failed start writes the record only
286
+ // while its job directory exists, so remove that directory first.
287
+ fs.rmSync(directory, { recursive: true, force: true });
288
+ fs.rmSync(path.join(jobs, `${owner.shellId}.json`), { force: true });
289
+ fs.rmSync(file, { force: true });
290
+ }
291
+ catch { /* a launcher holds the claim, or best effort */ }
292
+ }
293
+ /** Append at most the output budget, then continue draining until pipe EOF. */
294
+ const COLLECTOR = String.raw `
295
+ const fs = require("node:fs")
296
+ const [job, terminal, maxOutput, supervisor] = process.argv.slice(2)
297
+ const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW
298
+ let output
299
+ let stored = 0
300
+ let capped = false
301
+ function fail() {
302
+ try { fs.writeFileSync(job + "/error", "Terminal output storage failed\n") } catch {}
303
+ try { process.kill(Number(supervisor), "SIGUSR1") } catch {}
304
+ if (output !== undefined) { try { fs.closeSync(output) } catch {}; output = undefined }
305
+ }
306
+ function append(bytes) {
307
+ if (output === undefined) return
308
+ try {
309
+ let offset = 0
310
+ while (offset < bytes.length) {
311
+ const written = fs.writeSync(output, bytes, offset, bytes.length - offset)
312
+ if (!written) throw new Error("Terminal write made no progress")
313
+ offset += written
314
+ }
315
+ } catch { fail() }
316
+ }
317
+ try {
318
+ output = fs.openSync(terminal, flags)
319
+ const stat = fs.fstatSync(output)
320
+ if (!stat.isFile() || stat.nlink !== 1) throw new Error("Unsafe terminal record")
321
+ } catch { fail() }
322
+ const input = fs.openSync(job + "/pipe", fs.constants.O_RDONLY)
323
+ const buffer = Buffer.alloc(64 * 1024)
324
+ try {
325
+ for (;;) {
326
+ const read = fs.readSync(input, buffer, 0, buffer.length)
327
+ if (!read) break
328
+ const count = Math.min(read, Math.max(0, Number(maxOutput) - stored))
329
+ append(buffer.subarray(0, count))
330
+ stored += count
331
+ if (count < read && !capped) {
332
+ capped = true
333
+ append(Buffer.from("\n[Partial output: terminal output reached the 8 MiB storage limit]\n"))
334
+ }
335
+ }
336
+ fs.writeFileSync(job + "/collected", "")
337
+ } catch { fail() }
338
+ finally {
339
+ fs.closeSync(input)
340
+ if (output !== undefined) fs.closeSync(output)
341
+ }
342
+ `;
343
+ const SUPERVISOR = String.raw `
344
+ started=$(date +%s)
345
+ started_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
346
+ supervisor=$$
347
+ command_pid=""
348
+ collector=""
349
+ updater=""
350
+ watchdog=""
351
+ aborted=0
352
+ group_stopped=0
353
+ group_mode=""
354
+ fail() { printf '%s\n' "$1" >"$job/error"; exit 1; }
355
+ safe_dirs() { [ ! -L "$root" ] && [ ! -L "$terminals" ] && [ ! -L "$job" ] && [ -d "$job" ]; }
356
+ # Every field has a fixed width, so the header is rewritten in place and
357
+ # command output is appended once instead of being copied on every update.
358
+ header() {
359
+ printf '%s\n' '---' "pid: $supervisor" "cwd: $cwd_value" "command: $command_value"
360
+ [ "$title_value" = '""' ] || printf 'title: %s\n' "$title_value"
361
+ printf 'status: %-9s\nstarted_at: %s\nrunning_for_ms: %-15s\n---\n' "$1" "$started_at" "$(( ($(date +%s) - started) * 1000 ))"
362
+ }
363
+ write_header() { safe_dirs && header "$1" 1<>"$terminal"; }
364
+ owned_group() {
365
+ [ -n "$command_pid" ] || return 1
366
+ if [ "$group_mode" = bash ]; then
367
+ for running in $(jobs -pr); do [ "$running" = "$command_pid" ] && return 0; done
368
+ return 1
369
+ fi
370
+ is_ours "$command_pid" "$job/group.sh"
371
+ }
372
+ # TERM first; KILL once the payload has exited and nothing holds its output
373
+ # open any more, or after a three-second grace period. A group is stopped
374
+ # once: bash can still list a job it has just killed as running, and a killed
375
+ # group never writes command-exit, so a second pass would wait out the grace.
376
+ stop_group() {
377
+ [ "$group_stopped" = 0 ] || return 0
378
+ owned_group || return 0
379
+ kill -TERM "-$command_pid" 2>/dev/null || true
380
+ grace=0
381
+ while [ "$grace" -lt 30 ] && { [ ! -f "$job/command-exit" ] || kill -0 "$collector" 2>/dev/null; }; do
382
+ sleep 0.1; grace=$((grace + 1))
383
+ done
384
+ if owned_group; then kill -KILL "-$command_pid" 2>/dev/null || true; fi
385
+ group_stopped=1
386
+ }
387
+ # A trapped signal that interrupts bash's wait can discard the status of a
388
+ # child that call reaped, and a later wait for that child then blocks while
389
+ # any other child lives. Wait only for a child that still exists.
390
+ reap() {
391
+ while kill -0 "$1" 2>/dev/null; do
392
+ wait "$1" 2>/dev/null
393
+ if kill -0 "$1" 2>/dev/null; then sleep 0.1; fi
394
+ done
395
+ }
396
+ stop_job() {
397
+ [ -z "$1" ] || { kill -TERM "-$1" 2>/dev/null || kill -TERM "$1" 2>/dev/null || true; reap "$1"; }
398
+ }
399
+ on_signal() { aborted=1; stop_group; }
400
+ trap on_signal TERM INT HUP
401
+ # group.sh and the collector send USR1 when there is something to observe.
402
+ trap ':' USR1
403
+ # Sleep in a child so a trapped signal interrupts the wait at once, instead
404
+ # of forking a short sleep many times per second for the job's whole life.
405
+ nap() {
406
+ sleep "$1" & napper=$!
407
+ wait "$napper" 2>/dev/null || kill -KILL "$napper" 2>/dev/null
408
+ }
409
+ safe_dirs || fail 'Runtime terminal directory is unavailable'
410
+ cd -- "$workdir" || fail 'Background command working directory is unavailable'
411
+ [ -f "$job/command.sh" ] || fail 'Background command was not written'
412
+ mkfifo "$job/pipe" || fail 'Could not initialize terminal pipe'
413
+ mkfifo "$job/lifeline" || fail 'Could not initialize terminal supervision'
414
+ if [ -n "$BASH_VERSION" ]; then
415
+ set -m
416
+ group_mode=bash
417
+ elif command -v setsid >/dev/null 2>&1; then group_mode=setsid
418
+ else fail 'Background terminals require bash or setsid for command-group cancellation'; fi
419
+ safe_dirs && header running >"$terminal" || fail 'Could not initialize terminal record'
420
+ header_bytes=$(( $(wc -c <"$terminal") ))
421
+ printf '%s\n' "$header_bytes" >"$job/header-bytes" || fail 'Could not initialize terminal record'
422
+ [ "$aborted" = 0 ] || fail 'Background terminal was cancelled before launch'
423
+ # Held for the supervisor's whole life; every child closes its copy.
424
+ exec 4<>"$job/lifeline" || fail 'Could not initialize terminal supervision'
425
+ # A compiled Bun host re-enters the runtime instead of its bundled CLI.
426
+ BUN_BE_BUN=1 "$runtime" "$job/collector.cjs" "$job" "$terminal" "$max_output" "$supervisor" 2>/dev/null 4>&- &
427
+ collector=$!
428
+ if [ "$group_mode" = setsid ]; then
429
+ setsid sh "$job/group.sh" "$supervisor" >"$job/pipe" 2>&1 </dev/null 4>&- &
430
+ else
431
+ sh "$job/group.sh" "$supervisor" >"$job/pipe" 2>&1 </dev/null 4>&- &
432
+ fi
433
+ command_pid=$!
434
+ start_poll=0
435
+ while [ ! -f "$job/group-ready" ] && [ "$aborted" = 0 ] && kill -0 "$command_pid" 2>/dev/null && [ "$start_poll" -lt 100 ]; do
436
+ nap 0.1; start_poll=$((start_poll + 1))
437
+ done
438
+ if [ ! -f "$job/group-ready" ] || [ "$aborted" = 1 ]; then
439
+ stop_group; stop_job "$collector"; fail 'Background command group failed to initialize'
440
+ fi
441
+ printf '%s\n' "$supervisor" >"$job/ready"
442
+ # A TERM that reaches a helper before it has reset the supervisor's traps is
443
+ # discarded, so the helpers also stop on their own once job/finishing exists.
444
+ # They signal the supervisor only while it is still this job's: an abrupt
445
+ # death frees its PID for an unrelated process.
446
+ if [ "$hard_seconds" != 0 ]; then
447
+ # Without bash job control this subshell shares the supervisor's group, so
448
+ # stop_job can signal only the shell. The trap reaps the deadline sleep,
449
+ # the same way the launcher's cursor_deadline does.
450
+ (
451
+ cursor_nap=""
452
+ cursor_stopping=0
453
+ cursor_stop_sleep() { trap "" TERM INT HUP; kill -KILL "$cursor_nap" 2>/dev/null; wait "$cursor_nap" 2>/dev/null; }
454
+ trap 'cursor_stopping=1; if [ -n "$cursor_nap" ]; then cursor_stop_sleep; exit 0; fi' TERM INT HUP
455
+ [ ! -f "$job/finishing" ] || exit 0
456
+ sleep "$hard_seconds" & cursor_nap=$!
457
+ if [ "$cursor_stopping" = 1 ]; then cursor_stop_sleep; exit 0; fi
458
+ wait "$cursor_nap" && { : >"$job/timedout"; ! is_ours "$supervisor" "$job/runner.sh" || kill -TERM "$supervisor" 2>/dev/null; }
459
+ ) 4>&- &
460
+ watchdog=$!
461
+ fi
462
+ (
463
+ while [ ! -f "$job/finishing" ] && kill -0 "$supervisor" 2>/dev/null; do
464
+ sleep 1
465
+ write_header running && continue
466
+ printf 'Terminal output storage failed\n' >"$job/error"
467
+ if is_ours "$supervisor" "$job/runner.sh"; then kill -TERM "$supervisor"; exit 1; fi
468
+ break
469
+ done
470
+ [ ! -f "$job/finishing" ] || exit 0
471
+ # A supervisor that died abruptly cannot stop the helpers that do not hold
472
+ # the lifeline. The lifeline stops the command group within its grace, but a
473
+ # descendant in its own process group can hold the output open indefinitely.
474
+ [ -z "$watchdog" ] || ! is_ours "$watchdog" "$job/runner.sh" || kill -TERM "$watchdog" 2>/dev/null
475
+ drain=0
476
+ while [ "$drain" -lt 50 ] && kill -0 "$collector" 2>/dev/null; do sleep 0.1; drain=$((drain + 1)); done
477
+ ! is_ours "$collector" "$job/collector.cjs" || kill -TERM "$collector" 2>/dev/null
478
+ ) 4>&- &
479
+ updater=$!
480
+ # The nap is only a backstop: command exit, storage failure, timeouts and
481
+ # cancellation all signal this process and end it early.
482
+ while [ "$aborted" = 0 ] && [ ! -f "$job/command-exit" ] && kill -0 "$command_pid" 2>/dev/null; do
483
+ if [ -f "$job/error" ]; then on_signal; break; fi
484
+ nap 1
485
+ done
486
+ if [ "$aborted" = 1 ]; then stop_group; code=143
487
+ elif [ -f "$job/command-exit" ]; then code=$(cat "$job/command-exit")
488
+ # The leader ended without reporting. Its status is read once no other child
489
+ # is left, because bash may already have discarded it.
490
+ else code=lost; fi
491
+ if [ "$aborted" = 1 ]; then stop_group; code=143; fi
492
+ : >"$job/finishing"
493
+ stop_job "$updater"
494
+ stop_job "$watchdog"
495
+ # Descendants can still hold stdout after the command exits. Give them a
496
+ # bounded drain window. Normal completion must preserve deliberately detached
497
+ # daemons; only cancellation or an unexpected owner exit stops their group.
498
+ drain=0
499
+ drain_limit=50
500
+ [ "$aborted" = 0 ] || drain_limit=10
501
+ while kill -0 "$collector" 2>/dev/null && [ "$drain" -lt "$drain_limit" ]; do sleep 0.1; drain=$((drain + 1)); done
502
+ # A copier that ended on its own either reached EOF or could not write. It
503
+ # marks EOF itself, since bash can lose its exit status. During native
504
+ # cancellation an early end alone cannot establish an I/O failure. Real
505
+ # collector write/read failures already wrote job/error.
506
+ if ! kill -0 "$collector" 2>/dev/null && [ ! -f "$job/collected" ] && [ "$aborted" = 0 ]; then
507
+ printf 'Terminal output storage failed\n' >"$job/error"
508
+ fi
509
+ if [ "$aborted" = 1 ] || [ "$code" != 0 ]; then stop_group; fi
510
+ stop_job "$collector"
511
+ if [ "$code" = lost ]; then wait "$command_pid" 2>/dev/null; code=$?; fi
512
+ if [ -f "$job/error" ]; then aborted=1; code=1; fi
513
+ if [ "$aborted" = 1 ]; then status=aborted
514
+ elif [ "$code" = 0 ]; then status=succeeded
515
+ else status=failed; fi
516
+ printf '%s\n' "$(( $(wc -c <"$terminal") - header_bytes ))" >"$job/output-bytes" || fail 'Could not finalize terminal record'
517
+ write_header "$status" || fail 'Could not finalize terminal record'
518
+ printf '\n---\nexit_code: %s\nelapsed_ms: %s\nended_at: %s\n' "$code" "$(( ($(date +%s) - started) * 1000 ))" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" >>"$terminal" || fail 'Could not finalize terminal record'
519
+ if [ -f "$job/error" ]; then printf 'output_storage_failed: true\n' >>"$terminal"; fi
520
+ printf '%s\n' '---' >>"$terminal" || fail 'Could not finalize terminal record'
521
+ printf '%s\n' "$code" >"$job/done"
522
+ # Disarm the FIFO watcher before releasing the leader. The explicit message
523
+ # survives immediate cleanup of job/done by a result consumer.
524
+ printf 'completed\n' >&4
525
+ wait "$command_pid" 2>/dev/null || true
526
+ rm -f "$job/pipe" "$job/lifeline" "$job/command.sh" "$job/group.sh" "$job/collector.cjs" "$job/runner.sh" "$job/command-exit" "$job/group-ready" "$job/finishing" "$job/collected"
527
+ exit 0
528
+ `;