@aiscene/shared 8.0.3 → 8.0.4

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 (99) hide show
  1. package/dist/es/agent-tools/agent-behavior-init-args.mjs +44 -0
  2. package/dist/es/agent-tools/base-tools.mjs +163 -0
  3. package/dist/es/agent-tools/chrome-path.mjs +50 -0
  4. package/dist/es/agent-tools/cli-report-session.mjs +78 -0
  5. package/dist/es/agent-tools/error-formatter.mjs +106 -0
  6. package/dist/es/agent-tools/index.mjs +9 -0
  7. package/dist/es/agent-tools/init-arg-utils.mjs +38 -0
  8. package/dist/es/agent-tools/observation-artifact.mjs +5 -0
  9. package/dist/es/agent-tools/observation-record.mjs +248 -0
  10. package/dist/es/agent-tools/tool-defaults.mjs +54 -0
  11. package/dist/es/agent-tools/tool-generator.mjs +529 -0
  12. package/dist/es/agent-tools/types.mjs +3 -0
  13. package/dist/es/agent-tools/user-prompt.mjs +66 -0
  14. package/dist/es/cli/interrupt.mjs +116 -0
  15. package/dist/es/cli/record-command.mjs +130 -0
  16. package/dist/es/cli/screenshot-file.mjs +24 -0
  17. package/dist/es/cli/verbose-ai-act.mjs +230 -0
  18. package/dist/es/cli/verbose-screenshot.mjs +134 -0
  19. package/dist/es/cli/verbose.mjs +404 -0
  20. package/dist/es/env/parse-model-config.mjs +1 -1
  21. package/dist/es/env/types.mjs +3 -1
  22. package/dist/es/img/info.mjs +45 -1
  23. package/dist/es/img/photon-loader.mjs +4 -0
  24. package/dist/es/recorder.mjs +249 -0
  25. package/dist/lib/agent-tools/agent-behavior-init-args.js +87 -0
  26. package/dist/lib/agent-tools/base-tools.js +197 -0
  27. package/dist/lib/agent-tools/chrome-path.js +87 -0
  28. package/dist/lib/agent-tools/cli-report-session.js +121 -0
  29. package/dist/lib/agent-tools/error-formatter.js +149 -0
  30. package/dist/lib/agent-tools/index.js +114 -0
  31. package/dist/lib/agent-tools/init-arg-utils.js +78 -0
  32. package/dist/lib/agent-tools/observation-artifact.js +42 -0
  33. package/dist/lib/agent-tools/observation-record.js +297 -0
  34. package/dist/lib/agent-tools/tool-defaults.js +97 -0
  35. package/dist/lib/agent-tools/tool-generator.js +569 -0
  36. package/dist/lib/agent-tools/types.js +40 -0
  37. package/dist/lib/agent-tools/user-prompt.js +103 -0
  38. package/dist/lib/cli/interrupt.js +156 -0
  39. package/dist/lib/cli/record-command.js +164 -0
  40. package/dist/lib/cli/screenshot-file.js +58 -0
  41. package/dist/lib/cli/verbose-ai-act.js +273 -0
  42. package/dist/lib/cli/verbose-screenshot.js +177 -0
  43. package/dist/lib/cli/verbose.js +465 -0
  44. package/dist/lib/env/parse-model-config.js +1 -1
  45. package/dist/lib/env/types.js +5 -0
  46. package/dist/lib/img/info.js +48 -1
  47. package/dist/lib/img/photon-loader.js +38 -0
  48. package/dist/lib/recorder.js +307 -0
  49. package/dist/types/agent-tools/agent-behavior-init-args.d.ts +17 -0
  50. package/dist/types/agent-tools/base-tools.d.ts +158 -0
  51. package/dist/types/agent-tools/chrome-path.d.ts +2 -0
  52. package/dist/types/agent-tools/cli-report-session.d.ts +12 -0
  53. package/dist/types/agent-tools/error-formatter.d.ts +30 -0
  54. package/dist/types/agent-tools/index.d.ts +9 -0
  55. package/dist/types/agent-tools/init-arg-utils.d.ts +13 -0
  56. package/dist/types/agent-tools/observation-artifact.d.ts +10 -0
  57. package/dist/types/agent-tools/observation-record.d.ts +38 -0
  58. package/dist/types/agent-tools/tool-defaults.d.ts +63 -0
  59. package/dist/types/agent-tools/tool-generator.d.ts +13 -0
  60. package/dist/types/agent-tools/types.d.ts +213 -0
  61. package/dist/types/agent-tools/user-prompt.d.ts +13 -0
  62. package/dist/types/cli/interrupt.d.ts +49 -0
  63. package/dist/types/cli/record-command.d.ts +3 -0
  64. package/dist/types/cli/screenshot-file.d.ts +10 -0
  65. package/dist/types/cli/verbose-ai-act.d.ts +44 -0
  66. package/dist/types/cli/verbose-screenshot.d.ts +10 -0
  67. package/dist/types/cli/verbose.d.ts +40 -0
  68. package/dist/types/env/types.d.ts +4 -3
  69. package/dist/types/img/info.d.ts +2 -0
  70. package/dist/types/img/photon-loader.d.ts +2 -0
  71. package/dist/types/mcp/types.d.ts +1 -0
  72. package/dist/types/recorder.d.ts +113 -0
  73. package/package.json +1 -1
  74. package/src/agent-tools/agent-behavior-init-args.ts +109 -0
  75. package/src/agent-tools/base-tools.ts +399 -0
  76. package/src/agent-tools/chrome-path.ts +74 -0
  77. package/src/agent-tools/cli-report-session.ts +130 -0
  78. package/src/agent-tools/error-formatter.ts +177 -0
  79. package/src/agent-tools/index.ts +9 -0
  80. package/src/agent-tools/init-arg-utils.ts +105 -0
  81. package/src/agent-tools/observation-artifact.ts +29 -0
  82. package/src/agent-tools/observation-record.ts +331 -0
  83. package/src/agent-tools/tool-defaults.ts +119 -0
  84. package/src/agent-tools/tool-generator.ts +866 -0
  85. package/src/agent-tools/types.ts +250 -0
  86. package/src/agent-tools/user-prompt.ts +102 -0
  87. package/src/cli/interrupt.ts +207 -0
  88. package/src/cli/record-command.ts +177 -0
  89. package/src/cli/screenshot-file.ts +61 -0
  90. package/src/cli/verbose-ai-act.ts +387 -0
  91. package/src/cli/verbose-screenshot.ts +269 -0
  92. package/src/cli/verbose.ts +753 -0
  93. package/src/env/types.ts +2 -0
  94. package/src/img/index.ts +12 -0
  95. package/src/img/info.ts +61 -0
  96. package/src/img/photon-loader.ts +5 -0
  97. package/src/img/transform.ts +261 -2
  98. package/src/mcp/types.ts +2 -0
  99. package/src/recorder.ts +625 -0
@@ -0,0 +1,250 @@
1
+ import type { z } from 'zod';
2
+ import type { ToolDefaults } from './tool-defaults';
3
+
4
+ // Avoid circular dependency: don't import from @aiscene/core
5
+ // Instead, use generic types that will be provided by implementation
6
+
7
+ /**
8
+ * Default timeout constants for app loading verification
9
+ */
10
+ export const defaultAppLoadingTimeoutMs = 10000;
11
+ export const defaultAppLoadingCheckIntervalMs = 2000;
12
+
13
+ /**
14
+ * Content item types for tool results.
15
+ */
16
+ export type ToolResultContent =
17
+ | { type: 'text'; text: string }
18
+ | { type: 'image'; data: string; mimeType: string }
19
+ | { type: 'audio'; data: string; mimeType: string }
20
+ | {
21
+ type: 'resource';
22
+ resource:
23
+ | { text: string; uri: string; mimeType?: string }
24
+ | { uri: string; blob: string; mimeType?: string };
25
+ };
26
+
27
+ /**
28
+ * Result type for tool execution.
29
+ */
30
+ export interface ToolResult {
31
+ [x: string]: unknown;
32
+ content: ToolResultContent[];
33
+ isError?: boolean;
34
+ _meta?: Record<string, unknown>;
35
+ }
36
+
37
+ /**
38
+ * Tool handler function type
39
+ * Takes parsed arguments and returns a tool result
40
+ */
41
+ export type ToolHandler<T = Record<string, unknown>> = (
42
+ args: T,
43
+ ) => Promise<ToolResult>;
44
+
45
+ /**
46
+ * Tool schema type using Zod
47
+ */
48
+ export type ToolSchema = Record<string, z.ZodTypeAny>;
49
+
50
+ export interface ToolCliOption {
51
+ preferredName?: string;
52
+ aliases?: string[];
53
+ hidden?: boolean;
54
+ }
55
+
56
+ export interface ToolCliMetadata {
57
+ options?: Record<string, ToolCliOption>;
58
+ /** Schema keys populated from leading positional CLI arguments. */
59
+ positionals?: string[];
60
+ }
61
+
62
+ /**
63
+ * Tool definition for Midscene CLI and Skill surfaces.
64
+ */
65
+ export interface ToolDefinition<T = Record<string, unknown>> {
66
+ name: string;
67
+ description: string;
68
+ schema: ToolSchema;
69
+ handler: ToolHandler<T>;
70
+ cli?: ToolCliMetadata;
71
+ }
72
+
73
+ export type Tool = ToolDefinition;
74
+
75
+ /**
76
+ * Action space item definition
77
+ * Note: Intentionally no index signature to maintain compatibility with DeviceAction
78
+ */
79
+ export interface ActionSpaceItem {
80
+ name: string;
81
+ description?: string;
82
+ args?: Record<string, unknown>;
83
+ paramSchema?: z.ZodTypeAny;
84
+ }
85
+
86
+ /**
87
+ * Structural shape compatible with @aiscene/core `TUserPrompt`.
88
+ * Declared locally to avoid a circular dep on `@aiscene/core` from `@aiscene/shared`.
89
+ *
90
+ * Consumed by the `assert` and `act` tools in `generateCommonTools`, both of
91
+ * which forward reference images to core (`aiAssert` / `aiAct`). `aiWaitFor`
92
+ * stays string-only at the CLI surface because the tools generator does not
93
+ * yet expose a multimodal entry point for it.
94
+ */
95
+ export type UserPromptLike =
96
+ | string
97
+ | {
98
+ prompt: string;
99
+ images?: Array<{ name: string; url: string }>;
100
+ convertHttpImage2Base64?: boolean;
101
+ };
102
+
103
+ export interface RecordToReportScreenshot {
104
+ /**
105
+ * PNG/JPEG data URI, or raw PNG base64 body.
106
+ */
107
+ base64: string;
108
+ description?: string;
109
+ }
110
+
111
+ export interface RecordToReportOptions {
112
+ content?: string;
113
+ /**
114
+ * @deprecated Use `screenshots: [{ base64 }]` instead.
115
+ */
116
+ screenshotBase64?: string;
117
+ screenshots?: RecordToReportScreenshot[];
118
+ }
119
+
120
+ /** Generic progress-bus envelope as seen by tool consumers (all untrusted). */
121
+ export interface BaseAgentProgressEvent {
122
+ scope?: unknown;
123
+ phase?: unknown;
124
+ sequence?: unknown;
125
+ data?: unknown;
126
+ }
127
+
128
+ /** A single frame in a portable UI observation record. */
129
+ export interface UIObservationFrame {
130
+ /** Path to the captured image. Serialized manifests use relative paths. */
131
+ path: string;
132
+ /** MIME type of the captured image file. */
133
+ mimeType: 'image/png' | 'image/jpeg';
134
+ /** Capture timestamp in milliseconds. */
135
+ capturedAt: number;
136
+ }
137
+
138
+ /**
139
+ * Serializable observation window that can be persisted and loaded later.
140
+ * Frames are ordered from earliest to latest; the final frame represents the
141
+ * UI state at the end of the observation window.
142
+ */
143
+ export interface UIObservationRecord {
144
+ type: 'midscene_ui_observation';
145
+ version: 1;
146
+ /** Time when observation sampling started. */
147
+ startedAt: number;
148
+ /** Time when the final representative frame was captured. */
149
+ endedAt: number;
150
+ frames: UIObservationFrame[];
151
+ shotSize: {
152
+ width: number;
153
+ height: number;
154
+ };
155
+ shrunkShotToLogicalRatio: number;
156
+ }
157
+
158
+ /** Fixed observation window consumed by shared CLI surfaces. */
159
+ export interface BaseUIObservation {
160
+ readonly frameCount: number;
161
+ readonly startedAt: number;
162
+ readonly endedAt: number;
163
+ aiAssert(
164
+ assertion: UserPromptLike,
165
+ message?: string,
166
+ options?: Record<string, unknown>,
167
+ ): Promise<unknown>;
168
+ /** Release any resources owned by this observation. */
169
+ dispose?(): Promise<void>;
170
+ }
171
+
172
+ /** Minimal UI observation lifecycle required by shared tool surfaces. */
173
+ export interface BaseUIObserver {
174
+ readonly bufferedFrameCount: number;
175
+ /** Stop sampling and return the fixed observed window. */
176
+ stop(): Promise<BaseUIObservation>;
177
+ /** Release temporary backing files if observation does not complete. */
178
+ dispose?(): Promise<void>;
179
+ }
180
+
181
+ /** Options for {@link BaseAgent.startObserving}. */
182
+ export interface BaseUIObserverOptions {
183
+ /** Sampling interval in milliseconds. Defaults to 1000; minimum 200. */
184
+ intervalMs?: number;
185
+ /** Maximum number of buffered frames. Defaults to 30; minimum 2. */
186
+ maxFrames?: number;
187
+ /** Auto-stop timeout in milliseconds. Defaults to 300000; 0 disables it. */
188
+ watchdogMs?: number;
189
+ }
190
+
191
+ /**
192
+ * Base agent interface
193
+ * Represents a platform-specific agent (Android, iOS, Web)
194
+ * Note: Return types use `unknown` for compatibility with platform-specific implementations
195
+ */
196
+ export interface BaseAgent {
197
+ getActionSpace(): Promise<ActionSpaceItem[]>;
198
+ destroy?(): Promise<void>;
199
+ reportFile?: string | null;
200
+ page?: {
201
+ screenshotBase64(): Promise<string>;
202
+ };
203
+ addDumpUpdateListener?(
204
+ listener: (dump: string, executionDump?: unknown) => void,
205
+ ): () => void;
206
+ addProgressListener?(
207
+ listener: (event: BaseAgentProgressEvent) => void,
208
+ ): () => void;
209
+ recordToReport?(
210
+ title?: string,
211
+ opt?: RecordToReportOptions,
212
+ ): Promise<void>;
213
+ callActionInActionSpace?(
214
+ actionName: string,
215
+ params?: unknown,
216
+ ): Promise<unknown>;
217
+ aiAction?(
218
+ description: UserPromptLike,
219
+ params?: Record<string, unknown>,
220
+ ): Promise<unknown>;
221
+ aiWaitFor?(
222
+ assertion: string,
223
+ options: Record<string, unknown>,
224
+ ): Promise<unknown>;
225
+ aiAssert?(
226
+ assertion: UserPromptLike,
227
+ msg?: string,
228
+ options?: Record<string, unknown>,
229
+ ): Promise<unknown>;
230
+ /** Start a UI observation window and capture its baseline frame. */
231
+ startObserving?(options?: BaseUIObserverOptions): Promise<BaseUIObserver>;
232
+ }
233
+
234
+ /**
235
+ * Base device interface for temporary device instances
236
+ */
237
+ export interface BaseDevice {
238
+ actionSpace(): ActionSpaceItem[];
239
+ destroy?(): Promise<void>;
240
+ }
241
+
242
+ /**
243
+ * Interface for platform-specific tools manager.
244
+ */
245
+ export interface IMidsceneTools {
246
+ initTools(): Promise<void>;
247
+ destroy?(): Promise<void>;
248
+ getCliToolDefinitions?(): ToolDefinition[];
249
+ setToolDefaults?(toolDefaults: ToolDefaults): void;
250
+ }
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+ import type { UserPromptLike } from './types';
3
+
4
+ type PromptReferenceImage = { name: string; url: string };
5
+
6
+ function normalizeStringList(raw: unknown, fieldName: string): string[] {
7
+ if (raw === undefined || raw === null) return [];
8
+ if (typeof raw === 'string') {
9
+ const trimmed = raw.trim();
10
+ return trimmed ? [trimmed] : [];
11
+ }
12
+ if (Array.isArray(raw)) {
13
+ return raw.map((item, index) => {
14
+ if (typeof item !== 'string') {
15
+ throw new Error(`${fieldName}[${index}]: expected a string.`);
16
+ }
17
+ return item.trim();
18
+ });
19
+ }
20
+ throw new Error(
21
+ `${fieldName}: expected a string or string array, got ${typeof raw}.`,
22
+ );
23
+ }
24
+
25
+ function composeImages(input: {
26
+ image?: unknown;
27
+ imageName?: unknown;
28
+ }): PromptReferenceImage[] {
29
+ const urls = normalizeStringList(input.image, 'image');
30
+ const names = normalizeStringList(input.imageName, 'imageName');
31
+
32
+ if (urls.length !== names.length) {
33
+ throw new Error(
34
+ `image/imageName: expected the same number of --image and --image-name values, got ${urls.length} image(s) and ${names.length} image name(s).`,
35
+ );
36
+ }
37
+
38
+ return urls.map((url, index) => ({ name: names[index], url }));
39
+ }
40
+
41
+ function coerceBoolean(value: unknown): boolean | undefined {
42
+ if (value === undefined || value === null) return undefined;
43
+ if (typeof value === 'boolean') return value;
44
+ if (typeof value === 'string') {
45
+ const trimmed = value.trim();
46
+ if (!trimmed) return undefined;
47
+ const v = trimmed.toLowerCase();
48
+ if (v === 'true' || v === '1') return true;
49
+ if (v === 'false' || v === '0') return false;
50
+ throw new Error(
51
+ `convertHttpImage2Base64: expected "true", "false", "1", or "0"; got ${JSON.stringify(value)}.`,
52
+ );
53
+ }
54
+ throw new Error(
55
+ `convertHttpImage2Base64: expected a boolean, got ${typeof value}.`,
56
+ );
57
+ }
58
+
59
+ export function composeUserPrompt(input: {
60
+ prompt: string;
61
+ image?: unknown;
62
+ imageName?: unknown;
63
+ convertHttpImage2Base64?: unknown;
64
+ }): UserPromptLike {
65
+ const images = composeImages({
66
+ image: input.image,
67
+ imageName: input.imageName,
68
+ });
69
+ const convertFlag = coerceBoolean(input.convertHttpImage2Base64);
70
+
71
+ if (images.length === 0 && convertFlag === undefined) {
72
+ return input.prompt;
73
+ }
74
+
75
+ const payload: Exclude<UserPromptLike, string> = { prompt: input.prompt };
76
+ if (images.length > 0) {
77
+ payload.images = images;
78
+ }
79
+ if (convertFlag !== undefined) {
80
+ payload.convertHttpImage2Base64 = convertFlag;
81
+ }
82
+ return payload;
83
+ }
84
+
85
+ export const promptInputExtraSchema = {
86
+ image: z
87
+ .union([z.string(), z.array(z.string())])
88
+ .optional()
89
+ .describe('Reference image URL/path. Repeat --image for multiple images.'),
90
+ imageName: z
91
+ .union([z.string(), z.array(z.string())])
92
+ .optional()
93
+ .describe(
94
+ 'Reference image name. Repeat --image-name; must align with --image order.',
95
+ ),
96
+ convertHttpImage2Base64: z
97
+ .union([z.boolean(), z.string()])
98
+ .optional()
99
+ .describe(
100
+ 'If true, convert http(s) image URLs to base64 before sending to the model.',
101
+ ),
102
+ };
@@ -0,0 +1,207 @@
1
+ export type CliInterruptReason = 'sigint' | 'sigterm' | 'sighup' | 'watchdog';
2
+
3
+ type CliInterruptSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP';
4
+
5
+ export interface CliInterruptSource {
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;
32
+ }
33
+
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
+ }
70
+
71
+ function registerCliInterruptWaiter(source: CliInterruptSource): () => void {
72
+ const key = source as object;
73
+ activeInterruptWaiters.set(key, (activeInterruptWaiters.get(key) ?? 0) + 1);
74
+
75
+ let active = true;
76
+ return () => {
77
+ if (!active) return;
78
+ active = false;
79
+
80
+ const remaining = (activeInterruptWaiters.get(key) ?? 1) - 1;
81
+ if (remaining > 0) {
82
+ activeInterruptWaiters.set(key, remaining);
83
+ } else {
84
+ activeInterruptWaiters.delete(key);
85
+ }
86
+ };
87
+ }
88
+
89
+ /** Whether a foreground CLI command is currently waiting for this source. */
90
+ export function hasActiveCliInterruptWaiter(
91
+ source: CliInterruptSource = process,
92
+ ): boolean {
93
+ return (activeInterruptWaiters.get(source as object) ?? 0) > 0;
94
+ }
95
+
96
+ /**
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.
108
+ */
109
+ export function createCliInterruptWaiter(
110
+ watchdogMs: number,
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);
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;
130
+
131
+ const result = new Promise<CliInterruptReason>((resolve, reject) => {
132
+ resolveResult = resolve;
133
+ rejectResult = reject;
134
+ });
135
+
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');
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);
155
+ try {
156
+ disposeInput();
157
+ } finally {
158
+ if (timer) {
159
+ clearTimeout(timer);
160
+ timer = undefined;
161
+ }
162
+ unregisterWaiter();
163
+ }
164
+ }
165
+
166
+ const onTerminalCtrlC = () => {
167
+ if (!finished) {
168
+ finish('sigint');
169
+ return;
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);
183
+ if (watchdogMs > 0) {
184
+ timer = setTimeout(() => finish('watchdog'), watchdogMs);
185
+ }
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);
207
+ }