@iinm/plain-agent 1.5.4 → 1.6.1

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,163 +1,23 @@
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
7
  import { styleText } from "node:util";
8
+ import { createCommandHandler } from "./cliCommands.mjs";
9
+ import { createCompleter, SLASH_COMMANDS } from "./cliCompleter.mjs";
10
10
  import {
11
11
  formatCostSummary,
12
12
  formatProviderTokenUsage,
13
- formatToolResult,
14
- formatToolUse,
13
+ printMessage,
15
14
  } from "./cliFormatter.mjs";
15
+ import {
16
+ createPasteTransform,
17
+ resolvePastePlaceholders,
18
+ } from "./cliPasteTransform.mjs";
16
19
  import { consumeInterruptMessage } from "./context/consumeInterruptMessage.mjs";
17
- import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
18
- import { loadPrompts } from "./context/loadPrompts.mjs";
19
- import { loadUserMessageContext } from "./context/loadUserMessageContext.mjs";
20
20
  import { notify } from "./utils/notify.mjs";
21
- import { parseFileRange } from "./utils/parseFileRange.mjs";
22
- import { readFileRange } from "./utils/readFileRange.mjs";
23
-
24
- // Define available slash commands for tab completion
25
- const SLASH_COMMANDS = [
26
- { name: "/help", description: "Display this help message" },
27
- { name: "/agents", description: "List available agent roles" },
28
- {
29
- name: "/agents:<id>",
30
- description:
31
- "Delegate to an agent with the given ID (e.g., /agents:code-simplifier)",
32
- },
33
- { name: "/prompts", description: "List available prompts" },
34
- {
35
- name: "/prompts:<id>",
36
- description:
37
- "Invoke a prompt with the given ID (e.g., /prompts:feature-dev)",
38
- },
39
- {
40
- name: "/<id>",
41
- description:
42
- "Shortcut for prompts in the shortcuts/ directory (e.g., /commit)",
43
- },
44
- { name: "/paste", description: "Paste content from clipboard" },
45
- {
46
- name: "/resume",
47
- description: "Resume conversation after an LLM provider error",
48
- },
49
- { name: "/dump", description: "Save current messages to a JSON file" },
50
- { name: "/load", description: "Load messages from a JSON file" },
51
- { name: "/cost", description: "Display session cost and token usage" },
52
- ];
53
-
54
- /**
55
- * @typedef {Object} CompletionCandidate
56
- * @property {string} name
57
- * @property {string} description
58
- */
59
-
60
- /**
61
- * Find candidates that match the line, prioritizing prefix matches.
62
- * @param {(string | CompletionCandidate)[]} candidates
63
- * @param {string} line
64
- * @param {number} queryStartIndex
65
- * @returns {(string | CompletionCandidate)[]}
66
- */
67
- function findMatches(candidates, line, queryStartIndex) {
68
- const query = line.slice(queryStartIndex);
69
- const prefixMatches = [];
70
- const partialMatches = [];
71
-
72
- for (const candidate of candidates) {
73
- const name = typeof candidate === "string" ? candidate : candidate.name;
74
- if (name.startsWith(line)) {
75
- prefixMatches.push(candidate);
76
- } else if (
77
- query.length > 0 &&
78
- name.slice(queryStartIndex).includes(query)
79
- ) {
80
- partialMatches.push(candidate);
81
- }
82
- }
83
-
84
- return [...prefixMatches, ...partialMatches];
85
- }
86
-
87
- /**
88
- * Return the longest common prefix of the given strings.
89
- * @param {string[]} strings
90
- * @returns {string}
91
- */
92
- function commonPrefix(strings) {
93
- if (strings.length === 0) return "";
94
- let prefix = strings[0];
95
- for (let i = 1; i < strings.length; i++) {
96
- while (!strings[i].startsWith(prefix)) {
97
- prefix = prefix.slice(0, -1);
98
- }
99
- }
100
- return prefix;
101
- }
102
-
103
- /**
104
- * Display completion candidates and invoke the readline callback.
105
- *
106
- * Node.js readline normally requires two consecutive Tab presses to show the
107
- * candidate list. This helper lets readline handle the common-prefix
108
- * auto-completion first, then prints the candidate list on the next tick and
109
- * redraws the prompt so the display stays clean.
110
- *
111
- * @param {import("node:readline").Interface} rl
112
- * @param {(string | CompletionCandidate)[]} candidates
113
- * @param {string} line
114
- * @param {(err: Error | null, result: [string[], string]) => void} callback
115
- */
116
- function showCompletions(rl, candidates, line, callback) {
117
- const names = candidates.map((c) => (typeof c === "string" ? c : c.name));
118
- if (candidates.length <= 1) {
119
- callback(null, [names, line]);
120
- return;
121
- }
122
- const prefix = commonPrefix(names);
123
- if (prefix.length > line.length) {
124
- // Let readline insert the common prefix.
125
- callback(null, [[prefix], line]);
126
- } else {
127
- // Nothing new to insert.
128
- callback(null, [[], line]);
129
- }
130
- // After readline finishes its own refresh, print the candidate list and
131
- // redraw the prompt line. We cannot use rl.prompt(true) because its
132
- // internal _refreshLine clears everything below the prompt start, which
133
- // erases the candidate list we just wrote. Instead we manually re-output
134
- // the prompt and current line content.
135
- setTimeout(() => {
136
- const maxLength = process.stdout.columns ?? 100;
137
- const list = candidates
138
- .map((c) => {
139
- if (typeof c === "string") return c;
140
- const nameText = c.name.padEnd(25);
141
- const separator = " - ";
142
- const descText = c.description;
143
-
144
- // 画面幅に合わせて説明文をカット(色を付ける前に計算)
145
- const availableWidth =
146
- maxLength - nameText.length - separator.length - 3;
147
- const displayDesc =
148
- descText.length > availableWidth && availableWidth > 0
149
- ? `${descText.slice(0, availableWidth)}...`
150
- : descText;
151
-
152
- const name = styleText("cyan", nameText);
153
- const description = styleText("dim", displayDesc);
154
- return `${name}${separator}${description}`;
155
- })
156
- .join("\r\n");
157
- process.stdout.write(`\r\n${list}\r\n`);
158
- process.stdout.write(`${rl.getPrompt()}${rl.line}`);
159
- }, 0);
160
- }
161
21
 
162
22
  const HELP_MESSAGE = [
163
23
  "Commands:",
@@ -223,45 +83,6 @@ export function startInteractiveSession({
223
83
  subagentName: "",
224
84
  };
225
85
 
226
- /**
227
- * @param {string} id
228
- * @param {string} goal
229
- * @returns {Promise<void>}
230
- */
231
- async function invokeAgent(id, goal) {
232
- const agentRoles = await loadAgentRoles(claudeCodePlugins);
233
- const agent = agentRoles.get(id);
234
- const name = agent ? id : `custom:${id}`;
235
- const message = `Delegate to "${name}" agent with goal: ${goal}`;
236
-
237
- userEventEmitter.emit("userInput", [{ type: "text", text: message }]);
238
- }
239
-
240
- /**
241
- * @param {string} id
242
- * @param {string} args
243
- * @param {string} displayInvocation
244
- * @returns {Promise<void>}
245
- */
246
- async function invokePrompt(id, args, displayInvocation) {
247
- const prompts = await loadPrompts(claudeCodePlugins);
248
- const prompt = prompts.get(id);
249
-
250
- if (!prompt) {
251
- console.log(styleText("red", `\nPrompt not found: ${id}`));
252
- state.turn = true;
253
- cli.prompt();
254
- return;
255
- }
256
-
257
- const invocation = `${displayInvocation}${args ? ` ${args}` : ""}`;
258
- const message = prompt.isSkill
259
- ? `System: This prompt was invoked as "${invocation}".\nPrompt path: ${prompt.filePath}\n\n${prompt.content}`
260
- : `System: This prompt was invoked as "${invocation}".\n\n${prompt.content}`;
261
-
262
- userEventEmitter.emit("userInput", [{ type: "text", text: message }]);
263
- }
264
-
265
86
  const getCliPrompt = (subagentName = "") =>
266
87
  [
267
88
  "",
@@ -275,77 +96,59 @@ export function startInteractiveSession({
275
96
  "> ",
276
97
  ].join("\n");
277
98
 
99
+ // Cleanup handler to disable bracketed paste mode on exit
100
+ const cleanup = () => {
101
+ if (process.stdout.isTTY) {
102
+ process.stdout.write("\x1b[?2004l");
103
+ }
104
+ };
105
+
106
+ // Handle exit signals
107
+ let isExiting = false;
108
+ const handleExit = async () => {
109
+ if (isExiting) return;
110
+ isExiting = true;
111
+
112
+ cleanup();
113
+ const summary = agentCommands.getCostSummary();
114
+ console.log();
115
+ console.log(formatCostSummary(summary));
116
+ await onStop();
117
+ process.exit(0);
118
+ };
119
+
120
+ // Double-press exit confirmation
121
+ let lastExitAttempt = 0;
122
+ const EXIT_CONFIRM_TIMEOUT = 1500;
123
+
124
+ const confirmExit = () => {
125
+ const now = Date.now();
126
+ if (now - lastExitAttempt < EXIT_CONFIRM_TIMEOUT) {
127
+ handleExit();
128
+ return;
129
+ }
130
+ lastExitAttempt = now;
131
+ console.log(styleText("yellow", "\nPress Ctrl-C or Ctrl-D again to exit."));
132
+ };
133
+
134
+ // Create a transform stream to handle bracketed paste before readline
135
+ const pasteTransform = createPasteTransform(confirmExit);
136
+
137
+ // Set up transformed stdin for readline
138
+ process.stdin.pipe(pasteTransform);
139
+
140
+ // Enable bracketed paste mode
141
+ if (process.stdout.isTTY) {
142
+ process.stdout.write("\x1b[?2004h");
143
+ }
144
+
278
145
  let currentCliPrompt = getCliPrompt();
146
+ /** @type {import("node:readline").Interface} */
279
147
  const cli = readline.createInterface({
280
- input: process.stdin,
148
+ input: pasteTransform,
281
149
  output: process.stdout,
282
150
  prompt: currentCliPrompt,
283
- /**
284
- * @param {string} line
285
- * @param {(err?: Error | null, result?: [string[], string]) => void} callback
286
- */
287
- completer: (line, callback) => {
288
- (async () => {
289
- try {
290
- const prompts = await loadPrompts(claudeCodePlugins);
291
- const agentRoles = await loadAgentRoles(claudeCodePlugins);
292
-
293
- if (line.startsWith("/agents:")) {
294
- const prefix = "/agents:";
295
- const candidates = Array.from(agentRoles.values()).map((a) => ({
296
- name: `${prefix}${a.id}`,
297
- description: a.description,
298
- }));
299
- const hits = findMatches(candidates, line, prefix.length);
300
-
301
- showCompletions(cli, hits, line, callback);
302
- return;
303
- }
304
-
305
- if (line.startsWith("/prompts:")) {
306
- const prefix = "/prompts:";
307
- const candidates = Array.from(prompts.values()).map((p) => ({
308
- name: `${prefix}${p.id}`,
309
- description: p.description,
310
- }));
311
- const hits = findMatches(candidates, line, prefix.length);
312
-
313
- showCompletions(cli, hits, line, callback);
314
- return;
315
- }
316
-
317
- if (line.startsWith("/")) {
318
- const shortcuts = Array.from(prompts.values())
319
- .filter((p) => p.isShortcut)
320
- .map((p) => ({
321
- name: `/${p.id}`,
322
- description: p.description,
323
- }));
324
-
325
- const allCommands = [...SLASH_COMMANDS, ...shortcuts].filter(
326
- (cmd) => {
327
- const name = typeof cmd === "string" ? cmd : cmd.name;
328
- return (
329
- name !== "/<id>" &&
330
- (name === "/agents:" || !name.startsWith("/agent:")) &&
331
- (name === "/prompts:" || !name.startsWith("/prompt:"))
332
- );
333
- },
334
- );
335
-
336
- const hits = findMatches(allCommands, line, 1);
337
-
338
- showCompletions(cli, hits, line, callback);
339
- return;
340
- }
341
-
342
- callback(null, [[], line]);
343
- } catch (err) {
344
- const error = err instanceof Error ? err : new Error(String(err));
345
- callback(error, [[], line]);
346
- }
347
- })();
348
- },
151
+ completer: createCompleter(() => cli, claudeCodePlugins),
349
152
  });
350
153
 
351
154
  // Disable automatic prompt redraw on resize during agent turn
@@ -365,20 +168,14 @@ export function startInteractiveSession({
365
168
  process.stdin.setRawMode(true);
366
169
  }
367
170
 
368
- process.stdin.on("keypress", async (_, key) => {
369
- if (key.ctrl && key.name === "c") {
370
- const summary = agentCommands.getCostSummary();
371
- console.log();
372
- console.log(formatCostSummary(summary));
373
- await onStop();
374
- }
171
+ // Handle readline close (e.g., stdin closed externally)
172
+ cli.on("close", handleExit);
375
173
 
376
- if (key.ctrl && key.name === "d") {
377
- const summary = agentCommands.getCostSummary();
378
- console.log();
379
- console.log(formatCostSummary(summary));
380
- await onStop();
381
- }
174
+ const handleCommand = createCommandHandler({
175
+ agentCommands,
176
+ userEventEmitter,
177
+ claudeCodePlugins,
178
+ helpMessage: HELP_MESSAGE,
382
179
  });
383
180
 
384
181
  /**
@@ -390,7 +187,9 @@ export function startInteractiveSession({
390
187
  // Prevent concurrent input processing from multi-line paste
391
188
  state.turn = false;
392
189
 
393
- const inputTrimmed = input.trim();
190
+ // Resolve paste placeholders to original content
191
+ const resolvedInput = resolvePastePlaceholders(input);
192
+ const inputTrimmed = resolvedInput.trim();
394
193
 
395
194
  if (inputTrimmed.length === 0) {
396
195
  state.turn = true;
@@ -401,182 +200,11 @@ export function startInteractiveSession({
401
200
  cli.setPrompt(currentCliPrompt);
402
201
  await consumeInterruptMessage();
403
202
 
404
- if (["/help", "help"].includes(inputTrimmed.toLowerCase())) {
405
- console.log(`\n${HELP_MESSAGE}`);
406
- state.turn = true;
407
- cli.prompt();
408
- return;
409
- }
410
-
411
- if (inputTrimmed.startsWith("!")) {
412
- const fileRange = parseFileRange(inputTrimmed.slice(1));
413
- if (fileRange instanceof Error) {
414
- console.log(styleText("red", `\n${fileRange.message}`));
415
- state.turn = true;
416
- cli.prompt();
417
- return;
418
- }
419
-
420
- const fileContent = await readFileRange(fileRange);
421
- if (fileContent instanceof Error) {
422
- console.log(styleText("red", `\n${fileContent.message}`));
423
- state.turn = true;
424
- cli.prompt();
425
- return;
426
- }
427
-
428
- const messageWithContext = await loadUserMessageContext(fileContent);
429
-
430
- userEventEmitter.emit("userInput", messageWithContext);
431
- return;
432
- }
433
-
434
- if (inputTrimmed.toLowerCase() === "/dump") {
435
- await agentCommands.dumpMessages();
436
- state.turn = true;
437
- cli.prompt();
438
- return;
439
- }
440
-
441
- if (inputTrimmed.toLowerCase() === "/load") {
442
- await agentCommands.loadMessages();
443
- state.turn = true;
444
- cli.prompt();
445
- return;
446
- }
447
-
448
- if (inputTrimmed.toLowerCase() === "/cost") {
449
- const summary = agentCommands.getCostSummary();
450
- console.log(formatCostSummary(summary));
451
- state.turn = true;
452
- cli.prompt();
453
- return;
454
- }
455
-
456
- if (inputTrimmed === "/agents") {
457
- const agentRoles = await loadAgentRoles(claudeCodePlugins);
458
-
459
- console.log(styleText("bold", "\nAvailable Agent Roles:"));
460
- if (agentRoles.size === 0) {
461
- console.log(" No agent roles found.");
462
- } else {
463
- for (const role of agentRoles.values()) {
464
- const maxLength = process.stdout.columns ?? 100;
465
- const line = ` ${styleText("cyan", role.id.padEnd(20))} - ${role.description}`;
466
- console.log(
467
- line.length > maxLength ? `${line.slice(0, maxLength)}...` : line,
468
- );
469
- }
470
- }
203
+ const result = await handleCommand(inputTrimmed);
204
+ if (result === "prompt") {
471
205
  state.turn = true;
472
206
  cli.prompt();
473
- return;
474
- }
475
-
476
- if (inputTrimmed.startsWith("/prompts")) {
477
- const prompts = await loadPrompts(claudeCodePlugins);
478
-
479
- if (inputTrimmed === "/prompts") {
480
- console.log(styleText("bold", "\nAvailable Prompts:"));
481
- if (prompts.size === 0) {
482
- console.log(" No prompts found.");
483
- } else {
484
- for (const prompt of prompts.values()) {
485
- const maxLength = process.stdout.columns ?? 100;
486
- const line = ` ${styleText("cyan", prompt.id.padEnd(20))} - ${prompt.description}`;
487
- console.log(
488
- line.length > maxLength ? `${line.slice(0, maxLength)}...` : line,
489
- );
490
- }
491
- }
492
- state.turn = true;
493
- cli.prompt();
494
- return;
495
- }
496
-
497
- if (inputTrimmed.startsWith("/prompts:")) {
498
- const match = inputTrimmed.match(/^\/prompts:([^ ]+)(?:\s+(.*))?$/);
499
- if (!match) {
500
- console.log(styleText("red", "\nInvalid prompt invocation format."));
501
- state.turn = true;
502
- cli.prompt();
503
- return;
504
- }
505
- await invokePrompt(match[1], match[2] || "", `/prompts:${match[1]}`);
506
- return;
507
- }
508
- }
509
-
510
- if (inputTrimmed.startsWith("/agents:")) {
511
- const match = inputTrimmed.match(/^\/agents:([^ ]+)(?:\s+(.*))?$/);
512
- if (!match) {
513
- console.log(styleText("red", "\nInvalid agent invocation format."));
514
- state.turn = true;
515
- cli.prompt();
516
- return;
517
- }
518
- await invokeAgent(match[1], match[2] || "");
519
- return;
520
- }
521
-
522
- if (inputTrimmed.startsWith("/paste")) {
523
- const prompt = inputTrimmed.slice("/paste".length).trim();
524
- let clipboard;
525
- try {
526
- if (process.platform === "darwin") {
527
- clipboard = execFileSync("pbpaste", { encoding: "utf8" });
528
- } else if (process.platform === "linux") {
529
- clipboard = execFileSync("xsel", ["--clipboard", "--output"], {
530
- encoding: "utf8",
531
- });
532
- } else {
533
- console.log(
534
- styleText(
535
- "red",
536
- `\nUnsupported platform for /paste: ${process.platform}`,
537
- ),
538
- );
539
- state.turn = true;
540
- cli.prompt();
541
- return;
542
- }
543
- } catch (e) {
544
- const errorMessage = e instanceof Error ? e.message : String(e);
545
- console.log(
546
- styleText(
547
- "red",
548
- `\nFailed to get clipboard content: ${errorMessage}`,
549
- ),
550
- );
551
- state.turn = true;
552
- cli.prompt();
553
- return;
554
- }
555
-
556
- const combinedInput = prompt ? `${prompt}\n\n${clipboard}` : clipboard;
557
-
558
- const messageWithContext = await loadUserMessageContext(combinedInput);
559
- userEventEmitter.emit("userInput", messageWithContext);
560
- return;
561
- }
562
-
563
- // Handle shortcuts for prompts in shortcuts/ directory
564
- if (inputTrimmed.startsWith("/")) {
565
- const match = inputTrimmed.match(/^\/([^ ]+)(?:\s+(.*))?$/);
566
- if (match) {
567
- const id = match[1];
568
- const prompts = await loadPrompts(claudeCodePlugins);
569
- const prompt = prompts.get(id);
570
-
571
- if (prompt?.isShortcut) {
572
- await invokePrompt(id, match[2] || "", `/${id}`);
573
- return;
574
- }
575
- }
576
207
  }
577
-
578
- const messageWithContext = await loadUserMessageContext(inputTrimmed);
579
- userEventEmitter.emit("userInput", messageWithContext);
580
208
  }
581
209
 
582
210
  cli.on("line", async (lineInput) => {
@@ -590,7 +218,7 @@ export function startInteractiveSession({
590
218
  return;
591
219
  }
592
220
 
593
- // Handle multi-line delimiter
221
+ // Check for multi-line delimiter
594
222
  if (lineInput.trim() === '"""') {
595
223
  if (state.multiLineBuffer === null) {
596
224
  state.multiLineBuffer = [];
@@ -687,65 +315,8 @@ export function startInteractiveSession({
687
315
  });
688
316
 
689
317
  cli.prompt();
690
- }
691
318
 
692
- /**
693
- * @param {Message} message
694
- */
695
- function printMessage(message) {
696
- switch (message.role) {
697
- case "assistant": {
698
- // console.log(styleText("bold", "\nAgent:"));
699
- for (const part of message.content) {
700
- switch (part.type) {
701
- // Note: Streamで表示するためここでは表示しない
702
- // case "thinking":
703
- // console.log(
704
- // [
705
- // styleText("blue", "<thinking>"),
706
- // part.thinking,
707
- // styleText("blue", "</thinking>\n"),
708
- // ].join("\n"),
709
- // );
710
- // break;
711
- // case "text":
712
- // console.log(part.text);
713
- // break;
714
- case "tool_use":
715
- console.log(styleText("bold", "\nTool call:"));
716
- console.log(formatToolUse(part));
717
- break;
718
- }
719
- }
720
- break;
721
- }
722
- case "user": {
723
- for (const part of message.content) {
724
- switch (part.type) {
725
- case "tool_result": {
726
- console.log(styleText("bold", "\nTool result:"));
727
- console.log(formatToolResult(part));
728
- break;
729
- }
730
- case "text": {
731
- console.log(styleText("bold", "\nUser:"));
732
- console.log(part.text);
733
- break;
734
- }
735
- case "image": {
736
- break;
737
- }
738
- default: {
739
- console.log(styleText("bold", "\nUnknown Message Format:"));
740
- console.log(JSON.stringify(part, null, 2));
741
- }
742
- }
743
- }
744
- break;
745
- }
746
- default: {
747
- console.log(styleText("bold", "\nUnknown Message Format:"));
748
- console.log(JSON.stringify(message, null, 2));
749
- }
750
- }
319
+ // Register cleanup handlers
320
+ process.on("exit", cleanup);
321
+ process.on("SIGTERM", cleanup);
751
322
  }