@midscene/shared 1.12.2-beta-20260828074555.0 → 1.12.2

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.
@@ -1,4 +1,31 @@
1
1
  const activeInterruptWaiters = new WeakMap();
2
+ const noop = ()=>{};
3
+ const sigintExitCode = 130;
4
+ function guardTerminalCtrlC(input, onInterrupt) {
5
+ if (!input?.isTTY || !input.setRawMode) return noop;
6
+ const wasRaw = true === input.isRaw;
7
+ const wasFlowing = true === input.readableFlowing;
8
+ const onData = (chunk)=>{
9
+ const includesCtrlC = 'string' == typeof chunk && chunk.includes('\u0003') || chunk instanceof Uint8Array && chunk.includes(3);
10
+ if (includesCtrlC) onInterrupt();
11
+ };
12
+ const dispose = ()=>{
13
+ input.removeListener('data', onData);
14
+ try {
15
+ if (!wasFlowing) input.pause?.();
16
+ } finally{
17
+ if (!wasRaw) input.setRawMode?.(false);
18
+ }
19
+ };
20
+ input.setRawMode(true);
21
+ try {
22
+ input.on('data', onData);
23
+ } catch (error) {
24
+ dispose();
25
+ throw error;
26
+ }
27
+ return dispose;
28
+ }
2
29
  function registerCliInterruptWaiter(source) {
3
30
  const key = source;
4
31
  activeInterruptWaiters.set(key, (activeInterruptWaiters.get(key) ?? 0) + 1);
@@ -14,28 +41,76 @@ function registerCliInterruptWaiter(source) {
14
41
  function hasActiveCliInterruptWaiter(source = process) {
15
42
  return (activeInterruptWaiters.get(source) ?? 0) > 0;
16
43
  }
17
- function waitForCliInterrupt(watchdogMs, source = process) {
44
+ function createCliInterruptWaiter(watchdogMs, options = {}) {
45
+ const source = options.source ?? process;
46
+ const input = options.input ?? (source === process ? process.stdin : void 0);
47
+ const forceExit = options.forceExit ?? (source === process ? (exitCode)=>{
48
+ process.exit(exitCode);
49
+ } : void 0);
18
50
  const unregisterWaiter = registerCliInterruptWaiter(source);
19
- return new Promise((resolve, reject)=>{
20
- let timer;
21
- let finished = false;
22
- const finish = (reason)=>{
23
- if (finished) return;
24
- finished = true;
25
- source.removeListener('SIGINT', onSigint);
26
- if (timer) clearTimeout(timer);
27
- unregisterWaiter();
28
- resolve(reason);
29
- };
30
- const onSigint = ()=>finish('sigint');
51
+ let timer;
52
+ let finished = false;
53
+ let disposed = false;
54
+ let disposeInput = noop;
55
+ let resolveResult;
56
+ let rejectResult;
57
+ const result = new Promise((resolve, reject)=>{
58
+ resolveResult = resolve;
59
+ rejectResult = reject;
60
+ });
61
+ const finish = (reason)=>{
62
+ if (finished) return;
63
+ finished = true;
64
+ if (timer) {
65
+ clearTimeout(timer);
66
+ timer = void 0;
67
+ }
68
+ resolveResult(reason);
69
+ };
70
+ const onSigint = ()=>finish('sigint');
71
+ const onSigterm = ()=>finish('sigterm');
72
+ const onSighup = ()=>finish('sighup');
73
+ function dispose() {
74
+ if (disposed) return;
75
+ disposed = true;
76
+ source.removeListener('SIGINT', onSigint);
77
+ source.removeListener('SIGTERM', onSigterm);
78
+ source.removeListener('SIGHUP', onSighup);
31
79
  try {
32
- source.once('SIGINT', onSigint);
33
- } catch (error) {
80
+ disposeInput();
81
+ } finally{
82
+ if (timer) {
83
+ clearTimeout(timer);
84
+ timer = void 0;
85
+ }
34
86
  unregisterWaiter();
35
- reject(error);
36
- return;
37
87
  }
88
+ }
89
+ const onTerminalCtrlC = ()=>{
90
+ if (!finished) return void finish('sigint');
91
+ dispose();
92
+ forceExit?.(sigintExitCode);
93
+ };
94
+ try {
95
+ source.on('SIGINT', onSigint);
96
+ source.on('SIGTERM', onSigterm);
97
+ source.on('SIGHUP', onSighup);
98
+ disposeInput = guardTerminalCtrlC(input, onTerminalCtrlC);
38
99
  if (watchdogMs > 0) timer = setTimeout(()=>finish('watchdog'), watchdogMs);
100
+ } catch (error) {
101
+ dispose();
102
+ rejectResult(error);
103
+ }
104
+ return {
105
+ result,
106
+ dispose
107
+ };
108
+ }
109
+ function waitForCliInterrupt(watchdogMs, source = process, input = source === process ? process.stdin : void 0) {
110
+ const waiter = createCliInterruptWaiter(watchdogMs, {
111
+ source,
112
+ input
39
113
  });
114
+ return waiter.result.finally(waiter.dispose);
40
115
  }
41
- export { hasActiveCliInterruptWaiter, waitForCliInterrupt };
116
+ export { createCliInterruptWaiter, hasActiveCliInterruptWaiter, waitForCliInterrupt };
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { getErrorMessage } from "../agent-tools/error-formatter.mjs";
3
3
  import { resolveObservationArtifactAdapter } from "../agent-tools/observation-artifact.mjs";
4
4
  import { writeUIObservationRecord } from "../agent-tools/observation-record.mjs";
5
- import { waitForCliInterrupt } from "./interrupt.mjs";
5
+ import { createCliInterruptWaiter } from "./interrupt.mjs";
6
6
  import { attachCliVerboseDumpListener, emitCliVerboseEvent } from "./verbose.mjs";
7
7
  const recordCliMetadata = {
8
8
  positionals: [
@@ -55,7 +55,7 @@ function createErrorResult(message) {
55
55
  function createRecordCliCommand(getAgent, initArgSchema = {}, initArgCliMetadata) {
56
56
  return {
57
57
  name: 'record',
58
- description: 'Record the page/screen in the foreground until Ctrl+C, then save the ordered frame window for a later assert command.',
58
+ description: 'Record the page/screen in the foreground until Ctrl+C or a termination signal, then save the ordered frame window for a later assert command.',
59
59
  schema: {
60
60
  action: z.literal('start').describe('Start a foreground recording. Press Ctrl+C to finish.'),
61
61
  output: z.string().optional().describe('Path for the JSON observation manifest. Frames are stored in an adjacent <name>.frames directory. Defaults to a generated path under midscene_run/output.'),
@@ -80,6 +80,7 @@ function createRecordCliCommand(getAgent, initArgSchema = {}, initArgCliMetadata
80
80
  toolName: 'record'
81
81
  });
82
82
  let observer;
83
+ let interruptWaiter;
83
84
  try {
84
85
  const watchdogMs = args.watchdogMs ?? 300000;
85
86
  observer = await agent.startObserving({
@@ -87,12 +88,13 @@ function createRecordCliCommand(getAgent, initArgSchema = {}, initArgCliMetadata
87
88
  maxFrames: args.maxFrames,
88
89
  watchdogMs
89
90
  });
91
+ interruptWaiter = createCliInterruptWaiter(watchdogMs);
90
92
  emitCliVerboseEvent({
91
93
  event: 'recording_ready',
92
94
  tool: 'record',
93
95
  watchdogMs
94
96
  });
95
- const stopReason = await waitForCliInterrupt(watchdogMs);
97
+ const stopReason = await interruptWaiter.result;
96
98
  emitCliVerboseEvent({
97
99
  event: 'recording_stopping',
98
100
  tool: 'record',
@@ -110,8 +112,12 @@ function createRecordCliCommand(getAgent, initArgSchema = {}, initArgCliMetadata
110
112
  ]
111
113
  };
112
114
  } finally{
113
- await observer?.dispose?.();
114
- unsubscribeVerbose();
115
+ try {
116
+ await observer?.dispose?.();
117
+ } finally{
118
+ interruptWaiter?.dispose();
119
+ unsubscribeVerbose();
120
+ }
115
121
  }
116
122
  } catch (error) {
117
123
  const errorMessage = getErrorMessage(error);
@@ -305,7 +305,9 @@ function renderCliVerboseEventText(event, context) {
305
305
  case 'recording_ready':
306
306
  return '[Midscene] Recording. Press Ctrl+C to stop and save.';
307
307
  case 'recording_stopping':
308
- return 'watchdog' === event.reason ? '[Midscene] Recording watchdog reached; finalizing and saving.' : '[Midscene] Ctrl+C received; finalizing and saving.';
308
+ if ('watchdog' === event.reason) return '[Midscene] Recording watchdog reached; finalizing and saving.';
309
+ if ('sigterm' === event.reason) return '[Midscene] SIGTERM received; finalizing and saving.';
310
+ return 'sighup' === event.reason ? '[Midscene] SIGHUP received; finalizing and saving.' : '[Midscene] Ctrl+C received; finalizing and saving. Press Ctrl+C again to force exit.';
309
311
  case 'dump_update':
310
312
  {
311
313
  if (isActVerboseEvent(command, tool)) return;
@@ -5,7 +5,7 @@ import { assert } from "../utils.mjs";
5
5
  import { maskConfig, parseJson } from "./helper.mjs";
6
6
  import { initDebugConfig } from "./init-debug.mjs";
7
7
  const MODEL_CONFIG_DOC_URL = 'https://midscenejs.com/model-common-config.html';
8
- const getCurrentVersion = ()=>"1.12.2-beta-20260828074555.0";
8
+ const getCurrentVersion = ()=>"1.12.2";
9
9
  const getInvalidModelFamilyMessage = (modelFamily)=>`Invalid MIDSCENE_MODEL_FAMILY value: ${modelFamily}. Current version v${getCurrentVersion()} accepts the following model families: ${MODEL_FAMILY_VALUES.join(', ')}. You can also visit ${MODEL_CONFIG_DOC_URL} for the latest configuration information.`;
10
10
  const KEYS_MAP = {
11
11
  insight: INSIGHT_MODEL_CONFIG_KEYS,
@@ -24,10 +24,38 @@ var __webpack_require__ = {};
24
24
  var __webpack_exports__ = {};
25
25
  __webpack_require__.r(__webpack_exports__);
26
26
  __webpack_require__.d(__webpack_exports__, {
27
+ createCliInterruptWaiter: ()=>createCliInterruptWaiter,
27
28
  hasActiveCliInterruptWaiter: ()=>hasActiveCliInterruptWaiter,
28
29
  waitForCliInterrupt: ()=>waitForCliInterrupt
29
30
  });
30
31
  const activeInterruptWaiters = new WeakMap();
32
+ const noop = ()=>{};
33
+ const sigintExitCode = 130;
34
+ function guardTerminalCtrlC(input, onInterrupt) {
35
+ if (!input?.isTTY || !input.setRawMode) return noop;
36
+ const wasRaw = true === input.isRaw;
37
+ const wasFlowing = true === input.readableFlowing;
38
+ const onData = (chunk)=>{
39
+ const includesCtrlC = 'string' == typeof chunk && chunk.includes('\u0003') || chunk instanceof Uint8Array && chunk.includes(3);
40
+ if (includesCtrlC) onInterrupt();
41
+ };
42
+ const dispose = ()=>{
43
+ input.removeListener('data', onData);
44
+ try {
45
+ if (!wasFlowing) input.pause?.();
46
+ } finally{
47
+ if (!wasRaw) input.setRawMode?.(false);
48
+ }
49
+ };
50
+ input.setRawMode(true);
51
+ try {
52
+ input.on('data', onData);
53
+ } catch (error) {
54
+ dispose();
55
+ throw error;
56
+ }
57
+ return dispose;
58
+ }
31
59
  function registerCliInterruptWaiter(source) {
32
60
  const key = source;
33
61
  activeInterruptWaiters.set(key, (activeInterruptWaiters.get(key) ?? 0) + 1);
@@ -43,33 +71,83 @@ function registerCliInterruptWaiter(source) {
43
71
  function hasActiveCliInterruptWaiter(source = process) {
44
72
  return (activeInterruptWaiters.get(source) ?? 0) > 0;
45
73
  }
46
- function waitForCliInterrupt(watchdogMs, source = process) {
74
+ function createCliInterruptWaiter(watchdogMs, options = {}) {
75
+ const source = options.source ?? process;
76
+ const input = options.input ?? (source === process ? process.stdin : void 0);
77
+ const forceExit = options.forceExit ?? (source === process ? (exitCode)=>{
78
+ process.exit(exitCode);
79
+ } : void 0);
47
80
  const unregisterWaiter = registerCliInterruptWaiter(source);
48
- return new Promise((resolve, reject)=>{
49
- let timer;
50
- let finished = false;
51
- const finish = (reason)=>{
52
- if (finished) return;
53
- finished = true;
54
- source.removeListener('SIGINT', onSigint);
55
- if (timer) clearTimeout(timer);
56
- unregisterWaiter();
57
- resolve(reason);
58
- };
59
- const onSigint = ()=>finish('sigint');
81
+ let timer;
82
+ let finished = false;
83
+ let disposed = false;
84
+ let disposeInput = noop;
85
+ let resolveResult;
86
+ let rejectResult;
87
+ const result = new Promise((resolve, reject)=>{
88
+ resolveResult = resolve;
89
+ rejectResult = reject;
90
+ });
91
+ const finish = (reason)=>{
92
+ if (finished) return;
93
+ finished = true;
94
+ if (timer) {
95
+ clearTimeout(timer);
96
+ timer = void 0;
97
+ }
98
+ resolveResult(reason);
99
+ };
100
+ const onSigint = ()=>finish('sigint');
101
+ const onSigterm = ()=>finish('sigterm');
102
+ const onSighup = ()=>finish('sighup');
103
+ function dispose() {
104
+ if (disposed) return;
105
+ disposed = true;
106
+ source.removeListener('SIGINT', onSigint);
107
+ source.removeListener('SIGTERM', onSigterm);
108
+ source.removeListener('SIGHUP', onSighup);
60
109
  try {
61
- source.once('SIGINT', onSigint);
62
- } catch (error) {
110
+ disposeInput();
111
+ } finally{
112
+ if (timer) {
113
+ clearTimeout(timer);
114
+ timer = void 0;
115
+ }
63
116
  unregisterWaiter();
64
- reject(error);
65
- return;
66
117
  }
118
+ }
119
+ const onTerminalCtrlC = ()=>{
120
+ if (!finished) return void finish('sigint');
121
+ dispose();
122
+ forceExit?.(sigintExitCode);
123
+ };
124
+ try {
125
+ source.on('SIGINT', onSigint);
126
+ source.on('SIGTERM', onSigterm);
127
+ source.on('SIGHUP', onSighup);
128
+ disposeInput = guardTerminalCtrlC(input, onTerminalCtrlC);
67
129
  if (watchdogMs > 0) timer = setTimeout(()=>finish('watchdog'), watchdogMs);
130
+ } catch (error) {
131
+ dispose();
132
+ rejectResult(error);
133
+ }
134
+ return {
135
+ result,
136
+ dispose
137
+ };
138
+ }
139
+ function waitForCliInterrupt(watchdogMs, source = process, input = source === process ? process.stdin : void 0) {
140
+ const waiter = createCliInterruptWaiter(watchdogMs, {
141
+ source,
142
+ input
68
143
  });
144
+ return waiter.result.finally(waiter.dispose);
69
145
  }
146
+ exports.createCliInterruptWaiter = __webpack_exports__.createCliInterruptWaiter;
70
147
  exports.hasActiveCliInterruptWaiter = __webpack_exports__.hasActiveCliInterruptWaiter;
71
148
  exports.waitForCliInterrupt = __webpack_exports__.waitForCliInterrupt;
72
149
  for(var __rspack_i in __webpack_exports__)if (-1 === [
150
+ "createCliInterruptWaiter",
73
151
  "hasActiveCliInterruptWaiter",
74
152
  "waitForCliInterrupt"
75
153
  ].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
@@ -83,7 +83,7 @@ function createErrorResult(message) {
83
83
  function createRecordCliCommand(getAgent, initArgSchema = {}, initArgCliMetadata) {
84
84
  return {
85
85
  name: 'record',
86
- description: 'Record the page/screen in the foreground until Ctrl+C, then save the ordered frame window for a later assert command.',
86
+ description: 'Record the page/screen in the foreground until Ctrl+C or a termination signal, then save the ordered frame window for a later assert command.',
87
87
  schema: {
88
88
  action: external_zod_namespaceObject.z.literal('start').describe('Start a foreground recording. Press Ctrl+C to finish.'),
89
89
  output: external_zod_namespaceObject.z.string().optional().describe('Path for the JSON observation manifest. Frames are stored in an adjacent <name>.frames directory. Defaults to a generated path under midscene_run/output.'),
@@ -108,6 +108,7 @@ function createRecordCliCommand(getAgent, initArgSchema = {}, initArgCliMetadata
108
108
  toolName: 'record'
109
109
  });
110
110
  let observer;
111
+ let interruptWaiter;
111
112
  try {
112
113
  const watchdogMs = args.watchdogMs ?? 300000;
113
114
  observer = await agent.startObserving({
@@ -115,12 +116,13 @@ function createRecordCliCommand(getAgent, initArgSchema = {}, initArgCliMetadata
115
116
  maxFrames: args.maxFrames,
116
117
  watchdogMs
117
118
  });
119
+ interruptWaiter = (0, external_interrupt_js_namespaceObject.createCliInterruptWaiter)(watchdogMs);
118
120
  (0, external_verbose_js_namespaceObject.emitCliVerboseEvent)({
119
121
  event: 'recording_ready',
120
122
  tool: 'record',
121
123
  watchdogMs
122
124
  });
123
- const stopReason = await (0, external_interrupt_js_namespaceObject.waitForCliInterrupt)(watchdogMs);
125
+ const stopReason = await interruptWaiter.result;
124
126
  (0, external_verbose_js_namespaceObject.emitCliVerboseEvent)({
125
127
  event: 'recording_stopping',
126
128
  tool: 'record',
@@ -138,8 +140,12 @@ function createRecordCliCommand(getAgent, initArgSchema = {}, initArgCliMetadata
138
140
  ]
139
141
  };
140
142
  } finally{
141
- await observer?.dispose?.();
142
- unsubscribeVerbose();
143
+ try {
144
+ await observer?.dispose?.();
145
+ } finally{
146
+ interruptWaiter?.dispose();
147
+ unsubscribeVerbose();
148
+ }
143
149
  }
144
150
  } catch (error) {
145
151
  const errorMessage = (0, error_formatter_js_namespaceObject.getErrorMessage)(error);
@@ -342,7 +342,9 @@ function renderCliVerboseEventText(event, context) {
342
342
  case 'recording_ready':
343
343
  return '[Midscene] Recording. Press Ctrl+C to stop and save.';
344
344
  case 'recording_stopping':
345
- return 'watchdog' === event.reason ? '[Midscene] Recording watchdog reached; finalizing and saving.' : '[Midscene] Ctrl+C received; finalizing and saving.';
345
+ if ('watchdog' === event.reason) return '[Midscene] Recording watchdog reached; finalizing and saving.';
346
+ if ('sigterm' === event.reason) return '[Midscene] SIGTERM received; finalizing and saving.';
347
+ return 'sighup' === event.reason ? '[Midscene] SIGHUP received; finalizing and saving.' : '[Midscene] Ctrl+C received; finalizing and saving. Press Ctrl+C again to force exit.';
346
348
  case 'dump_update':
347
349
  {
348
350
  if (isActVerboseEvent(command, tool)) return;
@@ -37,7 +37,7 @@ const external_utils_js_namespaceObject = require("../utils.js");
37
37
  const external_helper_js_namespaceObject = require("./helper.js");
38
38
  const external_init_debug_js_namespaceObject = require("./init-debug.js");
39
39
  const MODEL_CONFIG_DOC_URL = 'https://midscenejs.com/model-common-config.html';
40
- const getCurrentVersion = ()=>"1.12.2-beta-20260828074555.0";
40
+ const getCurrentVersion = ()=>"1.12.2";
41
41
  const getInvalidModelFamilyMessage = (modelFamily)=>`Invalid MIDSCENE_MODEL_FAMILY value: ${modelFamily}. Current version v${getCurrentVersion()} accepts the following model families: ${external_types_js_namespaceObject.MODEL_FAMILY_VALUES.join(', ')}. You can also visit ${MODEL_CONFIG_DOC_URL} for the latest configuration information.`;
42
42
  const KEYS_MAP = {
43
43
  insight: external_constants_js_namespaceObject.INSIGHT_MODEL_CONFIG_KEYS,
@@ -1,13 +1,49 @@
1
- export type CliInterruptReason = 'sigint' | 'watchdog';
1
+ export type CliInterruptReason = 'sigint' | 'sigterm' | 'sighup' | 'watchdog';
2
+ type CliInterruptSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP';
2
3
  export interface CliInterruptSource {
3
- once(event: 'SIGINT', listener: () => void): unknown;
4
- removeListener(event: 'SIGINT', listener: () => void): unknown;
4
+ on(event: CliInterruptSignal, listener: () => void): unknown;
5
+ removeListener(event: CliInterruptSignal, listener: () => void): unknown;
6
+ }
7
+ export interface CliInterruptInputSource {
8
+ readonly isTTY?: boolean;
9
+ readonly isRaw?: boolean;
10
+ readonly readableFlowing?: boolean | null;
11
+ on(event: 'data', listener: (chunk: unknown) => void): unknown;
12
+ removeListener(event: 'data', listener: (chunk: unknown) => void): unknown;
13
+ setRawMode?(enabled: boolean): unknown;
14
+ pause?(): unknown;
15
+ }
16
+ export interface CliInterruptWaiter {
17
+ /** Resolves on the first stop signal or watchdog timeout. */
18
+ readonly result: Promise<CliInterruptReason>;
19
+ /** Release signal handlers after asynchronous finalization has completed. */
20
+ dispose(): void;
21
+ }
22
+ export interface CliInterruptWaiterOptions {
23
+ source?: CliInterruptSource;
24
+ input?: CliInterruptInputSource;
25
+ /** Called after restoring the terminal when Ctrl+C is pressed again. */
26
+ forceExit?: (exitCode: number) => void;
5
27
  }
6
28
  /** Whether a foreground CLI command is currently waiting for this source. */
7
29
  export declare function hasActiveCliInterruptWaiter(source?: CliInterruptSource): boolean;
8
30
  /**
9
- * Wait until the foreground CLI receives Ctrl+C. A positive watchdog keeps a
10
- * forgotten recording from running forever and uses the same graceful save
11
- * path as an explicit interrupt.
31
+ * Keep graceful-stop handlers installed until the caller has finished saving.
32
+ *
33
+ * Package runners such as pnpm can deliver SIGINT to the foreground child,
34
+ * immediately follow it with SIGTERM, then cause SIGHUP when the runner exits
35
+ * and its pseudo-terminal closes. Resolving on the first signal is not enough:
36
+ * removing any handler at that point lets a subsequent signal kill the child
37
+ * during asynchronous artifact finalization.
38
+ *
39
+ * On a TTY, Ctrl+C is captured as raw input so the package runner itself stays
40
+ * alive until the child has saved and restored the terminal. Signal handlers
41
+ * remain as the graceful-stop path for externally delivered termination.
42
+ */
43
+ export declare function createCliInterruptWaiter(watchdogMs: number, options?: CliInterruptWaiterOptions): CliInterruptWaiter;
44
+ /**
45
+ * Wait for one stop request and release the handlers immediately afterwards.
46
+ * Long-running finalizers should use {@link createCliInterruptWaiter} instead.
12
47
  */
13
- export declare function waitForCliInterrupt(watchdogMs: number, source?: CliInterruptSource): Promise<CliInterruptReason>;
48
+ export declare function waitForCliInterrupt(watchdogMs: number, source?: CliInterruptSource, input?: CliInterruptInputSource | undefined): Promise<CliInterruptReason>;
49
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midscene/shared",
3
- "version": "1.12.2-beta-20260828074555.0",
3
+ "version": "1.12.2",
4
4
  "repository": "https://github.com/web-infra-dev/midscene",
5
5
  "homepage": "https://midscenejs.com/",
6
6
  "types": "./dist/types/index.d.ts",
@@ -1,11 +1,72 @@
1
- export type CliInterruptReason = 'sigint' | 'watchdog';
1
+ export type CliInterruptReason = 'sigint' | 'sigterm' | 'sighup' | 'watchdog';
2
+
3
+ type CliInterruptSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP';
2
4
 
3
5
  export interface CliInterruptSource {
4
- once(event: 'SIGINT', listener: () => void): unknown;
5
- removeListener(event: 'SIGINT', listener: () => void): unknown;
6
+ on(event: CliInterruptSignal, listener: () => void): unknown;
7
+ removeListener(event: CliInterruptSignal, listener: () => void): unknown;
8
+ }
9
+
10
+ export interface CliInterruptInputSource {
11
+ readonly isTTY?: boolean;
12
+ readonly isRaw?: boolean;
13
+ readonly readableFlowing?: boolean | null;
14
+ on(event: 'data', listener: (chunk: unknown) => void): unknown;
15
+ removeListener(event: 'data', listener: (chunk: unknown) => void): unknown;
16
+ setRawMode?(enabled: boolean): unknown;
17
+ pause?(): unknown;
18
+ }
19
+
20
+ export interface CliInterruptWaiter {
21
+ /** Resolves on the first stop signal or watchdog timeout. */
22
+ readonly result: Promise<CliInterruptReason>;
23
+ /** Release signal handlers after asynchronous finalization has completed. */
24
+ dispose(): void;
25
+ }
26
+
27
+ export interface CliInterruptWaiterOptions {
28
+ source?: CliInterruptSource;
29
+ input?: CliInterruptInputSource;
30
+ /** Called after restoring the terminal when Ctrl+C is pressed again. */
31
+ forceExit?: (exitCode: number) => void;
6
32
  }
7
33
 
8
34
  const activeInterruptWaiters = new WeakMap<object, number>();
35
+ const noop = () => {};
36
+ const sigintExitCode = 130;
37
+
38
+ function guardTerminalCtrlC(
39
+ input: CliInterruptInputSource | undefined,
40
+ onInterrupt: () => void,
41
+ ): () => void {
42
+ if (!input?.isTTY || !input.setRawMode) return noop;
43
+
44
+ const wasRaw = input.isRaw === true;
45
+ const wasFlowing = input.readableFlowing === true;
46
+ const onData = (chunk: unknown) => {
47
+ const includesCtrlC =
48
+ (typeof chunk === 'string' && chunk.includes('\u0003')) ||
49
+ (chunk instanceof Uint8Array && chunk.includes(3));
50
+ if (includesCtrlC) onInterrupt();
51
+ };
52
+ const dispose = () => {
53
+ input.removeListener('data', onData);
54
+ try {
55
+ if (!wasFlowing) input.pause?.();
56
+ } finally {
57
+ if (!wasRaw) input.setRawMode?.(false);
58
+ }
59
+ };
60
+
61
+ input.setRawMode(true);
62
+ try {
63
+ input.on('data', onData);
64
+ } catch (error) {
65
+ dispose();
66
+ throw error;
67
+ }
68
+ return dispose;
69
+ }
9
70
 
10
71
  function registerCliInterruptWaiter(source: CliInterruptSource): () => void {
11
72
  const key = source as object;
@@ -33,39 +94,114 @@ export function hasActiveCliInterruptWaiter(
33
94
  }
34
95
 
35
96
  /**
36
- * Wait until the foreground CLI receives Ctrl+C. A positive watchdog keeps a
37
- * forgotten recording from running forever and uses the same graceful save
38
- * path as an explicit interrupt.
97
+ * Keep graceful-stop handlers installed until the caller has finished saving.
98
+ *
99
+ * Package runners such as pnpm can deliver SIGINT to the foreground child,
100
+ * immediately follow it with SIGTERM, then cause SIGHUP when the runner exits
101
+ * and its pseudo-terminal closes. Resolving on the first signal is not enough:
102
+ * removing any handler at that point lets a subsequent signal kill the child
103
+ * during asynchronous artifact finalization.
104
+ *
105
+ * On a TTY, Ctrl+C is captured as raw input so the package runner itself stays
106
+ * alive until the child has saved and restored the terminal. Signal handlers
107
+ * remain as the graceful-stop path for externally delivered termination.
39
108
  */
40
- export function waitForCliInterrupt(
109
+ export function createCliInterruptWaiter(
41
110
  watchdogMs: number,
42
- source: CliInterruptSource = process,
43
- ): Promise<CliInterruptReason> {
111
+ options: CliInterruptWaiterOptions = {},
112
+ ): CliInterruptWaiter {
113
+ const source = options.source ?? process;
114
+ const input =
115
+ options.input ?? (source === process ? process.stdin : undefined);
116
+ const forceExit =
117
+ options.forceExit ??
118
+ (source === process
119
+ ? (exitCode: number) => {
120
+ process.exit(exitCode);
121
+ }
122
+ : undefined);
44
123
  const unregisterWaiter = registerCliInterruptWaiter(source);
124
+ let timer: ReturnType<typeof setTimeout> | undefined;
125
+ let finished = false;
126
+ let disposed = false;
127
+ let disposeInput = noop;
128
+ let resolveResult!: (reason: CliInterruptReason) => void;
129
+ let rejectResult!: (error: unknown) => void;
45
130
 
46
- return new Promise((resolve, reject) => {
47
- let timer: ReturnType<typeof setTimeout> | undefined;
48
- let finished = false;
131
+ const result = new Promise<CliInterruptReason>((resolve, reject) => {
132
+ resolveResult = resolve;
133
+ rejectResult = reject;
134
+ });
49
135
 
50
- const finish = (reason: CliInterruptReason) => {
51
- if (finished) return;
52
- finished = true;
53
- source.removeListener('SIGINT', onSigint);
54
- if (timer) clearTimeout(timer);
55
- unregisterWaiter();
56
- resolve(reason);
57
- };
58
- const onSigint = () => finish('sigint');
136
+ const finish = (reason: CliInterruptReason) => {
137
+ if (finished) return;
138
+ finished = true;
139
+ if (timer) {
140
+ clearTimeout(timer);
141
+ timer = undefined;
142
+ }
143
+ resolveResult(reason);
144
+ };
145
+ const onSigint = () => finish('sigint');
146
+ const onSigterm = () => finish('sigterm');
147
+ const onSighup = () => finish('sighup');
59
148
 
149
+ function dispose() {
150
+ if (disposed) return;
151
+ disposed = true;
152
+ source.removeListener('SIGINT', onSigint);
153
+ source.removeListener('SIGTERM', onSigterm);
154
+ source.removeListener('SIGHUP', onSighup);
60
155
  try {
61
- source.once('SIGINT', onSigint);
62
- } catch (error) {
156
+ disposeInput();
157
+ } finally {
158
+ if (timer) {
159
+ clearTimeout(timer);
160
+ timer = undefined;
161
+ }
63
162
  unregisterWaiter();
64
- reject(error);
163
+ }
164
+ }
165
+
166
+ const onTerminalCtrlC = () => {
167
+ if (!finished) {
168
+ finish('sigint');
65
169
  return;
66
170
  }
171
+
172
+ // The first Ctrl+C protects asynchronous artifact finalization. A second
173
+ // explicit Ctrl+C is the user's escape hatch if device or file I/O hangs.
174
+ dispose();
175
+ forceExit?.(sigintExitCode);
176
+ };
177
+
178
+ try {
179
+ source.on('SIGINT', onSigint);
180
+ source.on('SIGTERM', onSigterm);
181
+ source.on('SIGHUP', onSighup);
182
+ disposeInput = guardTerminalCtrlC(input, onTerminalCtrlC);
67
183
  if (watchdogMs > 0) {
68
184
  timer = setTimeout(() => finish('watchdog'), watchdogMs);
69
185
  }
70
- });
186
+ } catch (error) {
187
+ dispose();
188
+ rejectResult(error);
189
+ }
190
+
191
+ return { result, dispose };
192
+ }
193
+
194
+ /**
195
+ * Wait for one stop request and release the handlers immediately afterwards.
196
+ * Long-running finalizers should use {@link createCliInterruptWaiter} instead.
197
+ */
198
+ export function waitForCliInterrupt(
199
+ watchdogMs: number,
200
+ source: CliInterruptSource = process,
201
+ input: CliInterruptInputSource | undefined = source === process
202
+ ? process.stdin
203
+ : undefined,
204
+ ): Promise<CliInterruptReason> {
205
+ const waiter = createCliInterruptWaiter(watchdogMs, { source, input });
206
+ return waiter.result.finally(waiter.dispose);
71
207
  }
@@ -9,7 +9,7 @@ import type {
9
9
  ToolResult,
10
10
  ToolSchema,
11
11
  } from '../agent-tools/types';
12
- import { waitForCliInterrupt } from './interrupt';
12
+ import { type CliInterruptWaiter, createCliInterruptWaiter } from './interrupt';
13
13
  import { attachCliVerboseDumpListener, emitCliVerboseEvent } from './verbose';
14
14
 
15
15
  const recordCliMetadata: ToolCliMetadata = {
@@ -62,7 +62,7 @@ export function createRecordCliCommand(
62
62
  return {
63
63
  name: 'record',
64
64
  description:
65
- 'Record the page/screen in the foreground until Ctrl+C, then save the ordered frame window for a later assert command.',
65
+ 'Record the page/screen in the foreground until Ctrl+C or a termination signal, then save the ordered frame window for a later assert command.',
66
66
  schema: {
67
67
  action: z
68
68
  .literal('start')
@@ -125,6 +125,7 @@ export function createRecordCliCommand(
125
125
  let observer:
126
126
  | Awaited<ReturnType<NonNullable<BaseAgent['startObserving']>>>
127
127
  | undefined;
128
+ let interruptWaiter: CliInterruptWaiter | undefined;
128
129
  try {
129
130
  const watchdogMs = (args.watchdogMs as number | undefined) ?? 300_000;
130
131
  observer = await agent.startObserving({
@@ -132,12 +133,13 @@ export function createRecordCliCommand(
132
133
  maxFrames: args.maxFrames as number | undefined,
133
134
  watchdogMs,
134
135
  });
136
+ interruptWaiter = createCliInterruptWaiter(watchdogMs);
135
137
  emitCliVerboseEvent({
136
138
  event: 'recording_ready',
137
139
  tool: 'record',
138
140
  watchdogMs,
139
141
  });
140
- const stopReason = await waitForCliInterrupt(watchdogMs);
142
+ const stopReason = await interruptWaiter.result;
141
143
  emitCliVerboseEvent({
142
144
  event: 'recording_stopping',
143
145
  tool: 'record',
@@ -158,8 +160,12 @@ export function createRecordCliCommand(
158
160
  ],
159
161
  };
160
162
  } finally {
161
- await observer?.dispose?.();
162
- unsubscribeVerbose();
163
+ try {
164
+ await observer?.dispose?.();
165
+ } finally {
166
+ interruptWaiter?.dispose();
167
+ unsubscribeVerbose();
168
+ }
163
169
  }
164
170
  } catch (error: unknown) {
165
171
  const errorMessage = getErrorMessage(error);
@@ -587,9 +587,15 @@ function renderCliVerboseEventText(
587
587
  case 'recording_ready':
588
588
  return '[Midscene] Recording. Press Ctrl+C to stop and save.';
589
589
  case 'recording_stopping':
590
- return event.reason === 'watchdog'
591
- ? '[Midscene] Recording watchdog reached; finalizing and saving.'
592
- : '[Midscene] Ctrl+C received; finalizing and saving.';
590
+ if (event.reason === 'watchdog') {
591
+ return '[Midscene] Recording watchdog reached; finalizing and saving.';
592
+ }
593
+ if (event.reason === 'sigterm') {
594
+ return '[Midscene] SIGTERM received; finalizing and saving.';
595
+ }
596
+ return event.reason === 'sighup'
597
+ ? '[Midscene] SIGHUP received; finalizing and saving.'
598
+ : '[Midscene] Ctrl+C received; finalizing and saving. Press Ctrl+C again to force exit.';
593
599
  case 'dump_update': {
594
600
  if (isActVerboseEvent(command, tool)) {
595
601
  return undefined;