@iinm/plain-agent 1.8.4 → 1.8.6

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 (85) hide show
  1. package/bin/plain +1 -1
  2. package/package.json +8 -9
  3. package/sandbox/bin/plain-sandbox +13 -0
  4. package/src/agent.d.ts +52 -0
  5. package/src/agent.mjs +204 -0
  6. package/src/agentLoop.mjs +419 -0
  7. package/src/agentState.mjs +41 -0
  8. package/src/claudeCodePlugin.mjs +164 -0
  9. package/src/cliArgs.mjs +175 -0
  10. package/src/cliBatch.mjs +147 -0
  11. package/src/cliCommands.mjs +283 -0
  12. package/src/cliCompleter.mjs +227 -0
  13. package/src/cliCost.mjs +309 -0
  14. package/src/cliFormatter.mjs +518 -0
  15. package/src/cliInteractive.mjs +533 -0
  16. package/src/cliInterruptTransform.mjs +51 -0
  17. package/src/cliMuteTransform.mjs +26 -0
  18. package/src/cliPasteTransform.mjs +183 -0
  19. package/src/config.d.ts +36 -0
  20. package/src/config.mjs +197 -0
  21. package/src/context/loadAgentRoles.mjs +267 -0
  22. package/src/context/loadPrompts.mjs +303 -0
  23. package/src/context/loadUserMessageContext.mjs +147 -0
  24. package/src/costTracker.mjs +210 -0
  25. package/src/env.mjs +44 -0
  26. package/src/main.mjs +281 -0
  27. package/src/mcpClient.mjs +351 -0
  28. package/src/mcpIntegration.mjs +160 -0
  29. package/src/model.d.ts +109 -0
  30. package/src/modelCaller.mjs +32 -0
  31. package/src/modelDefinition.d.ts +92 -0
  32. package/src/prompt.mjs +138 -0
  33. package/src/providers/anthropic.d.ts +248 -0
  34. package/src/providers/anthropic.mjs +587 -0
  35. package/src/providers/bedrock.d.ts +249 -0
  36. package/src/providers/bedrock.mjs +700 -0
  37. package/src/providers/gemini.d.ts +208 -0
  38. package/src/providers/gemini.mjs +754 -0
  39. package/src/providers/openai.d.ts +281 -0
  40. package/src/providers/openai.mjs +544 -0
  41. package/src/providers/openaiCompatible.d.ts +147 -0
  42. package/src/providers/openaiCompatible.mjs +652 -0
  43. package/src/providers/platform/awsSigV4.mjs +184 -0
  44. package/src/providers/platform/azure.mjs +42 -0
  45. package/src/providers/platform/bedrock.mjs +78 -0
  46. package/src/providers/platform/googleCloud.mjs +34 -0
  47. package/src/subagent.mjs +265 -0
  48. package/src/tmpfile.mjs +27 -0
  49. package/src/tool.d.ts +74 -0
  50. package/src/toolExecutor.mjs +236 -0
  51. package/src/toolInputValidator.mjs +183 -0
  52. package/src/toolUseApprover.mjs +99 -0
  53. package/src/tools/askURL.mjs +209 -0
  54. package/src/tools/askWeb.mjs +208 -0
  55. package/src/tools/compactContext.d.ts +4 -0
  56. package/src/tools/compactContext.mjs +87 -0
  57. package/src/tools/execCommand.d.ts +22 -0
  58. package/src/tools/execCommand.mjs +200 -0
  59. package/src/tools/patchFile.d.ts +4 -0
  60. package/src/tools/patchFile.mjs +133 -0
  61. package/src/tools/switchToMainAgent.d.ts +3 -0
  62. package/src/tools/switchToMainAgent.mjs +43 -0
  63. package/src/tools/switchToSubagent.d.ts +4 -0
  64. package/src/tools/switchToSubagent.mjs +59 -0
  65. package/src/tools/tmuxCommand.d.ts +14 -0
  66. package/src/tools/tmuxCommand.mjs +194 -0
  67. package/src/tools/writeFile.d.ts +4 -0
  68. package/src/tools/writeFile.mjs +56 -0
  69. package/src/usageStore.mjs +167 -0
  70. package/src/utils/evalJSONConfig.mjs +72 -0
  71. package/src/utils/matchValue.d.ts +6 -0
  72. package/src/utils/matchValue.mjs +40 -0
  73. package/src/utils/noThrow.mjs +31 -0
  74. package/src/utils/notify.mjs +29 -0
  75. package/src/utils/parseFileRange.mjs +18 -0
  76. package/src/utils/parseFrontmatter.mjs +19 -0
  77. package/src/utils/readFileRange.mjs +33 -0
  78. package/src/utils/retryOnError.mjs +41 -0
  79. package/src/voiceInput.mjs +61 -0
  80. package/src/voiceInputGemini.mjs +105 -0
  81. package/src/voiceInputOpenAI.mjs +104 -0
  82. package/src/voiceInputSession.mjs +543 -0
  83. package/src/voiceToggleKey.mjs +62 -0
  84. package/dist/main.mjs +0 -473
  85. package/dist/main.mjs.map +0 -7
@@ -0,0 +1,533 @@
1
+ /**
2
+ * @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "./agent"
3
+ * @import { ClaudeCodePlugin } from "./claudeCodePlugin.mjs"
4
+ * @import { VoiceInputConfig, VoiceSession } from "./voiceInput.mjs"
5
+ */
6
+
7
+ import readline from "node:readline";
8
+ import { styleText } from "node:util";
9
+ import { createCommandHandler } from "./cliCommands.mjs";
10
+ import { createCompleter, SLASH_COMMANDS } from "./cliCompleter.mjs";
11
+ import {
12
+ formatCostSummary,
13
+ formatProviderTokenUsage,
14
+ printMessage,
15
+ } from "./cliFormatter.mjs";
16
+ import { createInterruptTransform } from "./cliInterruptTransform.mjs";
17
+ import { createMuteTransform } from "./cliMuteTransform.mjs";
18
+ import { createPasteHandler } from "./cliPasteTransform.mjs";
19
+ import { appendUsageRecord, buildUsageRecord } from "./usageStore.mjs";
20
+ import { notify } from "./utils/notify.mjs";
21
+ import { parseVoiceToggleKey, startVoiceSession } from "./voiceInput.mjs";
22
+
23
+ const HELP_MESSAGE = [
24
+ "Commands:",
25
+ ...SLASH_COMMANDS.map(
26
+ (cmd) => ` ${cmd.name.padEnd(13)} - ${cmd.description}`,
27
+ ),
28
+ "",
29
+ "Multi-line Input Syntax:",
30
+ ' """ - Start/stop multi-line input mode',
31
+ "",
32
+ "File Input Syntax:",
33
+ " !path/to/file - Read content from a file",
34
+ " !path/to/file:N - Read line N from a file",
35
+ " !path/to/file:N-M - Read lines N to M from a file",
36
+ "",
37
+ "References (use within input content):",
38
+ " @path/to/file - Reference content from another file",
39
+ " @path/to/file:N - Reference line N from another file",
40
+ " @path/to/file:N-M - Reference lines N to M from another file",
41
+ "",
42
+ "Image Attachments (use within input content):",
43
+ " @path/to/image.png - Attach an image (png, jpg, jpeg, gif, webp)",
44
+ " @'path/with spaces.png' - Quote paths that include spaces",
45
+ " @path/with\\ spaces.png - Escape spaces with a backslash",
46
+ ]
47
+ .join("\n")
48
+ .trim()
49
+ .replace(/^[^ ].*:/gm, (m) => styleText("bold", m))
50
+ .replace(/^ {2}\/.+?(?= - )/gm, (m) => styleText("cyan", m))
51
+ .replace(/^ {2}.+?(?= - )/gm, (m) => styleText("blue", m));
52
+
53
+ /**
54
+ * @typedef {object} CliOptions
55
+ * @property {UserEventEmitter} userEventEmitter
56
+ * @property {AgentEventEmitter} agentEventEmitter
57
+ * @property {AgentCommands} agentCommands
58
+ * @property {string} sessionId
59
+ * @property {string} modelName
60
+ * @property {Date} startTime
61
+ * @property {{ command: string; args?: string[] } | undefined} notifyCmd
62
+ * @property {boolean} sandbox
63
+ * @property {() => Promise<void>} onStop
64
+ * @property {ClaudeCodePlugin[]} [claudeCodePlugins]
65
+ * @property {VoiceInputConfig} [voiceInput]
66
+ */
67
+
68
+ /**
69
+ * Persist the session's cost summary to the usage log.
70
+ * Failures are logged but never thrown so exit is not blocked.
71
+ *
72
+ * @param {import("./costTracker.mjs").CostSummary} summary
73
+ * @param {{ sessionId: string, modelName: string, startTime: Date }} meta
74
+ */
75
+ async function persistUsage(summary, { sessionId, modelName, startTime }) {
76
+ try {
77
+ const record = buildUsageRecord({
78
+ sessionId,
79
+ mode: "interactive",
80
+ modelName,
81
+ workingDir: process.cwd(),
82
+ costSummary: summary,
83
+ now: startTime,
84
+ });
85
+ if (!record) return;
86
+ await appendUsageRecord(record);
87
+ } catch (err) {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ console.error(
90
+ styleText("yellow", `Warning: failed to record usage: ${message}`),
91
+ );
92
+ }
93
+ }
94
+
95
+ /**
96
+ * @param {CliOptions} options
97
+ */
98
+ export function startInteractiveSession({
99
+ userEventEmitter,
100
+ agentEventEmitter,
101
+ agentCommands,
102
+ sessionId,
103
+ modelName,
104
+ startTime,
105
+ notifyCmd,
106
+ sandbox,
107
+ onStop,
108
+ claudeCodePlugins,
109
+ voiceInput,
110
+ }) {
111
+ /** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string }} */
112
+ const state = {
113
+ turn: true,
114
+ multiLineBuffer: null,
115
+ subagentName: "",
116
+ };
117
+
118
+ /**
119
+ * Active voice input session, or null when not recording.
120
+ * @type {{ session: VoiceSession, startCursor: number, transcriptLength: number } | null}
121
+ */
122
+ let voice = null;
123
+
124
+ // Parse the voice toggle key once at startup so misconfiguration fails
125
+ // loudly instead of silently falling back.
126
+ const voiceToggle = parseVoiceToggleKey(voiceInput?.toggleKey);
127
+
128
+ const getCliPrompt = (subagentName = "", flashMessage = "") =>
129
+ [
130
+ "",
131
+ styleText(
132
+ ["white", "bgGray"],
133
+ [
134
+ ...(subagentName ? [`[${subagentName}]`] : []),
135
+ `session: ${sessionId} | model: ${modelName} | sandbox: ${sandbox ? "on" : "off"}`,
136
+ ].join(" "),
137
+ ),
138
+ ...(flashMessage ? [flashMessage] : []),
139
+ "> ",
140
+ ].join("\n");
141
+
142
+ // Cleanup handler to disable bracketed paste mode on exit
143
+ const cleanup = () => {
144
+ if (process.stdout.isTTY) {
145
+ process.stdout.write("\x1b[?2004l");
146
+ }
147
+ };
148
+
149
+ // Handle exit signals
150
+ let isExiting = false;
151
+ const handleExit = async () => {
152
+ if (isExiting) return;
153
+ isExiting = true;
154
+
155
+ cleanup();
156
+ const summary = agentCommands.getCostSummary();
157
+ console.log();
158
+ console.log(formatCostSummary(summary));
159
+ await persistUsage(summary, { sessionId, modelName, startTime });
160
+ await onStop();
161
+ process.exit(0);
162
+ };
163
+
164
+ // Double-press Ctrl-D exit confirmation
165
+ let lastCtrlDAttempt = 0;
166
+ const EXIT_CONFIRM_TIMEOUT = 1500;
167
+
168
+ /** @type {import("node:readline").Interface} */
169
+ let cli;
170
+
171
+ /**
172
+ * Clear the current readline input line and redraw the prompt.
173
+ * Also aborts multi-line input mode if active.
174
+ */
175
+ const resetInput = () => {
176
+ if (state.multiLineBuffer !== null) {
177
+ state.multiLineBuffer = null;
178
+ cli.setPrompt(currentCliPrompt);
179
+ }
180
+ cli.write(null, { ctrl: true, name: "a" }); // move to line start
181
+ cli.write(null, { ctrl: true, name: "k" }); // delete to line end
182
+ cli.prompt();
183
+ };
184
+
185
+ const stopVoiceSession = async () => {
186
+ if (!voice) return;
187
+ const current = voice;
188
+ voice = null;
189
+ await current.session.stop();
190
+ cli.setPrompt(currentCliPrompt);
191
+ // @ts-expect-error - internal property
192
+ cli._refreshLine?.();
193
+ };
194
+
195
+ const handleVoiceToggle = () => {
196
+ // Ignore while the agent is working.
197
+ if (!state.turn) return;
198
+
199
+ if (voice) {
200
+ stopVoiceSession();
201
+ return;
202
+ }
203
+
204
+ if (!voiceInput) {
205
+ cli.setPrompt(
206
+ getCliPrompt(
207
+ state.subagentName,
208
+ styleText(
209
+ "yellow",
210
+ `Voice input not configured. Set \`voiceInput\` in your config to enable ${voiceToggle.label}.`,
211
+ ),
212
+ ),
213
+ );
214
+ cli.prompt(true);
215
+ return;
216
+ }
217
+
218
+ const startCursor = cli.cursor;
219
+ const session = startVoiceSession({
220
+ config: voiceInput,
221
+ callbacks: {
222
+ onTranscript: (delta) => {
223
+ if (!voice) return;
224
+ const insertAt = voice.startCursor + voice.transcriptLength;
225
+ // Insert delta at the recording's insertion point. User input is
226
+ // swallowed while recording, so the buffer around `insertAt` is
227
+ // stable.
228
+ const before = cli.line.slice(0, insertAt);
229
+ const after = cli.line.slice(insertAt);
230
+ // `line` and `cursor` are declared readonly in the Node typings but
231
+ // are writable at runtime — the existing code already patches
232
+ // `_refreshLine` in the same way.
233
+ const mutableCli = /** @type {{ line: string, cursor: number }} */ (
234
+ /** @type {unknown} */ (cli)
235
+ );
236
+ mutableCli.line = before + delta + after;
237
+ mutableCli.cursor = insertAt + delta.length;
238
+ voice.transcriptLength += delta.length;
239
+ // @ts-expect-error - internal property
240
+ cli._refreshLine?.();
241
+ },
242
+ onError: (err) => {
243
+ voice = null;
244
+ cli.setPrompt(
245
+ getCliPrompt(
246
+ state.subagentName,
247
+ styleText("red", `Voice input error: ${err.message}`),
248
+ ),
249
+ );
250
+ cli.prompt(true);
251
+ },
252
+ onClose: () => {
253
+ if (!voice) return;
254
+ voice = null;
255
+ cli.setPrompt(currentCliPrompt);
256
+ // @ts-expect-error - internal property
257
+ cli._refreshLine?.();
258
+ },
259
+ },
260
+ });
261
+ voice = { session, startCursor, transcriptLength: 0 };
262
+ cli.setPrompt(
263
+ getCliPrompt(
264
+ state.subagentName,
265
+ styleText(["red", "bold"], `● REC (${voiceToggle.label} to stop)`),
266
+ ),
267
+ );
268
+ // @ts-expect-error - internal property
269
+ cli._refreshLine?.();
270
+ };
271
+
272
+ const handleCtrlC = () => {
273
+ // Stop voice recording first if active.
274
+ if (voice) {
275
+ stopVoiceSession();
276
+ return;
277
+ }
278
+
279
+ // Agent turn: pause auto-approve; do not clear input.
280
+ if (!state.turn) {
281
+ agentCommands.pauseAutoApprove();
282
+ console.log(
283
+ styleText(
284
+ "yellow",
285
+ "\n\n⚠️ Ctrl-C: Auto-approve paused. Finishing current tool...\nPress Ctrl-D twice to exit.\n",
286
+ ),
287
+ );
288
+ return;
289
+ }
290
+
291
+ // User turn: clear current input. On empty input, show exit hint.
292
+ const hasInput = cli.line.length > 0 || state.multiLineBuffer !== null;
293
+ if (hasInput) {
294
+ resetInput();
295
+ } else {
296
+ cli.setPrompt(
297
+ getCliPrompt(
298
+ state.subagentName,
299
+ styleText("yellow", "Press Ctrl-D twice to exit"),
300
+ ),
301
+ );
302
+ cli.prompt();
303
+ }
304
+ // Reset Ctrl-D confirmation when Ctrl-C is pressed
305
+ lastCtrlDAttempt = 0;
306
+ };
307
+
308
+ const handleCtrlD = () => {
309
+ // User turn with non-empty input: ignore Ctrl-D entirely.
310
+ if (state.turn && (cli.line.length > 0 || state.multiLineBuffer !== null)) {
311
+ return;
312
+ }
313
+
314
+ const now = Date.now();
315
+ if (now - lastCtrlDAttempt < EXIT_CONFIRM_TIMEOUT) {
316
+ handleExit();
317
+ return;
318
+ }
319
+ lastCtrlDAttempt = now;
320
+ if (state.turn) {
321
+ cli.setPrompt(
322
+ getCliPrompt(
323
+ state.subagentName,
324
+ styleText("yellow", "Press Ctrl-D again to exit."),
325
+ ),
326
+ );
327
+ cli.prompt();
328
+ } else {
329
+ console.log(styleText("yellow", "\n\n⚠️ Press Ctrl-D again to exit.\n"));
330
+ }
331
+ };
332
+
333
+ // Pre-readline pipeline:
334
+ // stdin -> interrupt (Ctrl-C / Ctrl-D) -> mute (voice recording) -> paste (bracketed paste) -> readline
335
+ const interrupt = createInterruptTransform({
336
+ onCtrlC: handleCtrlC,
337
+ onCtrlD: handleCtrlD,
338
+ onVoiceToggle: handleVoiceToggle,
339
+ voiceToggleByte: voiceToggle.byte,
340
+ });
341
+ // While a voice session is recording, swallow all stdin bytes other than
342
+ // Ctrl-C / Ctrl-D / the voice toggle key so transcript insertion stays
343
+ // consistent.
344
+ const mute = createMuteTransform({ isMuted: () => voice !== null });
345
+ const paste = createPasteHandler();
346
+
347
+ process.stdin.pipe(interrupt).pipe(mute).pipe(paste.transform);
348
+
349
+ // Enable bracketed paste mode
350
+ if (process.stdout.isTTY) {
351
+ process.stdout.write("\x1b[?2004h");
352
+ }
353
+
354
+ let currentCliPrompt = getCliPrompt();
355
+ cli = readline.createInterface({
356
+ input: paste.transform,
357
+ output: process.stdout,
358
+ prompt: currentCliPrompt,
359
+ completer: createCompleter(() => cli, claudeCodePlugins),
360
+ });
361
+
362
+ // Disable automatic prompt redraw on resize during agent turn
363
+ // @ts-expect-error - internal property
364
+ const originalRefreshLine = cli._refreshLine?.bind(cli);
365
+ if (originalRefreshLine) {
366
+ // @ts-expect-error - internal property
367
+ cli._refreshLine = (...args) => {
368
+ if (state.turn) {
369
+ originalRefreshLine(...args);
370
+ }
371
+ };
372
+ }
373
+
374
+ readline.emitKeypressEvents(process.stdin);
375
+ if (process.stdin.isTTY) {
376
+ process.stdin.setRawMode(true);
377
+ }
378
+
379
+ // Handle readline close (e.g., stdin closed externally)
380
+ cli.on("close", handleExit);
381
+
382
+ const handleCommand = createCommandHandler({
383
+ agentCommands,
384
+ userEventEmitter,
385
+ claudeCodePlugins,
386
+ helpMessage: HELP_MESSAGE,
387
+ });
388
+
389
+ /**
390
+ * Process the complete user input.
391
+ * @param {string} input
392
+ * @returns {Promise<void>}
393
+ */
394
+ async function processInput(input) {
395
+ // Prevent concurrent input processing from multi-line paste
396
+ state.turn = false;
397
+
398
+ // Resolve paste placeholders to original content
399
+ const resolvedInput = paste.resolvePlaceholders(input);
400
+ const inputTrimmed = resolvedInput.trim();
401
+
402
+ if (inputTrimmed.length === 0) {
403
+ state.turn = true;
404
+ cli.prompt();
405
+ return;
406
+ }
407
+
408
+ cli.setPrompt(currentCliPrompt);
409
+
410
+ const result = await handleCommand(inputTrimmed);
411
+ if (result === "prompt") {
412
+ state.turn = true;
413
+ cli.prompt();
414
+ }
415
+ }
416
+
417
+ cli.on("line", async (lineInput) => {
418
+ if (!state.turn) {
419
+ console.warn(
420
+ styleText(
421
+ "yellow",
422
+ `\nAgent is working. Ignore input: ${lineInput.trim()}`,
423
+ ),
424
+ );
425
+ return;
426
+ }
427
+
428
+ // Check for multi-line delimiter
429
+ if (lineInput.trim() === '"""') {
430
+ if (state.multiLineBuffer === null) {
431
+ state.multiLineBuffer = [];
432
+ cli.setPrompt(styleText("gray", "... "));
433
+ cli.prompt();
434
+ return;
435
+ }
436
+
437
+ const combined = state.multiLineBuffer.join("\n");
438
+ state.multiLineBuffer = null;
439
+ cli.setPrompt(currentCliPrompt);
440
+
441
+ await processInput(combined);
442
+ return;
443
+ }
444
+
445
+ // Accumulate lines if in multi-line mode
446
+ if (state.multiLineBuffer !== null) {
447
+ state.multiLineBuffer.push(lineInput);
448
+ cli.prompt();
449
+ return;
450
+ }
451
+
452
+ await processInput(lineInput);
453
+ });
454
+
455
+ agentEventEmitter.on("partialMessageContent", (partialContent) => {
456
+ if (partialContent.position === "start") {
457
+ const subagentPrefix = state.subagentName
458
+ ? styleText("cyan", `[${state.subagentName}]\n`)
459
+ : "";
460
+ const partialContentStr = styleText("gray", `<${partialContent.type}>`);
461
+ console.log(`\n${subagentPrefix}${partialContentStr}`);
462
+ }
463
+ if (partialContent.content) {
464
+ if (partialContent.type === "tool_use") {
465
+ process.stdout.write(styleText("gray", partialContent.content));
466
+ } else {
467
+ process.stdout.write(partialContent.content);
468
+ }
469
+ }
470
+ if (partialContent.position === "stop") {
471
+ console.log(styleText("gray", `\n</${partialContent.type}>`));
472
+ }
473
+ });
474
+
475
+ agentEventEmitter.on("message", (message) => {
476
+ printMessage(message).catch((err) => {
477
+ console.error(
478
+ styleText("red", `Error rendering message: ${err.message}`),
479
+ );
480
+ });
481
+ });
482
+
483
+ agentEventEmitter.on("toolUseRequest", () => {
484
+ cli.setPrompt(
485
+ getCliPrompt(
486
+ state.subagentName,
487
+ styleText(
488
+ "yellow",
489
+ "Approve tool calls? (y = allow once, Y = allow in this session, or feedback)",
490
+ ),
491
+ ),
492
+ );
493
+ });
494
+
495
+ agentEventEmitter.on("subagentSwitched", (subagent) => {
496
+ state.subagentName = subagent?.name ?? "";
497
+ currentCliPrompt = getCliPrompt(state.subagentName);
498
+ cli.setPrompt(currentCliPrompt);
499
+ });
500
+
501
+ agentEventEmitter.on("providerTokenUsage", (usage) => {
502
+ console.log(formatProviderTokenUsage(usage));
503
+ });
504
+
505
+ agentEventEmitter.on("error", (error) => {
506
+ console.log(
507
+ styleText(
508
+ "red",
509
+ `\nError: message=${error.message}, stack=${error.stack}`,
510
+ ),
511
+ );
512
+ });
513
+
514
+ agentEventEmitter.on("turnEnd", async () => {
515
+ const err = notify(notifyCmd);
516
+ if (err) {
517
+ console.error(
518
+ styleText("yellow", `\nNotification error: ${err.message}`),
519
+ );
520
+ }
521
+ // 暫定対応: token usageのconsole出力を確実にflushするため、次のevent loop tickまで遅延
522
+ await new Promise((resolve) => setTimeout(resolve, 0));
523
+
524
+ state.turn = true;
525
+ cli.prompt();
526
+ });
527
+
528
+ cli.prompt();
529
+
530
+ // Register cleanup handlers
531
+ process.on("exit", cleanup);
532
+ process.on("SIGTERM", cleanup);
533
+ }
@@ -0,0 +1,51 @@
1
+ import { Transform } from "node:stream";
2
+
3
+ /**
4
+ * Create a Transform that intercepts Ctrl-C (0x03), Ctrl-D (0x04), and an
5
+ * optional "voice toggle" byte (default Ctrl-O, 0x0f). When one of those
6
+ * bytes is seen anywhere in a chunk, the corresponding callback is invoked
7
+ * and the entire chunk is dropped so that downstream consumers (e.g.
8
+ * readline) never observe it. All other input flows through unchanged.
9
+ *
10
+ * Priority when multiple handled bytes appear in the same chunk:
11
+ * Ctrl-C > Ctrl-D > voice toggle.
12
+ *
13
+ * @param {object} handlers
14
+ * @param {() => void} handlers.onCtrlC - Called when Ctrl-C is detected
15
+ * @param {() => void} handlers.onCtrlD - Called when Ctrl-D is detected
16
+ * @param {() => void} [handlers.onVoiceToggle]
17
+ * Called when the voice toggle byte is detected.
18
+ * @param {number} [handlers.voiceToggleByte]
19
+ * Byte value for the voice toggle key. Defaults to 0x0f (Ctrl-O).
20
+ * @returns {Transform}
21
+ */
22
+ export function createInterruptTransform({
23
+ onCtrlC,
24
+ onCtrlD,
25
+ onVoiceToggle,
26
+ voiceToggleByte = 0x0f,
27
+ }) {
28
+ const voiceToggleChar = String.fromCharCode(voiceToggleByte);
29
+ return new Transform({
30
+ transform(chunk, _encoding, callback) {
31
+ const data = chunk.toString("utf8");
32
+ if (data.includes("\x03")) {
33
+ onCtrlC();
34
+ callback();
35
+ return;
36
+ }
37
+ if (data.includes("\x04")) {
38
+ onCtrlD();
39
+ callback();
40
+ return;
41
+ }
42
+ if (onVoiceToggle && data.includes(voiceToggleChar)) {
43
+ onVoiceToggle();
44
+ callback();
45
+ return;
46
+ }
47
+ this.push(chunk);
48
+ callback();
49
+ },
50
+ });
51
+ }
@@ -0,0 +1,26 @@
1
+ import { Transform } from "node:stream";
2
+
3
+ /**
4
+ * Create a Transform that swallows all chunks while `isMuted()` returns true,
5
+ * and passes them through unchanged while it returns false.
6
+ *
7
+ * Intended to sit between `createInterruptTransform` and the paste handler so
8
+ * that callers can fully silence regular stdin input during special modes
9
+ * (e.g. while a voice input session is recording) without coupling that
10
+ * concern to the interrupt-detection logic.
11
+ *
12
+ * @param {object} options
13
+ * @param {() => boolean} options.isMuted
14
+ * Called for each incoming chunk; when true the chunk is dropped.
15
+ * @returns {Transform}
16
+ */
17
+ export function createMuteTransform({ isMuted }) {
18
+ return new Transform({
19
+ transform(chunk, _encoding, callback) {
20
+ if (!isMuted()) {
21
+ this.push(chunk);
22
+ }
23
+ callback();
24
+ },
25
+ });
26
+ }