@iinm/plain-agent 1.6.0 → 1.7.0
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/.config/config.predefined.json +6 -6
- package/README.md +6 -8
- package/package.json +4 -4
- package/src/agent.d.ts +1 -0
- package/src/agent.mjs +14 -0
- package/src/agentLoop.mjs +12 -9
- package/src/cliArgs.mjs +5 -5
- package/src/cliCommands.mjs +270 -0
- package/src/cliCompleter.mjs +222 -0
- package/src/cliFormatter.mjs +63 -1
- package/src/cliInteractive.mjs +64 -661
- package/src/cliPasteTransform.mjs +127 -0
- package/src/context/loadAgentRoles.mjs +5 -0
- package/src/context/loadPrompts.mjs +5 -0
- package/src/env.mjs +0 -5
- package/src/prompt.mjs +7 -6
- package/src/subagent.mjs +11 -4
- package/bin/plain-interrupt +0 -6
- package/src/context/consumeInterruptMessage.mjs +0 -30
package/src/cliInteractive.mjs
CHANGED
|
@@ -1,164 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { Message } from "./model"
|
|
3
2
|
* @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "./agent"
|
|
4
3
|
* @import { ClaudeCodePlugin } from "./claudeCodePlugin.mjs"
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
|
-
import { execFileSync } from "node:child_process";
|
|
8
6
|
import readline from "node:readline";
|
|
9
|
-
import { Transform } from "node:stream";
|
|
10
7
|
import { styleText } from "node:util";
|
|
8
|
+
import { createCommandHandler } from "./cliCommands.mjs";
|
|
9
|
+
import { createCompleter, SLASH_COMMANDS } from "./cliCompleter.mjs";
|
|
11
10
|
import {
|
|
12
11
|
formatCostSummary,
|
|
13
12
|
formatProviderTokenUsage,
|
|
14
|
-
|
|
15
|
-
formatToolUse,
|
|
13
|
+
printMessage,
|
|
16
14
|
} from "./cliFormatter.mjs";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
import {
|
|
16
|
+
createPasteTransform,
|
|
17
|
+
resolvePastePlaceholders,
|
|
18
|
+
} from "./cliPasteTransform.mjs";
|
|
21
19
|
import { notify } from "./utils/notify.mjs";
|
|
22
|
-
import { parseFileRange } from "./utils/parseFileRange.mjs";
|
|
23
|
-
import { readFileRange } from "./utils/readFileRange.mjs";
|
|
24
|
-
|
|
25
|
-
// Define available slash commands for tab completion
|
|
26
|
-
const SLASH_COMMANDS = [
|
|
27
|
-
{ name: "/help", description: "Display this help message" },
|
|
28
|
-
{ name: "/agents", description: "List available agent roles" },
|
|
29
|
-
{
|
|
30
|
-
name: "/agents:<id>",
|
|
31
|
-
description:
|
|
32
|
-
"Delegate to an agent with the given ID (e.g., /agents:code-simplifier)",
|
|
33
|
-
},
|
|
34
|
-
{ name: "/prompts", description: "List available prompts" },
|
|
35
|
-
{
|
|
36
|
-
name: "/prompts:<id>",
|
|
37
|
-
description:
|
|
38
|
-
"Invoke a prompt with the given ID (e.g., /prompts:feature-dev)",
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
name: "/<id>",
|
|
42
|
-
description:
|
|
43
|
-
"Shortcut for prompts in the shortcuts/ directory (e.g., /commit)",
|
|
44
|
-
},
|
|
45
|
-
{ name: "/paste", description: "Paste content from clipboard" },
|
|
46
|
-
{
|
|
47
|
-
name: "/resume",
|
|
48
|
-
description: "Resume conversation after an LLM provider error",
|
|
49
|
-
},
|
|
50
|
-
{ name: "/dump", description: "Save current messages to a JSON file" },
|
|
51
|
-
{ name: "/load", description: "Load messages from a JSON file" },
|
|
52
|
-
{ name: "/cost", description: "Display session cost and token usage" },
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* @typedef {Object} CompletionCandidate
|
|
57
|
-
* @property {string} name
|
|
58
|
-
* @property {string} description
|
|
59
|
-
*/
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Find candidates that match the line, prioritizing prefix matches.
|
|
63
|
-
* @param {(string | CompletionCandidate)[]} candidates
|
|
64
|
-
* @param {string} line
|
|
65
|
-
* @param {number} queryStartIndex
|
|
66
|
-
* @returns {(string | CompletionCandidate)[]}
|
|
67
|
-
*/
|
|
68
|
-
function findMatches(candidates, line, queryStartIndex) {
|
|
69
|
-
const query = line.slice(queryStartIndex);
|
|
70
|
-
const prefixMatches = [];
|
|
71
|
-
const partialMatches = [];
|
|
72
|
-
|
|
73
|
-
for (const candidate of candidates) {
|
|
74
|
-
const name = typeof candidate === "string" ? candidate : candidate.name;
|
|
75
|
-
if (name.startsWith(line)) {
|
|
76
|
-
prefixMatches.push(candidate);
|
|
77
|
-
} else if (
|
|
78
|
-
query.length > 0 &&
|
|
79
|
-
name.slice(queryStartIndex).includes(query)
|
|
80
|
-
) {
|
|
81
|
-
partialMatches.push(candidate);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return [...prefixMatches, ...partialMatches];
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Return the longest common prefix of the given strings.
|
|
90
|
-
* @param {string[]} strings
|
|
91
|
-
* @returns {string}
|
|
92
|
-
*/
|
|
93
|
-
function commonPrefix(strings) {
|
|
94
|
-
if (strings.length === 0) return "";
|
|
95
|
-
let prefix = strings[0];
|
|
96
|
-
for (let i = 1; i < strings.length; i++) {
|
|
97
|
-
while (!strings[i].startsWith(prefix)) {
|
|
98
|
-
prefix = prefix.slice(0, -1);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return prefix;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Display completion candidates and invoke the readline callback.
|
|
106
|
-
*
|
|
107
|
-
* Node.js readline normally requires two consecutive Tab presses to show the
|
|
108
|
-
* candidate list. This helper lets readline handle the common-prefix
|
|
109
|
-
* auto-completion first, then prints the candidate list on the next tick and
|
|
110
|
-
* redraws the prompt so the display stays clean.
|
|
111
|
-
*
|
|
112
|
-
* @param {import("node:readline").Interface} rl
|
|
113
|
-
* @param {(string | CompletionCandidate)[]} candidates
|
|
114
|
-
* @param {string} line
|
|
115
|
-
* @param {(err: Error | null, result: [string[], string]) => void} callback
|
|
116
|
-
*/
|
|
117
|
-
function showCompletions(rl, candidates, line, callback) {
|
|
118
|
-
const names = candidates.map((c) => (typeof c === "string" ? c : c.name));
|
|
119
|
-
if (candidates.length <= 1) {
|
|
120
|
-
callback(null, [names, line]);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
const prefix = commonPrefix(names);
|
|
124
|
-
if (prefix.length > line.length) {
|
|
125
|
-
// Let readline insert the common prefix.
|
|
126
|
-
callback(null, [[prefix], line]);
|
|
127
|
-
} else {
|
|
128
|
-
// Nothing new to insert.
|
|
129
|
-
callback(null, [[], line]);
|
|
130
|
-
}
|
|
131
|
-
// After readline finishes its own refresh, print the candidate list and
|
|
132
|
-
// redraw the prompt line. We cannot use rl.prompt(true) because its
|
|
133
|
-
// internal _refreshLine clears everything below the prompt start, which
|
|
134
|
-
// erases the candidate list we just wrote. Instead we manually re-output
|
|
135
|
-
// the prompt and current line content.
|
|
136
|
-
setTimeout(() => {
|
|
137
|
-
const maxLength = process.stdout.columns ?? 100;
|
|
138
|
-
const list = candidates
|
|
139
|
-
.map((c) => {
|
|
140
|
-
if (typeof c === "string") return c;
|
|
141
|
-
const nameText = c.name.padEnd(25);
|
|
142
|
-
const separator = " - ";
|
|
143
|
-
const descText = c.description;
|
|
144
|
-
|
|
145
|
-
// 画面幅に合わせて説明文をカット(色を付ける前に計算)
|
|
146
|
-
const availableWidth =
|
|
147
|
-
maxLength - nameText.length - separator.length - 3;
|
|
148
|
-
const displayDesc =
|
|
149
|
-
descText.length > availableWidth && availableWidth > 0
|
|
150
|
-
? `${descText.slice(0, availableWidth)}...`
|
|
151
|
-
: descText;
|
|
152
|
-
|
|
153
|
-
const name = styleText("cyan", nameText);
|
|
154
|
-
const description = styleText("dim", displayDesc);
|
|
155
|
-
return `${name}${separator}${description}`;
|
|
156
|
-
})
|
|
157
|
-
.join("\r\n");
|
|
158
|
-
process.stdout.write(`\r\n${list}\r\n`);
|
|
159
|
-
process.stdout.write(`${rl.getPrompt()}${rl.line}`);
|
|
160
|
-
}, 0);
|
|
161
|
-
}
|
|
162
20
|
|
|
163
21
|
const HELP_MESSAGE = [
|
|
164
22
|
"Commands:",
|
|
@@ -190,57 +48,6 @@ const HELP_MESSAGE = [
|
|
|
190
48
|
.replace(/^ {2}\/.+?(?= - )/gm, (m) => styleText("cyan", m))
|
|
191
49
|
.replace(/^ {2}.+?(?= - )/gm, (m) => styleText("blue", m));
|
|
192
50
|
|
|
193
|
-
// Bracketed paste mode sequences
|
|
194
|
-
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
195
|
-
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
196
|
-
|
|
197
|
-
// Store for pasted content
|
|
198
|
-
const pastedContentStore = new Map();
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Generate a short hash for paste reference
|
|
202
|
-
* @param {string} content
|
|
203
|
-
* @returns {string}
|
|
204
|
-
*/
|
|
205
|
-
function generatePasteHash(content) {
|
|
206
|
-
let hash = 0;
|
|
207
|
-
for (let i = 0; i < content.length; i++) {
|
|
208
|
-
const char = content.charCodeAt(i);
|
|
209
|
-
hash = (hash << 5) - hash + char;
|
|
210
|
-
hash = hash & hash; // Convert to 32bit integer
|
|
211
|
-
}
|
|
212
|
-
return Math.abs(hash).toString(16).padStart(6, "0").slice(0, 6);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Resolve paste placeholders and append context tags
|
|
217
|
-
* @param {string} input
|
|
218
|
-
* @returns {string}
|
|
219
|
-
*/
|
|
220
|
-
function resolvePastePlaceholders(input) {
|
|
221
|
-
/** @type {string[]} */
|
|
222
|
-
const contexts = [];
|
|
223
|
-
|
|
224
|
-
// Collect paste content for context tags while keeping placeholders
|
|
225
|
-
const text = input.replace(/\[pasted#([a-f0-9]{6})\]/g, (match, hash) => {
|
|
226
|
-
const content = pastedContentStore.get(hash);
|
|
227
|
-
if (content !== undefined) {
|
|
228
|
-
pastedContentStore.delete(hash); // Clean up after use
|
|
229
|
-
contexts.push(
|
|
230
|
-
`<context location="pasted#${hash}">\n${content}\n</context>`,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
return match; // Keep placeholder in text
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
// Append contexts to the end of input
|
|
237
|
-
if (contexts.length > 0) {
|
|
238
|
-
return [text, ...contexts].join("\n\n");
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return text;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
51
|
/**
|
|
245
52
|
* @typedef {object} CliOptions
|
|
246
53
|
* @property {UserEventEmitter} userEventEmitter
|
|
@@ -275,61 +82,6 @@ export function startInteractiveSession({
|
|
|
275
82
|
subagentName: "",
|
|
276
83
|
};
|
|
277
84
|
|
|
278
|
-
/**
|
|
279
|
-
* @param {string} id
|
|
280
|
-
* @param {string} goal
|
|
281
|
-
* @returns {Promise<void>}
|
|
282
|
-
*/
|
|
283
|
-
async function invokeAgent(id, goal) {
|
|
284
|
-
const agentRoles = await loadAgentRoles(claudeCodePlugins);
|
|
285
|
-
const agent = agentRoles.get(id);
|
|
286
|
-
const name = agent ? id : `custom:${id}`;
|
|
287
|
-
|
|
288
|
-
const [goalTextContent, ...goalImages] = await loadUserMessageContext(goal);
|
|
289
|
-
const goalText =
|
|
290
|
-
goalTextContent?.type === "text" ? goalTextContent.text : goal;
|
|
291
|
-
|
|
292
|
-
const messageText = `Delegate to "${name}" agent with goal: ${goalText}`;
|
|
293
|
-
userEventEmitter.emit("userInput", [
|
|
294
|
-
{ type: "text", text: messageText },
|
|
295
|
-
...goalImages,
|
|
296
|
-
]);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* @param {string} id
|
|
301
|
-
* @param {string} args
|
|
302
|
-
* @param {string} displayInvocation
|
|
303
|
-
* @returns {Promise<void>}
|
|
304
|
-
*/
|
|
305
|
-
async function invokePrompt(id, args, displayInvocation) {
|
|
306
|
-
const prompts = await loadPrompts(claudeCodePlugins);
|
|
307
|
-
const prompt = prompts.get(id);
|
|
308
|
-
|
|
309
|
-
if (!prompt) {
|
|
310
|
-
console.log(styleText("red", `\nPrompt not found: ${id}`));
|
|
311
|
-
state.turn = true;
|
|
312
|
-
cli.prompt();
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const [argsTextContent, ...argsImages] = args
|
|
317
|
-
? await loadUserMessageContext(args)
|
|
318
|
-
: [];
|
|
319
|
-
const argsText =
|
|
320
|
-
argsTextContent?.type === "text" ? argsTextContent.text : args;
|
|
321
|
-
|
|
322
|
-
const invocation = `${displayInvocation}${argsText ? ` ${argsText}` : ""}`;
|
|
323
|
-
const message = prompt.isSkill
|
|
324
|
-
? `System: This prompt was invoked as "${invocation}".\nPrompt path: ${prompt.filePath}\n\n${prompt.content}`
|
|
325
|
-
: `System: This prompt was invoked as "${invocation}".\n\n${prompt.content}`;
|
|
326
|
-
|
|
327
|
-
userEventEmitter.emit("userInput", [
|
|
328
|
-
{ type: "text", text: message },
|
|
329
|
-
...argsImages,
|
|
330
|
-
]);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
85
|
const getCliPrompt = (subagentName = "") =>
|
|
334
86
|
[
|
|
335
87
|
"",
|
|
@@ -343,77 +95,55 @@ export function startInteractiveSession({
|
|
|
343
95
|
"> ",
|
|
344
96
|
].join("\n");
|
|
345
97
|
|
|
346
|
-
//
|
|
347
|
-
|
|
98
|
+
// Cleanup handler to disable bracketed paste mode on exit
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
if (process.stdout.isTTY) {
|
|
101
|
+
process.stdout.write("\x1b[?2004l");
|
|
102
|
+
}
|
|
103
|
+
};
|
|
348
104
|
|
|
349
|
-
//
|
|
350
|
-
let
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
transform(chunk, _encoding, callback) {
|
|
355
|
-
let data = chunk.toString("utf8");
|
|
356
|
-
|
|
357
|
-
// Handle Ctrl-C and Ctrl-D
|
|
358
|
-
if (data.includes("\x03") || data.includes("\x04")) {
|
|
359
|
-
// Ctrl-C / Ctrl-D: request exit (handled by confirmExit)
|
|
360
|
-
onExitRequest();
|
|
361
|
-
callback();
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
105
|
+
// Handle exit signals
|
|
106
|
+
let isExiting = false;
|
|
107
|
+
const handleExit = async () => {
|
|
108
|
+
if (isExiting) return;
|
|
109
|
+
isExiting = true;
|
|
364
110
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
inPasteMode = false;
|
|
373
|
-
|
|
374
|
-
// Handle paste content
|
|
375
|
-
if (pasteBuffer) {
|
|
376
|
-
// Remove trailing newline for single-line paste detection
|
|
377
|
-
const trimmedPaste = pasteBuffer.replace(/\n$/, "");
|
|
378
|
-
|
|
379
|
-
// For single-line paste, insert directly without placeholder
|
|
380
|
-
if (!trimmedPaste.includes("\n")) {
|
|
381
|
-
this.push(trimmedPaste);
|
|
382
|
-
} else {
|
|
383
|
-
// For multi-line paste, use placeholder
|
|
384
|
-
const hash = generatePasteHash(pasteBuffer);
|
|
385
|
-
pastedContentStore.set(hash, pasteBuffer);
|
|
386
|
-
this.push(`[pasted#${hash}] `);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
pasteBuffer = "";
|
|
390
|
-
} else {
|
|
391
|
-
// Still in paste mode
|
|
392
|
-
pasteBuffer += data;
|
|
393
|
-
data = "";
|
|
394
|
-
}
|
|
395
|
-
} else {
|
|
396
|
-
const startIdx = data.indexOf(BRACKETED_PASTE_START);
|
|
397
|
-
if (startIdx !== -1) {
|
|
398
|
-
// Start of paste
|
|
399
|
-
// Output any data before the paste
|
|
400
|
-
if (startIdx > 0) {
|
|
401
|
-
this.push(data.slice(0, startIdx));
|
|
402
|
-
}
|
|
403
|
-
data = data.slice(startIdx + BRACKETED_PASTE_START.length);
|
|
404
|
-
inPasteMode = true;
|
|
405
|
-
pasteBuffer = "";
|
|
406
|
-
} else {
|
|
407
|
-
// Normal data
|
|
408
|
-
this.push(data);
|
|
409
|
-
data = "";
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
111
|
+
cleanup();
|
|
112
|
+
const summary = agentCommands.getCostSummary();
|
|
113
|
+
console.log();
|
|
114
|
+
console.log(formatCostSummary(summary));
|
|
115
|
+
await onStop();
|
|
116
|
+
process.exit(0);
|
|
117
|
+
};
|
|
413
118
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
119
|
+
// Double-press exit confirmation
|
|
120
|
+
let lastExitAttempt = 0;
|
|
121
|
+
const EXIT_CONFIRM_TIMEOUT = 1500;
|
|
122
|
+
|
|
123
|
+
const handleCtrlC = () => {
|
|
124
|
+
// If agent is running, pause auto-approve instead of exiting
|
|
125
|
+
if (!state.turn) {
|
|
126
|
+
agentCommands.pauseAutoApprove();
|
|
127
|
+
console.log(
|
|
128
|
+
styleText(
|
|
129
|
+
"yellow",
|
|
130
|
+
"\n⚠ Ctrl-C: Auto-approve paused. Finishing current tool...",
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
if (now - lastExitAttempt < EXIT_CONFIRM_TIMEOUT) {
|
|
138
|
+
handleExit();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
lastExitAttempt = now;
|
|
142
|
+
console.log(styleText("yellow", "\nPress Ctrl-C or Ctrl-D again to exit."));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Create a transform stream to handle bracketed paste before readline
|
|
146
|
+
const pasteTransform = createPasteTransform(handleCtrlC);
|
|
417
147
|
|
|
418
148
|
// Set up transformed stdin for readline
|
|
419
149
|
process.stdin.pipe(pasteTransform);
|
|
@@ -424,76 +154,12 @@ export function startInteractiveSession({
|
|
|
424
154
|
}
|
|
425
155
|
|
|
426
156
|
let currentCliPrompt = getCliPrompt();
|
|
157
|
+
/** @type {import("node:readline").Interface} */
|
|
427
158
|
const cli = readline.createInterface({
|
|
428
159
|
input: pasteTransform,
|
|
429
160
|
output: process.stdout,
|
|
430
161
|
prompt: currentCliPrompt,
|
|
431
|
-
|
|
432
|
-
* @param {string} line
|
|
433
|
-
* @param {(err?: Error | null, result?: [string[], string]) => void} callback
|
|
434
|
-
*/
|
|
435
|
-
completer: (line, callback) => {
|
|
436
|
-
(async () => {
|
|
437
|
-
try {
|
|
438
|
-
const prompts = await loadPrompts(claudeCodePlugins);
|
|
439
|
-
const agentRoles = await loadAgentRoles(claudeCodePlugins);
|
|
440
|
-
|
|
441
|
-
if (line.startsWith("/agents:")) {
|
|
442
|
-
const prefix = "/agents:";
|
|
443
|
-
const candidates = Array.from(agentRoles.values()).map((a) => ({
|
|
444
|
-
name: `${prefix}${a.id}`,
|
|
445
|
-
description: a.description,
|
|
446
|
-
}));
|
|
447
|
-
const hits = findMatches(candidates, line, prefix.length);
|
|
448
|
-
|
|
449
|
-
showCompletions(cli, hits, line, callback);
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (line.startsWith("/prompts:")) {
|
|
454
|
-
const prefix = "/prompts:";
|
|
455
|
-
const candidates = Array.from(prompts.values()).map((p) => ({
|
|
456
|
-
name: `${prefix}${p.id}`,
|
|
457
|
-
description: p.description,
|
|
458
|
-
}));
|
|
459
|
-
const hits = findMatches(candidates, line, prefix.length);
|
|
460
|
-
|
|
461
|
-
showCompletions(cli, hits, line, callback);
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
if (line.startsWith("/")) {
|
|
466
|
-
const shortcuts = Array.from(prompts.values())
|
|
467
|
-
.filter((p) => p.isShortcut)
|
|
468
|
-
.map((p) => ({
|
|
469
|
-
name: `/${p.id}`,
|
|
470
|
-
description: p.description,
|
|
471
|
-
}));
|
|
472
|
-
|
|
473
|
-
const allCommands = [...SLASH_COMMANDS, ...shortcuts].filter(
|
|
474
|
-
(cmd) => {
|
|
475
|
-
const name = typeof cmd === "string" ? cmd : cmd.name;
|
|
476
|
-
return (
|
|
477
|
-
name !== "/<id>" &&
|
|
478
|
-
(name === "/agents:" || !name.startsWith("/agent:")) &&
|
|
479
|
-
(name === "/prompts:" || !name.startsWith("/prompt:"))
|
|
480
|
-
);
|
|
481
|
-
},
|
|
482
|
-
);
|
|
483
|
-
|
|
484
|
-
const hits = findMatches(allCommands, line, 1);
|
|
485
|
-
|
|
486
|
-
showCompletions(cli, hits, line, callback);
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
callback(null, [[], line]);
|
|
491
|
-
} catch (err) {
|
|
492
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
493
|
-
callback(error, [[], line]);
|
|
494
|
-
}
|
|
495
|
-
})();
|
|
496
|
-
},
|
|
162
|
+
completer: createCompleter(() => cli, claudeCodePlugins),
|
|
497
163
|
});
|
|
498
164
|
|
|
499
165
|
// Disable automatic prompt redraw on resize during agent turn
|
|
@@ -512,47 +178,17 @@ export function startInteractiveSession({
|
|
|
512
178
|
if (process.stdin.isTTY) {
|
|
513
179
|
process.stdin.setRawMode(true);
|
|
514
180
|
}
|
|
515
|
-
// Cleanup handler to disable bracketed paste mode on exit
|
|
516
|
-
const cleanup = () => {
|
|
517
|
-
if (process.stdout.isTTY) {
|
|
518
|
-
process.stdout.write("\x1b[?2004l");
|
|
519
|
-
}
|
|
520
|
-
};
|
|
521
|
-
|
|
522
|
-
// Handle exit signals
|
|
523
|
-
let isExiting = false;
|
|
524
|
-
const handleExit = async () => {
|
|
525
|
-
if (isExiting) return;
|
|
526
|
-
isExiting = true;
|
|
527
|
-
|
|
528
|
-
cleanup();
|
|
529
|
-
const summary = agentCommands.getCostSummary();
|
|
530
|
-
console.log();
|
|
531
|
-
console.log(formatCostSummary(summary));
|
|
532
|
-
await onStop();
|
|
533
|
-
process.exit(0);
|
|
534
|
-
};
|
|
535
|
-
|
|
536
|
-
// Double-press exit confirmation
|
|
537
|
-
let lastExitAttempt = 0;
|
|
538
|
-
const EXIT_CONFIRM_TIMEOUT = 1500;
|
|
539
|
-
|
|
540
|
-
const confirmExit = () => {
|
|
541
|
-
const now = Date.now();
|
|
542
|
-
if (now - lastExitAttempt < EXIT_CONFIRM_TIMEOUT) {
|
|
543
|
-
handleExit();
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
lastExitAttempt = now;
|
|
547
|
-
console.log(styleText("yellow", "\nPress Ctrl-C or Ctrl-D again to exit."));
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
// Wire up exit request handler for Ctrl-C / Ctrl-D
|
|
551
|
-
onExitRequest = confirmExit;
|
|
552
181
|
|
|
553
182
|
// Handle readline close (e.g., stdin closed externally)
|
|
554
183
|
cli.on("close", handleExit);
|
|
555
184
|
|
|
185
|
+
const handleCommand = createCommandHandler({
|
|
186
|
+
agentCommands,
|
|
187
|
+
userEventEmitter,
|
|
188
|
+
claudeCodePlugins,
|
|
189
|
+
helpMessage: HELP_MESSAGE,
|
|
190
|
+
});
|
|
191
|
+
|
|
556
192
|
/**
|
|
557
193
|
* Process the complete user input.
|
|
558
194
|
* @param {string} input
|
|
@@ -573,184 +209,12 @@ export function startInteractiveSession({
|
|
|
573
209
|
}
|
|
574
210
|
|
|
575
211
|
cli.setPrompt(currentCliPrompt);
|
|
576
|
-
await consumeInterruptMessage();
|
|
577
|
-
|
|
578
|
-
if (["/help", "help"].includes(inputTrimmed.toLowerCase())) {
|
|
579
|
-
console.log(`\n${HELP_MESSAGE}`);
|
|
580
|
-
state.turn = true;
|
|
581
|
-
cli.prompt();
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (inputTrimmed.startsWith("!")) {
|
|
586
|
-
const fileRange = parseFileRange(inputTrimmed.slice(1));
|
|
587
|
-
if (fileRange instanceof Error) {
|
|
588
|
-
console.log(styleText("red", `\n${fileRange.message}`));
|
|
589
|
-
state.turn = true;
|
|
590
|
-
cli.prompt();
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
const fileContent = await readFileRange(fileRange);
|
|
595
|
-
if (fileContent instanceof Error) {
|
|
596
|
-
console.log(styleText("red", `\n${fileContent.message}`));
|
|
597
|
-
state.turn = true;
|
|
598
|
-
cli.prompt();
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const messageWithContext = await loadUserMessageContext(fileContent);
|
|
603
|
-
|
|
604
|
-
userEventEmitter.emit("userInput", messageWithContext);
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
if (inputTrimmed.toLowerCase() === "/dump") {
|
|
609
|
-
await agentCommands.dumpMessages();
|
|
610
|
-
state.turn = true;
|
|
611
|
-
cli.prompt();
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
if (inputTrimmed.toLowerCase() === "/load") {
|
|
616
|
-
await agentCommands.loadMessages();
|
|
617
|
-
state.turn = true;
|
|
618
|
-
cli.prompt();
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
if (inputTrimmed.toLowerCase() === "/cost") {
|
|
623
|
-
const summary = agentCommands.getCostSummary();
|
|
624
|
-
console.log(formatCostSummary(summary));
|
|
625
|
-
state.turn = true;
|
|
626
|
-
cli.prompt();
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
if (inputTrimmed === "/agents") {
|
|
631
|
-
const agentRoles = await loadAgentRoles(claudeCodePlugins);
|
|
632
212
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
console.log(" No agent roles found.");
|
|
636
|
-
} else {
|
|
637
|
-
for (const role of agentRoles.values()) {
|
|
638
|
-
const maxLength = process.stdout.columns ?? 100;
|
|
639
|
-
const line = ` ${styleText("cyan", role.id.padEnd(20))} - ${role.description}`;
|
|
640
|
-
console.log(
|
|
641
|
-
line.length > maxLength ? `${line.slice(0, maxLength)}...` : line,
|
|
642
|
-
);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
213
|
+
const result = await handleCommand(inputTrimmed);
|
|
214
|
+
if (result === "prompt") {
|
|
645
215
|
state.turn = true;
|
|
646
216
|
cli.prompt();
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
if (inputTrimmed.startsWith("/prompts")) {
|
|
651
|
-
const prompts = await loadPrompts(claudeCodePlugins);
|
|
652
|
-
|
|
653
|
-
if (inputTrimmed === "/prompts") {
|
|
654
|
-
console.log(styleText("bold", "\nAvailable Prompts:"));
|
|
655
|
-
if (prompts.size === 0) {
|
|
656
|
-
console.log(" No prompts found.");
|
|
657
|
-
} else {
|
|
658
|
-
for (const prompt of prompts.values()) {
|
|
659
|
-
const maxLength = process.stdout.columns ?? 100;
|
|
660
|
-
const line = ` ${styleText("cyan", prompt.id.padEnd(20))} - ${prompt.description}`;
|
|
661
|
-
console.log(
|
|
662
|
-
line.length > maxLength ? `${line.slice(0, maxLength)}...` : line,
|
|
663
|
-
);
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
state.turn = true;
|
|
667
|
-
cli.prompt();
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (inputTrimmed.startsWith("/prompts:")) {
|
|
672
|
-
const match = inputTrimmed.match(/^\/prompts:([^ ]+)(?:\s+(.*))?$/s);
|
|
673
|
-
if (!match) {
|
|
674
|
-
console.log(styleText("red", "\nInvalid prompt invocation format."));
|
|
675
|
-
state.turn = true;
|
|
676
|
-
cli.prompt();
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
await invokePrompt(match[1], match[2] || "", `/prompts:${match[1]}`);
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
if (inputTrimmed.startsWith("/agents:")) {
|
|
685
|
-
const match = inputTrimmed.match(/^\/agents:([^ ]+)(?:\s+(.*))?$/s);
|
|
686
|
-
if (!match) {
|
|
687
|
-
console.log(styleText("red", "\nInvalid agent invocation format."));
|
|
688
|
-
state.turn = true;
|
|
689
|
-
cli.prompt();
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
await invokeAgent(match[1], match[2] || "");
|
|
693
|
-
return;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
if (inputTrimmed.startsWith("/paste")) {
|
|
697
|
-
const prompt = inputTrimmed.slice("/paste".length).trim();
|
|
698
|
-
let clipboard;
|
|
699
|
-
try {
|
|
700
|
-
if (process.platform === "darwin") {
|
|
701
|
-
clipboard = execFileSync("pbpaste", { encoding: "utf8" });
|
|
702
|
-
} else if (process.platform === "linux") {
|
|
703
|
-
clipboard = execFileSync("xsel", ["--clipboard", "--output"], {
|
|
704
|
-
encoding: "utf8",
|
|
705
|
-
});
|
|
706
|
-
} else {
|
|
707
|
-
console.log(
|
|
708
|
-
styleText(
|
|
709
|
-
"red",
|
|
710
|
-
`\nUnsupported platform for /paste: ${process.platform}`,
|
|
711
|
-
),
|
|
712
|
-
);
|
|
713
|
-
state.turn = true;
|
|
714
|
-
cli.prompt();
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
} catch (e) {
|
|
718
|
-
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
719
|
-
console.log(
|
|
720
|
-
styleText(
|
|
721
|
-
"red",
|
|
722
|
-
`\nFailed to get clipboard content: ${errorMessage}`,
|
|
723
|
-
),
|
|
724
|
-
);
|
|
725
|
-
state.turn = true;
|
|
726
|
-
cli.prompt();
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const combinedInput = prompt ? `${prompt}\n\n${clipboard}` : clipboard;
|
|
731
|
-
|
|
732
|
-
const messageWithContext = await loadUserMessageContext(combinedInput);
|
|
733
|
-
userEventEmitter.emit("userInput", messageWithContext);
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Handle shortcuts for prompts in shortcuts/ directory
|
|
738
|
-
if (inputTrimmed.startsWith("/")) {
|
|
739
|
-
const match = inputTrimmed.match(/^\/([^ ]+)(?:\s+(.*))?$/);
|
|
740
|
-
if (match) {
|
|
741
|
-
const id = match[1];
|
|
742
|
-
const prompts = await loadPrompts(claudeCodePlugins);
|
|
743
|
-
const prompt = prompts.get(id);
|
|
744
|
-
|
|
745
|
-
if (prompt?.isShortcut) {
|
|
746
|
-
await invokePrompt(id, match[2] || "", `/${id}`);
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
217
|
}
|
|
751
|
-
|
|
752
|
-
const messageWithContext = await loadUserMessageContext(inputTrimmed);
|
|
753
|
-
userEventEmitter.emit("userInput", messageWithContext);
|
|
754
218
|
}
|
|
755
219
|
|
|
756
220
|
cli.on("line", async (lineInput) => {
|
|
@@ -866,64 +330,3 @@ export function startInteractiveSession({
|
|
|
866
330
|
process.on("exit", cleanup);
|
|
867
331
|
process.on("SIGTERM", cleanup);
|
|
868
332
|
}
|
|
869
|
-
|
|
870
|
-
/**
|
|
871
|
-
* @param {Message} message
|
|
872
|
-
*/
|
|
873
|
-
function printMessage(message) {
|
|
874
|
-
switch (message.role) {
|
|
875
|
-
case "assistant": {
|
|
876
|
-
// console.log(styleText("bold", "\nAgent:"));
|
|
877
|
-
for (const part of message.content) {
|
|
878
|
-
switch (part.type) {
|
|
879
|
-
// Note: Streamで表示するためここでは表示しない
|
|
880
|
-
// case "thinking":
|
|
881
|
-
// console.log(
|
|
882
|
-
// [
|
|
883
|
-
// styleText("blue", "<thinking>"),
|
|
884
|
-
// part.thinking,
|
|
885
|
-
// styleText("blue", "</thinking>\n"),
|
|
886
|
-
// ].join("\n"),
|
|
887
|
-
// );
|
|
888
|
-
// break;
|
|
889
|
-
// case "text":
|
|
890
|
-
// console.log(part.text);
|
|
891
|
-
// break;
|
|
892
|
-
case "tool_use":
|
|
893
|
-
console.log(styleText("bold", "\nTool call:"));
|
|
894
|
-
console.log(formatToolUse(part));
|
|
895
|
-
break;
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
break;
|
|
899
|
-
}
|
|
900
|
-
case "user": {
|
|
901
|
-
for (const part of message.content) {
|
|
902
|
-
switch (part.type) {
|
|
903
|
-
case "tool_result": {
|
|
904
|
-
console.log(styleText("bold", "\nTool result:"));
|
|
905
|
-
console.log(formatToolResult(part));
|
|
906
|
-
break;
|
|
907
|
-
}
|
|
908
|
-
case "text": {
|
|
909
|
-
console.log(styleText("bold", "\nUser:"));
|
|
910
|
-
console.log(part.text);
|
|
911
|
-
break;
|
|
912
|
-
}
|
|
913
|
-
case "image": {
|
|
914
|
-
break;
|
|
915
|
-
}
|
|
916
|
-
default: {
|
|
917
|
-
console.log(styleText("bold", "\nUnknown Message Format:"));
|
|
918
|
-
console.log(JSON.stringify(part, null, 2));
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
break;
|
|
923
|
-
}
|
|
924
|
-
default: {
|
|
925
|
-
console.log(styleText("bold", "\nUnknown Message Format:"));
|
|
926
|
-
console.log(JSON.stringify(message, null, 2));
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|