mtech-ai-local-runner 0.1.47 → 0.1.49

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.
@@ -0,0 +1,239 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SESSION_TTL_MS, WAKE_PERIOD_MS, sessionKey } from "./store.js";
3
+ import { LOWER_UUID } from "./types.js";
4
+ /*
5
+ * Routing, seen and wake rules for AI-first ask delivery (Work Item e1c08b33, spec sections 4 and 5).
6
+ * Every function here works on the in-memory AskState and does no I/O, so the rules are tested directly.
7
+ * The AI's own actions never count as seen: only hook events the runner observes move a delivery forward.
8
+ */
9
+ /** A session is woken, or a delivery pinned to it, only after a human prompt within the last 30 minutes. */
10
+ export const WAKE_WINDOW_MS = 30 * 60 * 1000;
11
+ /** At most 6 wakes per session per hour (Claude asyncRewake exits and Codex Stop continuations). */
12
+ export const WAKE_LIMIT = 6;
13
+ /** At most 5 deliveries are spelled out in one hook output; the rest are summarized. */
14
+ export const MAX_ITEMS_PER_OUTPUT = 5;
15
+ export function hashSessionId(sessionId) {
16
+ return createHash("sha256").update(sessionId, "utf8").digest("hex");
17
+ }
18
+ /** Live from its first event until SessionEnd, or until 12 hours pass without an event. */
19
+ export function isLive(session, now) {
20
+ return session.endedAt === null && now - session.lastEventAt <= SESSION_TTL_MS;
21
+ }
22
+ export function humanPromptWithin(session, now) {
23
+ return session.lastPromptAt !== null && now - session.lastPromptAt <= WAKE_WINDOW_MS;
24
+ }
25
+ export function turnInProgress(session) {
26
+ return session.lastPromptAt !== null && (session.lastTurnEndAt === null || session.lastPromptAt > session.lastTurnEndAt);
27
+ }
28
+ export function wakesLeft(session, now) {
29
+ return WAKE_LIMIT - session.wakes.filter(at => now - at < WAKE_PERIOD_MS).length;
30
+ }
31
+ /** Spec section 5: a live session the delivery names, else the live session with the most recent human prompt. */
32
+ export function chooseSession(item, state, now) {
33
+ const live = Object.entries(state.sessions).filter(([, session]) => isLive(session, now));
34
+ const targets = new Set(item.targetSessionHashes);
35
+ const named = live.filter(([, session]) => targets.has(hashSessionId(session.sessionId)));
36
+ const pool = named.length > 0 ? named : live;
37
+ pool.sort(([keyA, a], [keyB, b]) => (b.lastPromptAt ?? -1) - (a.lastPromptAt ?? -1) || b.lastEventAt - a.lastEventAt || keyA.localeCompare(keyB));
38
+ return pool[0]?.[0] ?? null;
39
+ }
40
+ /**
41
+ * Where a delivery that is not shown yet goes now:
42
+ * - no live session, or the chosen one had no human prompt in 30 minutes: a notification, then the next
43
+ * SessionStart or prompt in any session;
44
+ * - the chosen session is in a turn: pinned there, shown at its turn end or next prompt;
45
+ * - an idle Claude session whose Stop watcher waits, under its hourly limit: woken now;
46
+ * - otherwise (an idle Codex session, no watcher, or the limit reached): a notification.
47
+ */
48
+ export function routeItem(item, state, now, watching) {
49
+ const key = chooseSession(item, state, now);
50
+ const session = key === null ? undefined : state.sessions[key];
51
+ if (key === null || session === undefined || !humanPromptWithin(session, now))
52
+ return { action: "notify" };
53
+ if (turnInProgress(session))
54
+ return { action: "pin", sessionKey: key };
55
+ if (session.app === "claude" && watching.has(key) && wakesLeft(session, now) > 0)
56
+ return { action: "wake", sessionKey: key };
57
+ return { action: "notify" };
58
+ }
59
+ /** At most 5 spelled out, oldest first; the rest are summarized by one "and N more" line. */
60
+ export function selectForOutput(items) {
61
+ const sorted = [...items].sort((a, b) => a.createdAt - b.createdAt || a.receivedAt - b.receivedAt || a.deliveryId.localeCompare(b.deliveryId));
62
+ return { items: sorted.slice(0, MAX_ITEMS_PER_OUTPUT), more: sorted.slice(MAX_ITEMS_PER_OUTPUT) };
63
+ }
64
+ function everyItem(output) {
65
+ return [...output.items, ...output.more];
66
+ }
67
+ /** Registers or refreshes the session an event came from. Any event but SessionEnd makes it live again. */
68
+ function touch(state, context) {
69
+ const key = sessionKey(context.app, context.sessionId);
70
+ let session = state.sessions[key];
71
+ if (session === undefined) {
72
+ session = { app: context.app, sessionId: context.sessionId, cwd: context.cwd, startedAt: context.now, lastEventAt: context.now,
73
+ lastPromptAt: null, lastTurnEndAt: null, endedAt: null, wakes: [] };
74
+ state.sessions[key] = session;
75
+ }
76
+ session.lastEventAt = context.now;
77
+ session.endedAt = null;
78
+ if (context.cwd !== "")
79
+ session.cwd = context.cwd;
80
+ return { key, session };
81
+ }
82
+ function markShown(item, key, app, via, now) {
83
+ item.phase = "shown";
84
+ item.sessionKey = key;
85
+ item.shownVia = via;
86
+ item.shownAt = now;
87
+ if (item.firstShownVia === null) {
88
+ item.firstShownVia = via;
89
+ item.outbox.push({ event: "shown", via, app });
90
+ }
91
+ }
92
+ function markSeen(item, app, now) {
93
+ item.phase = "seen";
94
+ item.seenAt = now;
95
+ item.outbox.push({ event: "seen", via: item.shownVia ?? "prompt", app });
96
+ }
97
+ function itemsWhere(state, predicate) {
98
+ return Object.values(state.items).filter(predicate);
99
+ }
100
+ /**
101
+ * What SessionStart and UserPromptSubmit show in the session `key`: every waiting item and what is pinned to that session.
102
+ * The one rule for both hooks. A pinned item always names a session, which the store enforces on load (normalizeAskState).
103
+ */
104
+ function dueFor(state, key) {
105
+ return itemsWhere(state, item => item.phase === "waiting" || (item.phase === "pinned" && item.sessionKey === key));
106
+ }
107
+ /** Stores new deliveries and queues their received report. A repeated offer queues received again, so a lost report is resent. */
108
+ export function addDeliveries(state, deliveries, now) {
109
+ const added = [];
110
+ const repeated = [];
111
+ for (const delivery of deliveries) {
112
+ const known = state.items[delivery.deliveryId];
113
+ if (known !== undefined) {
114
+ if (!known.outbox.some(report => report.event === "received"))
115
+ known.outbox.unshift({ event: "received" });
116
+ repeated.push(known);
117
+ continue;
118
+ }
119
+ const item = { ...delivery, targetSessionHashes: [...delivery.targetSessionHashes], receivedAt: now, phase: "waiting",
120
+ sessionKey: null, shownVia: null, shownAt: null, firstShownVia: null, notifiedAt: null, seenAt: null, outbox: [{ event: "received" }] };
121
+ state.items[delivery.deliveryId] = item;
122
+ added.push(item);
123
+ }
124
+ return { added, repeated };
125
+ }
126
+ /** Drops deliveries the Gateway closed (answered, decided, expired or fallen back). Returns the ids it dropped. */
127
+ export function closeDeliveries(state, deliveryIds) {
128
+ const closed = [];
129
+ for (const id of deliveryIds) {
130
+ if (typeof id === "string" && LOWER_UUID.test(id) && state.items[id] !== undefined) {
131
+ delete state.items[id];
132
+ closed.push(id);
133
+ }
134
+ }
135
+ return closed;
136
+ }
137
+ /** SessionStart: register the session and show what waits for any session (seen at its next prompt). */
138
+ export function sessionStarted(state, context) {
139
+ const { key } = touch(state, context);
140
+ const output = selectForOutput(dueFor(state, key));
141
+ for (const item of everyItem(output))
142
+ markShown(item, key, context.app, "session_start", context.now);
143
+ return output;
144
+ }
145
+ /** UserPromptSubmit: earlier items shown in this session become seen; waiting items are shown and seen at once. */
146
+ export function promptSubmitted(state, context) {
147
+ const { key, session } = touch(state, context);
148
+ session.lastPromptAt = context.now;
149
+ for (const item of itemsWhere(state, item => item.phase === "shown" && item.sessionKey === key))
150
+ markSeen(item, context.app, context.now);
151
+ const output = selectForOutput(dueFor(state, key));
152
+ for (const item of everyItem(output)) {
153
+ markShown(item, key, context.app, "prompt", context.now);
154
+ markSeen(item, context.app, context.now);
155
+ }
156
+ return output;
157
+ }
158
+ /**
159
+ * Stop: what is pinned to this session is shown at its turn end, unless the turn already continued because of a
160
+ * Stop hook or the hourly wake limit is reached. Then it waits for any session and gets a notification instead.
161
+ */
162
+ export function turnEnded(state, context, options) {
163
+ const { key, session } = touch(state, context);
164
+ const pinned = itemsWhere(state, item => item.phase === "pinned" && item.sessionKey === key);
165
+ session.lastTurnEndAt = context.now;
166
+ if (pinned.length === 0)
167
+ return { items: [], more: [] };
168
+ if (options.stopHookActive || wakesLeft(session, context.now) <= 0) {
169
+ for (const item of pinned) {
170
+ item.phase = "waiting";
171
+ item.sessionKey = null;
172
+ }
173
+ return { items: [], more: [] };
174
+ }
175
+ const output = selectForOutput(pinned);
176
+ for (const item of everyItem(output))
177
+ markShown(item, key, context.app, "stop", context.now);
178
+ session.wakes.push(context.now);
179
+ return output;
180
+ }
181
+ /** SessionEnd: the session is no longer live; what was pinned to it waits for another session. */
182
+ export function sessionEnded(state, context) {
183
+ const key = sessionKey(context.app, context.sessionId);
184
+ const session = state.sessions[key];
185
+ if (session !== undefined) {
186
+ session.endedAt = context.now;
187
+ session.lastEventAt = context.now;
188
+ }
189
+ for (const item of itemsWhere(state, item => item.phase === "pinned" && item.sessionKey === key)) {
190
+ item.phase = "waiting";
191
+ item.sessionKey = null;
192
+ }
193
+ }
194
+ /**
195
+ * Re-decides every delivery not shown yet. Pins are applied at once. Wakes and notifications are returned for
196
+ * the hub to carry out; it confirms each with confirmWake or confirmNotified. One notification per delivery.
197
+ * Plan, deliver and confirm must not interleave for the same state: the hub serializes them, and notifiedAt is set only by confirmNotified.
198
+ */
199
+ export function planRoutes(state, now, watching) {
200
+ const wakeGroups = new Map();
201
+ const notify = [];
202
+ for (const item of itemsWhere(state, item => item.phase === "waiting" || item.phase === "pinned")) {
203
+ const decision = routeItem(item, state, now, watching);
204
+ if (decision.action === "notify") {
205
+ item.phase = "waiting";
206
+ item.sessionKey = null;
207
+ if (item.notifiedAt === null)
208
+ notify.push(item);
209
+ continue;
210
+ }
211
+ item.phase = "pinned";
212
+ item.sessionKey = decision.sessionKey;
213
+ if (decision.action === "wake")
214
+ wakeGroups.set(decision.sessionKey, [...wakeGroups.get(decision.sessionKey) ?? [], item]);
215
+ }
216
+ return { wakes: [...wakeGroups].map(([key, items]) => ({ sessionKey: key, output: selectForOutput(items) })), notify };
217
+ }
218
+ /** The hub delivered a wake to the session's watcher: everything in it counts as shown there. */
219
+ export function confirmWake(state, key, output, now) {
220
+ const session = state.sessions[key];
221
+ if (session === undefined)
222
+ return;
223
+ for (const item of everyItem(output)) {
224
+ if (state.items[item.deliveryId] === item && item.phase === "pinned" && item.sessionKey === key)
225
+ markShown(item, key, session.app, "rewake", now);
226
+ }
227
+ session.wakes.push(now);
228
+ }
229
+ /** The notification was attempted. Only one that was shown is reported, as shown via notification. */
230
+ export function confirmNotified(state, deliveryId, now, shown) {
231
+ const item = state.items[deliveryId];
232
+ if (item === undefined)
233
+ return;
234
+ item.notifiedAt = now;
235
+ if (shown && item.firstShownVia === null) {
236
+ item.firstShownVia = "notification";
237
+ item.outbox.push({ event: "shown", via: "notification" });
238
+ }
239
+ }
@@ -0,0 +1,206 @@
1
+ import { AskHub, hubSocketPath } from "./hub.js";
2
+ import { AskInbox } from "./inbox.js";
3
+ import { AskStore } from "./store.js";
4
+ import { refreshShim } from "./shim.js";
5
+ import { installedApps } from "./integrations.js";
6
+ import { createNotifier } from "./notifier.js";
7
+ /*
8
+ * AI-first ask delivery inside the daemon (Work Item e1c08b33). It rides on the existing session-open loop:
9
+ * no new long-poll. The hub runs for as long as that loop runs, deliveries from each wait are stored and
10
+ * reported, and heartbeats advertise ask_inbox only while the hub is up and an integration is installed.
11
+ */
12
+ /** How long a shutdown waits for saves, notifications and a report in flight before it gives up and lets the lock go. */
13
+ const SHUTDOWN_WAIT_MS = 5000;
14
+ /**
15
+ * How long advertisement() waits for a start in progress. The full daemon's first heartbeat can come before the hub is
16
+ * up; without the wait it would leave ask_inbox out, the Gateway would stop sending ask keys, and the stored deliveries
17
+ * would end. Bounded, so a heartbeat, and the job claim behind it, is never held up longer than this.
18
+ */
19
+ export const ADVERTISEMENT_START_WAIT_MS = 2000;
20
+ function errorCode(error) {
21
+ const code = error?.code;
22
+ if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code))
23
+ return code;
24
+ const message = error instanceof Error ? error.message : "";
25
+ return /^[A-Z0-9_]{1,64}$/.test(message) ? message : "UNKNOWN";
26
+ }
27
+ export class AskRuntime {
28
+ deps;
29
+ controller = new AbortController();
30
+ inbox;
31
+ hub;
32
+ started = false;
33
+ /** The one start of this run, kept so advertisement() can wait for it; null until start() is called. */
34
+ starting = null;
35
+ flushing = null;
36
+ flushAgain = false;
37
+ constructor(deps) {
38
+ this.deps = deps;
39
+ this.inbox = new AskInbox({ store: new AskStore(deps.stateRoot), now: deps.now, notify: deps.notify ?? createNotifier({ log: event => this.log(event) }),
40
+ onChange: () => this.flushSoon(), log: event => this.log(event) });
41
+ this.hub = new AskHub({ socketPath: hubSocketPath(deps.stateRoot), handler: this.inbox, log: event => this.log(event) });
42
+ }
43
+ /** True while the hub listens; only then are deliveries taken and ask_inbox advertised. */
44
+ get ready() {
45
+ return this.started;
46
+ }
47
+ /**
48
+ * Never throws, and runs once per daemon run. Without a hub (it cannot listen, or the stored state cannot be read:
49
+ * the next save would overwrite it), deliveries are left to the Gateway, which sends Slack when they are not received.
50
+ */
51
+ start() {
52
+ this.starting ??= this.startOnce();
53
+ return this.starting;
54
+ }
55
+ async startOnce() {
56
+ try {
57
+ await this.inbox.load();
58
+ await this.hub.start();
59
+ this.started = true;
60
+ }
61
+ catch (error) {
62
+ this.log({ event: "ask_hub_unavailable", code: errorCode(error) });
63
+ return;
64
+ }
65
+ // Installed integrations keep running the hook client of the release that runs now.
66
+ try {
67
+ await refreshShim({ stateRoot: this.deps.stateRoot });
68
+ }
69
+ catch (error) {
70
+ this.log({ event: "ask_shim_refresh_failed", code: errorCode(error) });
71
+ }
72
+ }
73
+ /**
74
+ * Shutdown, inside the daemon lock: waiting watchers are released, the hub stops, then saves, notifications and a
75
+ * report in flight are waited for, for at most SHUTDOWN_WAIT_MS. Safe to call again.
76
+ */
77
+ async stop() {
78
+ this.started = false;
79
+ this.controller.abort();
80
+ this.inbox.releaseAll("shutdown");
81
+ try {
82
+ await this.hub.stop();
83
+ }
84
+ catch { /* A socket left behind is removed at the next start. */ }
85
+ const settled = (async () => {
86
+ await this.flushing?.catch(() => undefined);
87
+ await this.inbox.idle();
88
+ })();
89
+ let timer;
90
+ const timedOut = await Promise.race([
91
+ settled.then(() => false, () => false),
92
+ new Promise(resolve => { timer = setTimeout(() => resolve(true), this.deps.shutdownWaitMs ?? SHUTDOWN_WAIT_MS); }),
93
+ ]);
94
+ clearTimeout(timer);
95
+ if (timedOut)
96
+ this.log({ event: "ask_shutdown_timed_out" });
97
+ }
98
+ /**
99
+ * Takes the ask keys of one session-open wait result. Reports go out in the background. Never throws.
100
+ *
101
+ * The Gateway sends ask_deliveries (and closed_delivery_ids) only while asks are on for this runner: the switch is on,
102
+ * the runner's person is a pilot actor and it has stored this runner's ask_inbox. A result without ask_deliveries
103
+ * therefore means asks are off, and every stored delivery ends here with no report: the Gateway's fallback owns them.
104
+ * A result with the key present but empty changes nothing.
105
+ *
106
+ * Resolves false when the result's closures could not be handled (the hub is down, or closing failed), so the push
107
+ * transport does not acknowledge them and the Gateway lists them again (Work Item 25d8ea74).
108
+ */
109
+ async handleWait(result) {
110
+ if (!this.started)
111
+ return false;
112
+ let closuresHandled = true;
113
+ try {
114
+ if (result.askDeliveries === undefined) {
115
+ await this.inbox.endAll();
116
+ return true;
117
+ }
118
+ if (result.closedDeliveryIds !== undefined && result.closedDeliveryIds.length > 0) {
119
+ closuresHandled = false;
120
+ await this.inbox.close(result.closedDeliveryIds);
121
+ closuresHandled = true;
122
+ }
123
+ if (result.askDeliveries.length > 0)
124
+ await this.inbox.receive(result.askDeliveries);
125
+ }
126
+ catch (error) {
127
+ this.log({ event: "ask_delivery_failed", code: errorCode(error) });
128
+ }
129
+ this.flushSoon();
130
+ return closuresHandled;
131
+ }
132
+ /**
133
+ * heartbeat runner.session_open.ask_inbox, or undefined when the hub is down or nothing is installed. While a start is
134
+ * in progress it waits for it, for at most ADVERTISEMENT_START_WAIT_MS; before start() it answers at once.
135
+ */
136
+ async advertisement() {
137
+ if (!this.started && this.starting !== null)
138
+ await this.waitForStart(this.starting);
139
+ if (!this.started)
140
+ return undefined;
141
+ let apps = [];
142
+ try {
143
+ apps = await (this.deps.installedApps ?? (() => installedApps(this.deps.env, this.deps.stateRoot)))();
144
+ }
145
+ catch {
146
+ apps = [];
147
+ }
148
+ return apps.length > 0 ? { version: 1, apps: [...apps] } : undefined;
149
+ }
150
+ /** Resolves when the start settles or the bound passes, whichever comes first. Never rejects: start() never throws. */
151
+ async waitForStart(starting) {
152
+ let timer;
153
+ await Promise.race([
154
+ starting,
155
+ new Promise(resolve => { timer = setTimeout(resolve, this.deps.advertisementStartWaitMs ?? ADVERTISEMENT_START_WAIT_MS); }),
156
+ ]);
157
+ clearTimeout(timer);
158
+ }
159
+ /** One flush at a time; a request that arrives during a flush runs it once more afterwards. */
160
+ flush() {
161
+ if (this.flushing !== null) {
162
+ this.flushAgain = true;
163
+ return this.flushing;
164
+ }
165
+ this.flushing = (async () => {
166
+ try {
167
+ do {
168
+ this.flushAgain = false;
169
+ await this.inbox.flush(this.deps.report, this.controller.signal);
170
+ } while (this.flushAgain && !this.controller.signal.aborted);
171
+ }
172
+ finally {
173
+ this.flushing = null;
174
+ }
175
+ })();
176
+ return this.flushing;
177
+ }
178
+ flushSoon() {
179
+ if (this.started)
180
+ void this.flush().catch(error => { if (!this.controller.signal.aborted)
181
+ this.log({ event: "ask_report_failed", code: errorCode(error) }); });
182
+ }
183
+ log(event) {
184
+ try {
185
+ this.deps.log?.(event);
186
+ }
187
+ catch { /* Logging never stops delivery. */ }
188
+ }
189
+ }
190
+ /** Runs the asks hub for exactly as long as the session-open loop: started before its first wait, stopped after its last. */
191
+ export function withAsks(asks, loop) {
192
+ return {
193
+ async run() {
194
+ await asks.start();
195
+ try {
196
+ await loop.run();
197
+ }
198
+ finally {
199
+ await asks.stop();
200
+ }
201
+ },
202
+ stop() {
203
+ loop.stop();
204
+ },
205
+ };
206
+ }
@@ -0,0 +1,78 @@
1
+ import { access, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /*
5
+ * The stable hook entry point <stateRoot>/bin/gateway-hook (Work Item e1c08b33, spec section 8.3). Claude Code and
6
+ * Codex run only this path, so the hook command never changes when the runner is upgraded and Codex does not ask
7
+ * for trust again. The shim names the Node executable and hook client of the release that wrote it; the daemon
8
+ * rewrites it at start so it follows the running release.
9
+ */
10
+ export const SHIM_FILE = join("bin", "gateway-hook");
11
+ // Paths go into single quotes in the shim and double quotes in hook commands; only characters that stay literal in
12
+ // both are allowed (the same set as the PreToolUse hook command), so no quote, $, backquote or backslash.
13
+ const SAFE_PATH = /^[A-Za-z0-9._/:@+~,=% -]+$/;
14
+ export function shimPath(stateRoot) {
15
+ return join(stateRoot, SHIM_FILE);
16
+ }
17
+ /** The hook client installed next to this module (dist/src/asks/hook-client.js in the package). */
18
+ export function hookClientScript() {
19
+ return fileURLToPath(new URL("./hook-client.js", import.meta.url));
20
+ }
21
+ function safePath(label, path) {
22
+ if (!isAbsolute(path) || !SAFE_PATH.test(path)) {
23
+ // The code is set as well, so the logs that keep only an error's code name it.
24
+ throw Object.assign(new Error(`ASK_HOOK_PATH_UNSAFE: the ${label} path must be absolute and free of quotes and shell characters`), { code: "ASK_HOOK_PATH_UNSAFE" });
25
+ }
26
+ return path;
27
+ }
28
+ /** The command line in every installed hook. It names only the shim, the app and the event. */
29
+ export function hookCommand(stateRoot, app, event) {
30
+ return `"${safePath("shim", shimPath(stateRoot))}" ${app} ${event}`;
31
+ }
32
+ export function renderShim(input) {
33
+ const node = safePath("Node executable", input.nodePath);
34
+ const client = safePath("hook client", input.clientPath);
35
+ const stateRoot = safePath("state root", input.stateRoot);
36
+ return [
37
+ "#!/bin/sh",
38
+ "# Written by mtech-ai-local-runner for AI Gateway asks. The runner rewrites this file; do not edit it.",
39
+ `node='${node}'`,
40
+ `client='${client}'`,
41
+ "# A runner release that is gone must never disturb a session: print nothing and succeed.",
42
+ "[ -x \"$node\" ] && [ -f \"$client\" ] || exit 0",
43
+ `exec "$node" "$client" '--state-root=${stateRoot}' "$@"`,
44
+ "",
45
+ ].join("\n");
46
+ }
47
+ /** Writes the shim atomically with mode 0755 for this release. Returns whether its content changed. */
48
+ export async function writeShim(input) {
49
+ const path = shimPath(input.stateRoot);
50
+ const content = renderShim({ nodePath: input.nodePath ?? process.execPath, clientPath: input.clientPath ?? hookClientScript(), stateRoot: input.stateRoot });
51
+ if (await readFile(path, "utf8").catch(() => null) === content)
52
+ return { path, changed: false };
53
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
54
+ const temporary = `${path}.${process.pid}.tmp`;
55
+ try {
56
+ await writeFile(temporary, content, { mode: 0o755 });
57
+ await chmod(temporary, 0o755);
58
+ await rename(temporary, path);
59
+ }
60
+ catch (error) {
61
+ await rm(temporary, { force: true });
62
+ throw error;
63
+ }
64
+ return { path, changed: true };
65
+ }
66
+ /** Points an existing shim at the running release. It never creates one: installing an integration does. */
67
+ export async function refreshShim(input) {
68
+ try {
69
+ await access(shimPath(input.stateRoot));
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ return (await writeShim(input)).changed;
75
+ }
76
+ export async function removeShim(stateRoot) {
77
+ await rm(shimPath(stateRoot), { force: true });
78
+ }