@iinm/plain-agent 1.7.16 → 1.7.18

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,6 +1,7 @@
1
1
  /**
2
2
  * @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "./agent"
3
3
  * @import { ClaudeCodePlugin } from "./claudeCodePlugin.mjs"
4
+ * @import { VoiceInputConfig, VoiceSession } from "./voiceInput.mjs"
4
5
  */
5
6
 
6
7
  import readline from "node:readline";
@@ -13,8 +14,10 @@ import {
13
14
  printMessage,
14
15
  } from "./cliFormatter.mjs";
15
16
  import { createInterruptTransform } from "./cliInterruptTransform.mjs";
17
+ import { createMuteTransform } from "./cliMuteTransform.mjs";
16
18
  import { createPasteHandler } from "./cliPasteTransform.mjs";
17
19
  import { notify } from "./utils/notify.mjs";
20
+ import { parseVoiceToggleKey, startVoiceSession } from "./voiceInput.mjs";
18
21
 
19
22
  const HELP_MESSAGE = [
20
23
  "Commands:",
@@ -57,6 +60,7 @@ const HELP_MESSAGE = [
57
60
  * @property {boolean} sandbox
58
61
  * @property {() => Promise<void>} onStop
59
62
  * @property {ClaudeCodePlugin[]} [claudeCodePlugins]
63
+ * @property {VoiceInputConfig} [voiceInput]
60
64
  */
61
65
 
62
66
  /**
@@ -72,6 +76,7 @@ export function startInteractiveSession({
72
76
  sandbox,
73
77
  onStop,
74
78
  claudeCodePlugins,
79
+ voiceInput,
75
80
  }) {
76
81
  /** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string }} */
77
82
  const state = {
@@ -80,6 +85,16 @@ export function startInteractiveSession({
80
85
  subagentName: "",
81
86
  };
82
87
 
88
+ /**
89
+ * Active voice input session, or null when not recording.
90
+ * @type {{ session: VoiceSession, startCursor: number, transcriptLength: number } | null}
91
+ */
92
+ let voice = null;
93
+
94
+ // Parse the voice toggle key once at startup so misconfiguration fails
95
+ // loudly instead of silently falling back.
96
+ const voiceToggle = parseVoiceToggleKey(voiceInput?.toggleKey);
97
+
83
98
  const getCliPrompt = (subagentName = "", flashMessage = "") =>
84
99
  [
85
100
  "",
@@ -136,7 +151,100 @@ export function startInteractiveSession({
136
151
  cli.prompt();
137
152
  };
138
153
 
154
+ const stopVoiceSession = async () => {
155
+ if (!voice) return;
156
+ const current = voice;
157
+ voice = null;
158
+ await current.session.stop();
159
+ cli.setPrompt(currentCliPrompt);
160
+ // @ts-expect-error - internal property
161
+ cli._refreshLine?.();
162
+ };
163
+
164
+ const handleVoiceToggle = () => {
165
+ // Ignore while the agent is working.
166
+ if (!state.turn) return;
167
+
168
+ if (voice) {
169
+ stopVoiceSession();
170
+ return;
171
+ }
172
+
173
+ if (!voiceInput) {
174
+ cli.setPrompt(
175
+ getCliPrompt(
176
+ state.subagentName,
177
+ styleText(
178
+ "yellow",
179
+ `Voice input not configured. Set \`voiceInput\` in your config to enable ${voiceToggle.label}.`,
180
+ ),
181
+ ),
182
+ );
183
+ cli.prompt(true);
184
+ return;
185
+ }
186
+
187
+ const startCursor = cli.cursor;
188
+ const session = startVoiceSession({
189
+ config: voiceInput,
190
+ callbacks: {
191
+ onTranscript: (delta) => {
192
+ if (!voice) return;
193
+ const insertAt = voice.startCursor + voice.transcriptLength;
194
+ // Insert delta at the recording's insertion point. User input is
195
+ // swallowed while recording, so the buffer around `insertAt` is
196
+ // stable.
197
+ const before = cli.line.slice(0, insertAt);
198
+ const after = cli.line.slice(insertAt);
199
+ // `line` and `cursor` are declared readonly in the Node typings but
200
+ // are writable at runtime — the existing code already patches
201
+ // `_refreshLine` in the same way.
202
+ const mutableCli = /** @type {{ line: string, cursor: number }} */ (
203
+ /** @type {unknown} */ (cli)
204
+ );
205
+ mutableCli.line = before + delta + after;
206
+ mutableCli.cursor = insertAt + delta.length;
207
+ voice.transcriptLength += delta.length;
208
+ // @ts-expect-error - internal property
209
+ cli._refreshLine?.();
210
+ },
211
+ onError: (err) => {
212
+ voice = null;
213
+ cli.setPrompt(
214
+ getCliPrompt(
215
+ state.subagentName,
216
+ styleText("red", `Voice input error: ${err.message}`),
217
+ ),
218
+ );
219
+ cli.prompt(true);
220
+ },
221
+ onClose: () => {
222
+ if (!voice) return;
223
+ voice = null;
224
+ cli.setPrompt(currentCliPrompt);
225
+ // @ts-expect-error - internal property
226
+ cli._refreshLine?.();
227
+ },
228
+ },
229
+ });
230
+ voice = { session, startCursor, transcriptLength: 0 };
231
+ cli.setPrompt(
232
+ getCliPrompt(
233
+ state.subagentName,
234
+ styleText(["red", "bold"], `● REC (${voiceToggle.label} to stop)`),
235
+ ),
236
+ );
237
+ // @ts-expect-error - internal property
238
+ cli._refreshLine?.();
239
+ };
240
+
139
241
  const handleCtrlC = () => {
242
+ // Stop voice recording first if active.
243
+ if (voice) {
244
+ stopVoiceSession();
245
+ return;
246
+ }
247
+
140
248
  // Agent turn: pause auto-approve; do not clear input.
141
249
  if (!state.turn) {
142
250
  agentCommands.pauseAutoApprove();
@@ -192,14 +300,20 @@ export function startInteractiveSession({
192
300
  };
193
301
 
194
302
  // Pre-readline pipeline:
195
- // stdin -> interrupt (Ctrl-C / Ctrl-D) -> paste (bracketed paste) -> readline
303
+ // stdin -> interrupt (Ctrl-C / Ctrl-D) -> mute (voice recording) -> paste (bracketed paste) -> readline
196
304
  const interrupt = createInterruptTransform({
197
305
  onCtrlC: handleCtrlC,
198
306
  onCtrlD: handleCtrlD,
307
+ onVoiceToggle: handleVoiceToggle,
308
+ voiceToggleByte: voiceToggle.byte,
199
309
  });
310
+ // While a voice session is recording, swallow all stdin bytes other than
311
+ // Ctrl-C / Ctrl-D / the voice toggle key so transcript insertion stays
312
+ // consistent.
313
+ const mute = createMuteTransform({ isMuted: () => voice !== null });
200
314
  const paste = createPasteHandler();
201
315
 
202
- process.stdin.pipe(interrupt).pipe(paste.transform);
316
+ process.stdin.pipe(interrupt).pipe(mute).pipe(paste.transform);
203
317
 
204
318
  // Enable bracketed paste mode
205
319
  if (process.stdout.isTTY) {
@@ -1,19 +1,31 @@
1
1
  import { Transform } from "node:stream";
2
2
 
3
3
  /**
4
- * Create a Transform that intercepts Ctrl-C (0x03) and Ctrl-D (0x04). When
5
- * either byte is seen anywhere in a chunk, the corresponding callback is
6
- * invoked and the entire chunk is dropped so that downstream consumers (e.g.
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.
7
8
  * readline) never observe it. All other input flows through unchanged.
8
9
  *
9
- * If both bytes appear in the same chunk, Ctrl-C is handled first.
10
+ * Priority when multiple handled bytes appear in the same chunk:
11
+ * Ctrl-C > Ctrl-D > voice toggle.
10
12
  *
11
13
  * @param {object} handlers
12
14
  * @param {() => void} handlers.onCtrlC - Called when Ctrl-C is detected
13
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).
14
20
  * @returns {Transform}
15
21
  */
16
- export function createInterruptTransform({ onCtrlC, onCtrlD }) {
22
+ export function createInterruptTransform({
23
+ onCtrlC,
24
+ onCtrlD,
25
+ onVoiceToggle,
26
+ voiceToggleByte = 0x0f,
27
+ }) {
28
+ const voiceToggleChar = String.fromCharCode(voiceToggleByte);
17
29
  return new Transform({
18
30
  transform(chunk, _encoding, callback) {
19
31
  const data = chunk.toString("utf8");
@@ -27,6 +39,11 @@ export function createInterruptTransform({ onCtrlC, onCtrlD }) {
27
39
  callback();
28
40
  return;
29
41
  }
42
+ if (onVoiceToggle && data.includes(voiceToggleChar)) {
43
+ onVoiceToggle();
44
+ callback();
45
+ return;
46
+ }
30
47
  this.push(chunk);
31
48
  callback();
32
49
  },
@@ -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
+ }
package/src/config.d.ts CHANGED
@@ -4,6 +4,7 @@ import { AskURLToolOptions } from "./tools/askURL.mjs";
4
4
  import { AskWebToolOptions } from "./tools/askWeb.mjs";
5
5
  import { ExecCommandSanboxConfig } from "./tools/execCommand";
6
6
  import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
7
+ import { VoiceInputConfig } from "./voiceInput.mjs";
7
8
 
8
9
  export type AppConfig = {
9
10
  model?: string;
@@ -21,6 +22,7 @@ export type AppConfig = {
21
22
  };
22
23
  mcpServers?: Record<string, MCPServerConfig>;
23
24
  notifyCmd?: string;
25
+ voiceInput?: VoiceInputConfig;
24
26
  claudeCodePlugins?: ClaudeCodePluginRepo[];
25
27
  };
26
28
 
package/src/config.mjs CHANGED
@@ -98,6 +98,9 @@ export async function loadAppConfig(options = {}) {
98
98
  ...(merged.claudeCodePlugins ?? []),
99
99
  ...(config.claudeCodePlugins ?? []),
100
100
  ],
101
+ voiceInput: config.voiceInput
102
+ ? { ...(merged.voiceInput ?? {}), ...config.voiceInput }
103
+ : merged.voiceInput,
101
104
  };
102
105
  }
103
106
 
package/src/main.mjs CHANGED
@@ -257,6 +257,7 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
257
257
  ...sessionOptions,
258
258
  notifyCmd: appConfig.notifyCmd || AGENT_NOTIFY_CMD_DEFAULT,
259
259
  claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
260
+ voiceInput: appConfig.voiceInput,
260
261
  });
261
262
  }
262
263
  })().catch((err) => {