cursor-opencode-provider 0.8.0 → 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.
- package/CHANGELOG.md +174 -0
- package/README.md +230 -44
- package/SECURITY.md +60 -12
- package/dist/activity.d.ts +73 -1
- package/dist/activity.js +231 -5
- package/dist/agent-url.js +37 -9
- package/dist/auth-renewal.d.ts +1 -1
- package/dist/auth-renewal.js +58 -26
- package/dist/billing.d.ts +105 -0
- package/dist/billing.js +212 -0
- package/dist/classic-tool-results.d.ts +1 -0
- package/dist/classic-tool-results.js +42 -0
- package/dist/context/build.d.ts +23 -4
- package/dist/context/build.js +65 -10
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/frozen.js +6 -2
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +45 -18
- package/dist/context/paths.js +72 -22
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +34 -0
- package/dist/context/skills.js +211 -0
- package/dist/debug.js +13 -2
- package/dist/early-tool-rows.d.ts +60 -0
- package/dist/early-tool-rows.js +96 -0
- package/dist/errors.d.ts +49 -7
- package/dist/errors.js +163 -8
- package/dist/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +122 -30
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +149 -30
- package/dist/language-model.js +3127 -673
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +12 -1
- package/dist/model-config.js +78 -30
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +233 -65
- package/dist/plugin.js +97 -23
- package/dist/pricing-data.d.ts +40 -3
- package/dist/pricing-data.js +40 -3
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +7 -4
- package/dist/process-shared.d.ts +14 -0
- package/dist/process-shared.js +18 -0
- package/dist/protocol/apply-patch.d.ts +25 -13
- package/dist/protocol/apply-patch.js +79 -30
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- package/dist/protocol/blob-store.d.ts +5 -1
- package/dist/protocol/blob-store.js +10 -6
- package/dist/protocol/client-version.js +6 -4
- package/dist/protocol/conversation-cache-header.d.ts +23 -0
- package/dist/protocol/conversation-cache-header.js +149 -0
- package/dist/protocol/conversation-persistence.d.ts +27 -1
- package/dist/protocol/conversation-persistence.js +628 -126
- package/dist/protocol/conversation-state.d.ts +8 -1
- package/dist/protocol/conversation-state.js +28 -7
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +164 -21
- package/dist/protocol/request.d.ts +40 -19
- package/dist/protocol/request.js +55 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +6 -0
- package/dist/protocol/tool-call-bridge.js +24 -0
- package/dist/protocol/tools.d.ts +90 -9
- package/dist/protocol/tools.js +543 -151
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +22 -4
- package/dist/replay-safety.js +86 -27
- package/dist/retry-budget.d.ts +21 -0
- package/dist/retry-budget.js +94 -0
- package/dist/runtime-files.d.ts +23 -0
- package/dist/runtime-files.js +581 -0
- package/dist/session.d.ts +129 -10
- package/dist/session.js +198 -35
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/shell-timeout.d.ts +54 -10
- package/dist/shell-timeout.js +362 -92
- package/dist/stream-write.d.ts +20 -0
- package/dist/stream-write.js +87 -0
- package/dist/terminal-runner.d.ts +15 -0
- package/dist/terminal-runner.js +528 -0
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +10 -1
- package/dist/transport/connect.js +124 -27
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +85 -34
- package/package.json +5 -3
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
- package/dist/protocol/thinking.d.ts +0 -15
- 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
|
+
`;
|
package/dist/todo-store.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type SessionTodo = {
|
|
|
22
22
|
/** Normalize one host/Cursor todo item. Drops entries with empty content. */
|
|
23
23
|
export declare function normalizeSessionTodo(value: unknown, index: number): SessionTodo | undefined;
|
|
24
24
|
export declare function normalizeSessionTodos(value: unknown): SessionTodo[];
|
|
25
|
+
/** Fill omitted defaults before validation without dropping malformed entries. */
|
|
26
|
+
export declare function normalizeOpencodeTodoArgs(args: Record<string, unknown>): Record<string, unknown>;
|
|
25
27
|
export declare function getSessionTodos(sessionID: string): SessionTodo[];
|
|
26
28
|
export declare function setSessionTodos(sessionID: string, todos: unknown): SessionTodo[];
|
|
27
29
|
export declare function clearSessionTodos(sessionID: string): void;
|