opera-devtools-mcp 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/build/src/ToolHandler.js +9 -2
  3. package/build/src/bin/chrome-devtools.js +30 -97
  4. package/build/src/bin/opera-browser-cli.js +102 -0
  5. package/build/src/bin/opera-devtools-mcp.js +20 -1
  6. package/build/src/browser.js +18 -9
  7. package/build/src/daemon/client.js +46 -40
  8. package/build/src/daemon/daemon.js +62 -39
  9. package/build/src/opera/branding.js +4 -2
  10. package/build/src/opera/browserActivity.js +62 -0
  11. package/build/src/opera/browserCleanup.js +123 -0
  12. package/build/src/opera/browserErrors.js +66 -0
  13. package/build/src/opera/browserFlags.js +184 -38
  14. package/build/src/opera/browserTarget.js +513 -0
  15. package/build/src/opera/cdpErrors.js +391 -0
  16. package/build/src/opera/cliCommands.js +378 -0
  17. package/build/src/opera/cliOutput.js +284 -0
  18. package/build/src/opera/compactSnapshot.js +525 -0
  19. package/build/src/opera/config.js +166 -0
  20. package/build/src/opera/daemonLifecycle.js +257 -0
  21. package/build/src/opera/daemonLog.js +103 -0
  22. package/build/src/opera/daemonPidFile.js +83 -0
  23. package/build/src/opera/daemonShutdown.js +66 -0
  24. package/build/src/opera/daemonSocket.js +87 -0
  25. package/build/src/opera/daemonStreaming.js +130 -0
  26. package/build/src/opera/daemonToolCall.js +26 -0
  27. package/build/src/opera/detect.js +114 -0
  28. package/build/src/opera/doctor.js +317 -0
  29. package/build/src/opera/envConfig.js +229 -0
  30. package/build/src/opera/launcherNotice.js +116 -0
  31. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  32. package/build/src/opera/logs.js +133 -0
  33. package/build/src/opera/mcpServerSupervisor.js +128 -0
  34. package/build/src/opera/migrationShared.js +164 -0
  35. package/build/src/opera/operaPages.js +56 -0
  36. package/build/src/opera/pageIdRouting.js +35 -0
  37. package/build/src/opera/pageRecovery.js +53 -0
  38. package/build/src/opera/profile.js +270 -0
  39. package/build/src/opera/refArgs.js +36 -0
  40. package/build/src/opera/serviceWorkerRetry.js +46 -4
  41. package/build/src/opera/setup.js +290 -0
  42. package/build/src/opera/skills/SKILL.md +160 -0
  43. package/build/src/opera/streamingTools.js +73 -0
  44. package/build/src/opera/suggestions.js +67 -0
  45. package/build/src/opera/toolHandlerHooks.js +25 -1
  46. package/build/src/opera/tools/opera.js +107 -38
  47. package/build/src/opera/urlResolver.js +69 -0
  48. package/build/src/opera/webStorageWarning.js +92 -0
  49. package/build/src/third_party/devtools-formatter-worker.js +1 -0
  50. package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
  51. package/build/src/third_party/index.js +2 -1
  52. package/build/src/utils/url.js +6 -0
  53. package/build/src/version.js +1 -1
  54. package/package.json +12 -10
  55. package/build/src/bin/opera-devtools.js +0 -10
@@ -0,0 +1,257 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Daemon lifecycle supervision, owned by Opera.
9
+ *
10
+ * The upstream daemon is spawned `detached: true` and `unref()`ed, so the CLI
11
+ * is not its parent and nothing recovers the process tree when it goes away
12
+ * badly. Two holes follow from that, and this module closes both without
13
+ * touching the daemon's own supervision of its children:
14
+ *
15
+ * 1. **Liveness is pid-file-only.** `isDaemonRunning` reads the pid file, so a
16
+ * daemon whose pid file was removed (or whose socket was unlinked by a
17
+ * racing starter) is invisible: the next `start` forks a second daemon and
18
+ * the first keeps running as an orphan holding the session's real socket.
19
+ * `ensureCleanStart` probes the socket itself — the one signal that survives
20
+ * a missing pid file — and stops any daemon it finds there.
21
+ *
22
+ * 2. **A dead daemon leaves its process group behind.** SIGKILL (what the OOM
23
+ * killer sends) runs no handler, so the MCP server and the browser stay up
24
+ * re-parented to init. `ensureCleanStart` kills the whole process group of a
25
+ * dead pid, which is what reaps them: the daemon is a session leader
26
+ * (`detached: true` calls `setsid()`), so every child it spawned shares its
27
+ * group id — including group members whose leader has already exited.
28
+ *
29
+ * Nothing here deletes the pid or socket files. File creation belongs to the
30
+ * daemon's own `O_EXCL` claim (`daemon.ts`); a client that unlinked them would
31
+ * reopen the race this module exists to close.
32
+ */
33
+ import fs from 'node:fs';
34
+ import net from 'node:net';
35
+ import path from 'node:path';
36
+ import process from 'node:process';
37
+ import { setTimeout as sleep } from 'node:timers/promises';
38
+ import { getDaemonPid, getRuntimeHome, getSocketPath, IS_WINDOWS, } from '../daemon/utils.js';
39
+ import { PipeTransport } from '../third_party/index.js';
40
+ import { logger, puppeteerLogger } from '../utils/logger.js';
41
+ /** How long a socket probe waits to connect before concluding nothing is there. */
42
+ const SOCKET_CONNECT_TIMEOUT_MS = 1_000;
43
+ /**
44
+ * How long it then waits for the reply, once something accepted the connection.
45
+ * Longer than the connect wait on purpose: a daemon that connected is alive, and
46
+ * a busy one — a long tool call, a GC pause, a stalled disk — answers late rather
47
+ * than never. Declaring it dead on a 1s reply window forks a second daemon whose
48
+ * `O_EXCL` claim then fails, which reaches the user as a spurious startup error.
49
+ */
50
+ const SOCKET_REPLY_TIMEOUT_MS = 5_000;
51
+ /** How long to wait for a daemon to exit after asking it to stop. */
52
+ const STOP_WAIT_TIMEOUT_MS = 5_000;
53
+ const STOP_WAIT_POLL_MS = 50;
54
+ /** Where a dying daemon explains itself to the next CLI invocation. */
55
+ const EXIT_REASON_FILE = 'daemon-error';
56
+ /**
57
+ * `process.kill(pid, 0)` with the semantics the callers want: `EPERM` means the
58
+ * pid exists but belongs to another user, which still counts as alive.
59
+ */
60
+ function isProcessAlive(pid) {
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ }
65
+ catch (error) {
66
+ return error.code === 'EPERM';
67
+ }
68
+ }
69
+ /**
70
+ * Get rid of a daemon that is known to be dead or unresponsive. POSIX: SIGKILL
71
+ * its whole process group, which is what reaps the MCP server, the browser and
72
+ * Chrome's helpers along with it (the daemon is a session leader, and the group
73
+ * outlives its leader as long as one member is alive).
74
+ *
75
+ * `ESRCH` (the group is already gone) is the expected outcome once the daemon
76
+ * exited cleanly, so it is not logged as a failure.
77
+ */
78
+ function killStaleDaemon(pid) {
79
+ if (IS_WINDOWS) {
80
+ // No process groups to signal, so the daemon itself is the only handle
81
+ // available here. What it spawned is not reachable from this call.
82
+ try {
83
+ process.kill(pid, 'SIGKILL');
84
+ }
85
+ catch {
86
+ // already gone
87
+ }
88
+ return;
89
+ }
90
+ try {
91
+ process.kill(-pid, 'SIGKILL');
92
+ logger?.(`Killed the process group of daemon ${pid}`);
93
+ }
94
+ catch (error) {
95
+ const code = error.code;
96
+ if (code !== 'ESRCH') {
97
+ logger?.(`Could not kill the process group of daemon ${pid}:`, error);
98
+ }
99
+ }
100
+ }
101
+ async function waitForProcessExit(pid, timeoutMs) {
102
+ const deadline = Date.now() + timeoutMs;
103
+ while (Date.now() < deadline) {
104
+ if (!isProcessAlive(pid)) {
105
+ return true;
106
+ }
107
+ await sleep(STOP_WAIT_POLL_MS);
108
+ }
109
+ return !isProcessAlive(pid);
110
+ }
111
+ /** The pid out of a raw `status` reply, or null if the reply is not one. */
112
+ function readPidFromStatus(rawReply) {
113
+ try {
114
+ const response = JSON.parse(rawReply);
115
+ if (!response.success || typeof response.result !== 'string') {
116
+ return null;
117
+ }
118
+ const status = JSON.parse(response.result);
119
+ return typeof status.pid === 'number' ? status.pid : null;
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ /**
126
+ * Open the session's socket and send one framed command, resolving with the
127
+ * daemon's raw reply. `null` means the caller should act as if the session is
128
+ * empty: the socket file is missing, nobody accepts the connection, or the
129
+ * connection broke before a reply arrived.
130
+ *
131
+ * A connection that is accepted and then goes quiet is logged separately. It
132
+ * means a daemon exists and is too busy (or too wedged) to answer, which is a
133
+ * different thing to debug than no daemon at all — but the same thing to do
134
+ * about, because nothing here can stop a daemon that will not talk.
135
+ *
136
+ * This bypasses `sendCommand`, whose pid-file guard is the very check that
137
+ * cannot see the daemon we are looking for.
138
+ */
139
+ async function probeSocket(sessionId, command) {
140
+ const socketPath = getSocketPath(sessionId);
141
+ const { promise, resolve } = Promise.withResolvers();
142
+ const socket = net.createConnection({ path: socketPath });
143
+ let settled = false;
144
+ let timer;
145
+ const settle = (reply) => {
146
+ if (settled) {
147
+ return;
148
+ }
149
+ settled = true;
150
+ clearTimeout(timer);
151
+ socket.destroy();
152
+ resolve(reply);
153
+ };
154
+ const armTimer = (timeoutMs, waitingFor) => {
155
+ clearTimeout(timer);
156
+ timer = setTimeout(() => {
157
+ logger?.(`Daemon probe on ${socketPath} timed out after ${timeoutMs}ms ${waitingFor}`);
158
+ settle(null);
159
+ }, timeoutMs);
160
+ };
161
+ armTimer(SOCKET_CONNECT_TIMEOUT_MS, 'waiting for a connection');
162
+ socket.on('error', (error) => {
163
+ // ECONNREFUSED, ENOENT or a mid-reply reset: nothing to talk to.
164
+ logger?.(`Daemon probe on ${socketPath} failed: ${error.code ?? error.message}`);
165
+ settle(null);
166
+ });
167
+ socket.on('connect', () => {
168
+ armTimer(SOCKET_REPLY_TIMEOUT_MS, `waiting for a reply to ${command.method}`);
169
+ const transport = new PipeTransport(socket, socket, puppeteerLogger);
170
+ transport.onmessage = (message) => settle(message);
171
+ transport.send(JSON.stringify(command));
172
+ });
173
+ return await promise;
174
+ }
175
+ /**
176
+ * Make the session safe to start a daemon in.
177
+ *
178
+ * Returns `true` when a live daemon already serves the session — the caller
179
+ * should simply wait for it to become ready — and `false` when the caller
180
+ * should fork one. Any daemon that the pid file does not mention, and any
181
+ * process group left behind by a daemon that died without cleaning up, is gone
182
+ * by the time this resolves.
183
+ */
184
+ export async function ensureCleanStart(sessionId) {
185
+ const pid = getDaemonPid(sessionId);
186
+ if (pid !== null) {
187
+ if (isProcessAlive(pid)) {
188
+ return true;
189
+ }
190
+ // The pid file outlived its daemon. Everything the daemon spawned — MCP
191
+ // server, browser, Chrome helpers — shares its process group, and the group
192
+ // still exists as long as one member does, so this single kill reaps the
193
+ // tree that a SIGKILLed daemon left behind.
194
+ logger?.(`Pid file names the dead daemon ${pid}; reaping its process group`);
195
+ killStaleDaemon(pid);
196
+ }
197
+ const reply = await probeSocket(sessionId, { method: 'status' });
198
+ const hiddenPid = reply === null ? null : readPidFromStatus(reply);
199
+ if (hiddenPid !== null) {
200
+ logger?.(`Found daemon ${hiddenPid}, which the pid file does not mention; stopping it`);
201
+ await probeSocket(sessionId, { method: 'stop' });
202
+ if (!(await waitForProcessExit(hiddenPid, STOP_WAIT_TIMEOUT_MS))) {
203
+ // It took the request and did not go. Leaving it is precisely the
204
+ // invisible orphan this probe exists to remove - the caller is about to
205
+ // fork a daemon that will unlink its socket - so stop asking.
206
+ logger?.(`Daemon ${hiddenPid} ignored stop; killing its process group`);
207
+ killStaleDaemon(hiddenPid);
208
+ await waitForProcessExit(hiddenPid, STOP_WAIT_TIMEOUT_MS);
209
+ }
210
+ }
211
+ // Nothing owns the session now. Drop a stale exit reason so a later failure
212
+ // is not reported with a previous daemon's explanation. This is the only
213
+ // place the reason is cleared: `readExitReason` deliberately leaves it in
214
+ // place, because the CLI's readiness loop reads it on every retry and reports
215
+ // its *last* error — a read that consumed the file would hand the user a
216
+ // "Daemon is not running." with no explanation.
217
+ try {
218
+ fs.rmSync(exitReasonPath(sessionId), { force: true });
219
+ }
220
+ catch {
221
+ // best-effort
222
+ }
223
+ return false;
224
+ }
225
+ /** The file a daemon leaves behind to explain its own exit. */
226
+ function exitReasonPath(sessionId) {
227
+ return path.join(getRuntimeHome(sessionId), EXIT_REASON_FILE);
228
+ }
229
+ /**
230
+ * Record why a daemon is going away, for the next CLI invocation to surface.
231
+ * Best-effort: the daemon is on its way out, and a reason file that cannot be
232
+ * written must not be the thing that keeps it alive.
233
+ */
234
+ export function writeExitReason(sessionId, message) {
235
+ try {
236
+ fs.writeFileSync(exitReasonPath(sessionId), message);
237
+ }
238
+ catch {
239
+ // best-effort
240
+ }
241
+ }
242
+ /**
243
+ * The daemon's exit reason, if it left one. Left in place on purpose: a readiness
244
+ * loop reads this on every retry and reports its last error, so the file is only
245
+ * cleared where the session is known clean — `ensureCleanStart`, before a new
246
+ * daemon is forked. Any caller that reads the reason without that having run
247
+ * will keep seeing it; that is the invariant, not a leak.
248
+ */
249
+ export function readExitReason(sessionId) {
250
+ try {
251
+ return fs.readFileSync(exitReasonPath(sessionId), 'utf-8').trim() || null;
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
257
+ //# sourceMappingURL=daemonLifecycle.js.map
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import { getRuntimeHome } from '../daemon/utils.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { readExitReason } from './daemonLifecycle.js';
13
+ /**
14
+ * The daemon's own stdout/stderr capture point, inside the session's runtime
15
+ * home.
16
+ */
17
+ export function getDaemonLogPath(sessionId) {
18
+ return path.join(getRuntimeHome(sessionId), 'daemon.log');
19
+ }
20
+ /**
21
+ * The log of the daemon before the current one. The log is rotated rather than
22
+ * truncated — the CLI points at it when a daemon was killed — so this is where a
23
+ * killed daemon's output lives once the session has started a new one.
24
+ */
25
+ export function getPreviousDaemonLogPath(sessionId) {
26
+ return `${getDaemonLogPath(sessionId)}.prev`;
27
+ }
28
+ /**
29
+ * Open the daemon's log for the spawn, creating the runtime directory the daemon
30
+ * would otherwise create itself.
31
+ *
32
+ * The directory is chmod-ed after creation rather than relying on `mkdirSync`'s
33
+ * mode, which is masked by the umask and does nothing at all to a directory that
34
+ * already exists. The daemon refuses to run on a group/world-writable runtime
35
+ * directory, so the mode has to hold whether the CLI or the daemon created it.
36
+ *
37
+ * Nothing here is allowed to stop the daemon from starting: a log is a
38
+ * diagnostic, and a runtime dir this process cannot write (another user's, a
39
+ * read-only mount) degrades to `'ignore'` stdio with a warning instead of
40
+ * killing the spawn. `O_NOFOLLOW` so a planted symlink is never written
41
+ * through — the pid file gets the same guarantee.
42
+ */
43
+ export function openDaemonLog(sessionId) {
44
+ try {
45
+ const logPath = getDaemonLogPath(sessionId);
46
+ const dir = path.dirname(logPath);
47
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ if (process.platform !== 'win32') {
49
+ fs.chmodSync(dir, 0o700);
50
+ }
51
+ // Rotate instead of truncating: this file is where a killed daemon's output
52
+ // lives, and the CLI points at it when a daemon left no reason behind.
53
+ // Keeping one previous generation bounds the growth without throwing away
54
+ // the only evidence of the previous failure.
55
+ try {
56
+ fs.renameSync(logPath, getPreviousDaemonLogPath(sessionId));
57
+ }
58
+ catch {
59
+ // No previous log — the common case.
60
+ }
61
+ return fs.openSync(logPath, fs.constants.O_WRONLY |
62
+ fs.constants.O_CREAT |
63
+ fs.constants.O_APPEND |
64
+ fs.constants.O_NOFOLLOW, 0o600);
65
+ }
66
+ catch (error) {
67
+ logger?.('Could not open the daemon log; the daemon will run without one:', error);
68
+ return 'ignore';
69
+ }
70
+ }
71
+ /**
72
+ * Why the daemon is gone, for a command that was already in flight.
73
+ *
74
+ * A daemon that ran its own teardown leaves a reason; one that was killed
75
+ * cannot, and that difference is the whole point of the message. `readExitReason`
76
+ * leaves the file in place, so this can be called on every attempt.
77
+ */
78
+ export function daemonExitMessage(sessionId) {
79
+ const reason = readExitReason(sessionId);
80
+ if (reason) {
81
+ return `Daemon exited while running the command: ${reason}`;
82
+ }
83
+ return ('Daemon exited while running the command and left no reason behind, ' +
84
+ 'which means it was killed rather than shut down (SIGKILL, the OOM killer). ' +
85
+ `Its output is at ${getDaemonLogPath(sessionId)}` +
86
+ `, or at ${getPreviousDaemonLogPath(sessionId)} if a daemon has started since.`);
87
+ }
88
+ /**
89
+ * The rejection a spawn failure deserves, for `Promise.race` against the
90
+ * pid-file wait.
91
+ *
92
+ * A spawn that fails outright — a missing script, an unusable execPath — emits
93
+ * `'error'` and never creates the pid file, which would otherwise surface as an
94
+ * unexplained ready-timeout. The log names the reason; this names the log.
95
+ */
96
+ export function nameSpawnFailure(child, sessionId) {
97
+ return new Promise((_, reject) => {
98
+ child.on('error', error => {
99
+ reject(new Error(`Failed to start the daemon: ${error.message}. Its log is at ${getDaemonLogPath(sessionId)}.`, { cause: error }));
100
+ });
101
+ });
102
+ }
103
+ //# sourceMappingURL=daemonLog.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * The daemon's pid file, behind an `O_EXCL` claim.
9
+ *
10
+ * Upstream opens the file with `O_TRUNC`, which makes the pid file a last-writer
11
+ * -wins slot rather than a lock: two clients that both observe "not running"
12
+ * both spawn a daemon, the second one truncates the first one's file and then
13
+ * unlinks its socket, and the session is left with one visible daemon and one
14
+ * deaf one. `O_EXCL` makes exactly one of them the owner; the loser gets
15
+ * `EEXIST` and exits before it can touch anything.
16
+ *
17
+ * A file left by a daemon that died is still reclaimable, but only after
18
+ * re-reading it to confirm it names that same dead pid: a third daemon may have
19
+ * claimed it in between. The re-read *narrows* the window to the gap between
20
+ * that comparison and the unlink — microseconds, and the loser of any race here
21
+ * retries `O_EXCL` and exits, so it does not close it outright.
22
+ */
23
+ import fs from 'node:fs';
24
+ import { constants, openSync } from 'node:fs';
25
+ import process from 'node:process';
26
+ import { getDaemonPid, getPidFilePath } from '../daemon/utils.js';
27
+ const PID_FILE_FLAGS = constants.O_WRONLY |
28
+ constants.O_CREAT |
29
+ constants.O_EXCL |
30
+ constants.O_NOFOLLOW;
31
+ /**
32
+ * Remove the pid file of a daemon that is no longer alive. False when the file
33
+ * belongs to a live daemon, or to one that is mid-write and has not written its
34
+ * pid yet — unlinking either would hide a daemon that is starting up.
35
+ */
36
+ function reclaimStalePidFile(sessionId) {
37
+ const pidFilePath = getPidFilePath(sessionId);
38
+ const existingPid = getDaemonPid(sessionId);
39
+ if (existingPid === null) {
40
+ return false;
41
+ }
42
+ try {
43
+ process.kill(existingPid, 0);
44
+ return false;
45
+ }
46
+ catch {
47
+ // Dead pid: the file is ours to reclaim.
48
+ }
49
+ try {
50
+ if (fs.readFileSync(pidFilePath, 'utf-8').trim() !== existingPid.toString()) {
51
+ return false;
52
+ }
53
+ fs.unlinkSync(pidFilePath);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ /**
61
+ * Open the session's pid file and return the descriptor to write this process's
62
+ * pid into. Throws — the daemon is expected to report and exit — if another
63
+ * daemon owns it.
64
+ *
65
+ * `O_NOFOLLOW` means a symlink is never followed, and the returned descriptor
66
+ * is created `0o600`: readable and writable by its owner alone.
67
+ */
68
+ export function claimPidFile(sessionId) {
69
+ const pidFilePath = getPidFilePath(sessionId);
70
+ try {
71
+ return openSync(pidFilePath, PID_FILE_FLAGS, 0o600);
72
+ }
73
+ catch (err) {
74
+ if (err.code !== 'EEXIST' ||
75
+ !reclaimStalePidFile(sessionId)) {
76
+ throw err;
77
+ }
78
+ }
79
+ // One retry: the window between the unlink and this open is nanoseconds, so a
80
+ // second `EEXIST` means another daemon genuinely won the race.
81
+ return openSync(pidFilePath, PID_FILE_FLAGS, 0o600);
82
+ }
83
+ //# sourceMappingURL=daemonPidFile.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * How the daemon goes away, and what it says about it.
9
+ *
10
+ * Two problems, both about the daemon's last words. Every shutdown path has to
11
+ * record *why* it is leaving — the CLI reads that reason when a command was in
12
+ * flight, and a shutdown that records nothing is reported there as a kill — and
13
+ * the handlers that do it have to exist before anything can go wrong, which is
14
+ * why the daemon installs them next to its other startup work rather than deep
15
+ * in its teardown.
16
+ *
17
+ * A shutdown on request is not a kill: `SIGTERM` from a supervisor, `SIGINT`
18
+ * from a terminal and `SIGHUP` from a closed shell each name themselves, so
19
+ * "left no reason behind" is left to mean what it says — a `SIGKILL` or the OOM
20
+ * killer, neither of which can run a handler at all.
21
+ */
22
+ import process from 'node:process';
23
+ import { writeExitReason } from './daemonLifecycle.js';
24
+ /** Every signal the daemon treats as "stop", and the words it reports them with. */
25
+ const SHUTDOWN_SIGNALS = [
26
+ ['SIGTERM', 'terminated by signal: SIGTERM'],
27
+ ['SIGINT', 'interrupted by signal: SIGINT'],
28
+ ['SIGHUP', 'hung up on signal: SIGHUP'],
29
+ ];
30
+ function describeError(kind, error) {
31
+ return `${kind}: ${error instanceof Error ? error.message : String(error)}`;
32
+ }
33
+ /**
34
+ * Install the daemon's signal and uncaught-error handlers.
35
+ *
36
+ * The stack goes to stderr rather than `logger` because stderr *is* the daemon
37
+ * log (stdio is redirected to it at spawn), and a `logger` stream would be
38
+ * dropped by the `process.exit` the teardown ends in.
39
+ */
40
+ export function installShutdownHandlers(handlers) {
41
+ for (const [signal, reason] of SHUTDOWN_SIGNALS) {
42
+ process.on(signal, () => {
43
+ void handlers.onSignal(reason);
44
+ });
45
+ }
46
+ process.on('uncaughtException', error => {
47
+ console.error('[MCP Daemon] Uncaught exception:', error);
48
+ void handlers.onException(describeError('uncaught exception', error));
49
+ });
50
+ process.on('unhandledRejection', error => {
51
+ console.error('[MCP Daemon] Unhandled rejection:', error);
52
+ void handlers.onException(describeError('unhandled rejection', error));
53
+ });
54
+ }
55
+ /**
56
+ * Record why a daemon is going away, for the next CLI invocation to surface.
57
+ * Best-effort: the daemon is on its way out, and a reason file that cannot be
58
+ * written must not be the thing that keeps it alive. A shutdown with no reason
59
+ * to give writes nothing.
60
+ */
61
+ export function recordShutdownReason(sessionId, reason) {
62
+ if (reason) {
63
+ writeExitReason(sessionId, reason);
64
+ }
65
+ }
66
+ //# sourceMappingURL=daemonShutdown.js.map
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * The daemon's socket edges: the frame it is answering (`dispatchSocketMessage`,
9
+ * which routes a streaming request through `answerSocketMessage`), and the
10
+ * failure it reports when it cannot start listening at all.
11
+ *
12
+ * Both exist because the daemon's own error handling is a trapdoor. A frame that
13
+ * is not JSON rejects the transport callback, and the daemon's
14
+ * `unhandledRejection` handler runs `cleanup(1)`: one malformed byte from
15
+ * anything that can open the socket takes the whole session down. And a startup
16
+ * failure that runs `cleanup()` unlinks the socket file — which, when the
17
+ * failure is a lost bind race, belongs to the daemon that won. The winner is
18
+ * left alive and deaf, which is the orphan this whole area exists to prevent.
19
+ */
20
+ import { randomUUID } from 'node:crypto';
21
+ import process from 'node:process';
22
+ import { writeExitReason } from './daemonLifecycle.js';
23
+ import { withLogSink } from './daemonStreaming.js';
24
+ /**
25
+ * Parse one framed socket message and hand it to `handle`, answering with an
26
+ * error reply when it is not JSON — or when `handle` itself throws. The reply is
27
+ * a reply, not an exception: the caller sends it and closes the connection, and
28
+ * nothing on this path is allowed to reach the daemon's `unhandledRejection`
29
+ * handler, whose answer to any stray rejection is to tear the session down.
30
+ */
31
+ export async function answerSocketMessage(raw, handle) {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ return { success: false, error: 'malformed message' };
38
+ }
39
+ try {
40
+ return await handle(parsed);
41
+ }
42
+ catch (error) {
43
+ return {
44
+ success: false,
45
+ error: error instanceof Error ? error.message : String(error),
46
+ };
47
+ }
48
+ }
49
+ /**
50
+ * Answer one framed socket message, streaming requests included.
51
+ *
52
+ * A streaming request is the `invoke_tool` variant that opted in, and it gets a
53
+ * token of its own: the chunks it produces are written to this connection as
54
+ * they arrive, and the same final response frame follows. Two connections may
55
+ * stream at once, so the token is what keeps their chunks apart — see
56
+ * `opera/daemonStreaming.ts`. Every other message goes straight to `handle`.
57
+ */
58
+ export async function dispatchSocketMessage(raw, transport, handle) {
59
+ return answerSocketMessage(raw, message => {
60
+ if (message.method === 'invoke_tool' && message.stream === true) {
61
+ const streamToken = randomUUID();
62
+ return withLogSink(streamToken, chunk => {
63
+ transport.send(JSON.stringify({ log: chunk }));
64
+ }, () => handle(message, streamToken));
65
+ }
66
+ return handle(message);
67
+ });
68
+ }
69
+ /**
70
+ * Report why the daemon could not start, and get out of the way.
71
+ *
72
+ * A daemon that lost the bind race must not run the caller's `teardown`: it
73
+ * would unlink the socket file the winner is listening on and leave a live,
74
+ * unreachable daemon behind. It exits bare instead, leaving a reason for the
75
+ * next CLI invocation to surface. A failure after a successful bind is ours to
76
+ * clean up, socket and all — and it records why, like every other shutdown.
77
+ */
78
+ export async function reportStartupFailure(error, options) {
79
+ const detail = error instanceof Error ? error.message : String(error);
80
+ if (options.socketBound) {
81
+ await options.teardown(`startup failed: ${detail}`);
82
+ return;
83
+ }
84
+ writeExitReason(options.sessionId, `socket bind failed: ${detail}`);
85
+ process.exit(1);
86
+ }
87
+ //# sourceMappingURL=daemonSocket.js.map