@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.
- package/bin/plain +1 -1
- package/package.json +8 -9
- package/sandbox/bin/plain-sandbox +13 -0
- package/src/agent.d.ts +52 -0
- package/src/agent.mjs +204 -0
- package/src/agentLoop.mjs +419 -0
- package/src/agentState.mjs +41 -0
- package/src/claudeCodePlugin.mjs +164 -0
- package/src/cliArgs.mjs +175 -0
- package/src/cliBatch.mjs +147 -0
- package/src/cliCommands.mjs +283 -0
- package/src/cliCompleter.mjs +227 -0
- package/src/cliCost.mjs +309 -0
- package/src/cliFormatter.mjs +518 -0
- package/src/cliInteractive.mjs +533 -0
- package/src/cliInterruptTransform.mjs +51 -0
- package/src/cliMuteTransform.mjs +26 -0
- package/src/cliPasteTransform.mjs +183 -0
- package/src/config.d.ts +36 -0
- package/src/config.mjs +197 -0
- package/src/context/loadAgentRoles.mjs +267 -0
- package/src/context/loadPrompts.mjs +303 -0
- package/src/context/loadUserMessageContext.mjs +147 -0
- package/src/costTracker.mjs +210 -0
- package/src/env.mjs +44 -0
- package/src/main.mjs +281 -0
- package/src/mcpClient.mjs +351 -0
- package/src/mcpIntegration.mjs +160 -0
- package/src/model.d.ts +109 -0
- package/src/modelCaller.mjs +32 -0
- package/src/modelDefinition.d.ts +92 -0
- package/src/prompt.mjs +138 -0
- package/src/providers/anthropic.d.ts +248 -0
- package/src/providers/anthropic.mjs +587 -0
- package/src/providers/bedrock.d.ts +249 -0
- package/src/providers/bedrock.mjs +700 -0
- package/src/providers/gemini.d.ts +208 -0
- package/src/providers/gemini.mjs +754 -0
- package/src/providers/openai.d.ts +281 -0
- package/src/providers/openai.mjs +544 -0
- package/src/providers/openaiCompatible.d.ts +147 -0
- package/src/providers/openaiCompatible.mjs +652 -0
- package/src/providers/platform/awsSigV4.mjs +184 -0
- package/src/providers/platform/azure.mjs +42 -0
- package/src/providers/platform/bedrock.mjs +78 -0
- package/src/providers/platform/googleCloud.mjs +34 -0
- package/src/subagent.mjs +265 -0
- package/src/tmpfile.mjs +27 -0
- package/src/tool.d.ts +74 -0
- package/src/toolExecutor.mjs +236 -0
- package/src/toolInputValidator.mjs +183 -0
- package/src/toolUseApprover.mjs +99 -0
- package/src/tools/askURL.mjs +209 -0
- package/src/tools/askWeb.mjs +208 -0
- package/src/tools/compactContext.d.ts +4 -0
- package/src/tools/compactContext.mjs +87 -0
- package/src/tools/execCommand.d.ts +22 -0
- package/src/tools/execCommand.mjs +200 -0
- package/src/tools/patchFile.d.ts +4 -0
- package/src/tools/patchFile.mjs +133 -0
- package/src/tools/switchToMainAgent.d.ts +3 -0
- package/src/tools/switchToMainAgent.mjs +43 -0
- package/src/tools/switchToSubagent.d.ts +4 -0
- package/src/tools/switchToSubagent.mjs +59 -0
- package/src/tools/tmuxCommand.d.ts +14 -0
- package/src/tools/tmuxCommand.mjs +194 -0
- package/src/tools/writeFile.d.ts +4 -0
- package/src/tools/writeFile.mjs +56 -0
- package/src/usageStore.mjs +167 -0
- package/src/utils/evalJSONConfig.mjs +72 -0
- package/src/utils/matchValue.d.ts +6 -0
- package/src/utils/matchValue.mjs +40 -0
- package/src/utils/noThrow.mjs +31 -0
- package/src/utils/notify.mjs +29 -0
- package/src/utils/parseFileRange.mjs +18 -0
- package/src/utils/parseFrontmatter.mjs +19 -0
- package/src/utils/readFileRange.mjs +33 -0
- package/src/utils/retryOnError.mjs +41 -0
- package/src/voiceInput.mjs +61 -0
- package/src/voiceInputGemini.mjs +105 -0
- package/src/voiceInputOpenAI.mjs +104 -0
- package/src/voiceInputSession.mjs +543 -0
- package/src/voiceToggleKey.mjs +62 -0
- package/dist/main.mjs +0 -473
- 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
|
+
}
|