@iinm/plain-agent 1.5.4 → 1.6.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/package.json +1 -1
- package/src/cliInteractive.mjs +200 -22
package/package.json
CHANGED
package/src/cliInteractive.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import readline from "node:readline";
|
|
9
|
+
import { Transform } from "node:stream";
|
|
9
10
|
import { styleText } from "node:util";
|
|
10
11
|
import {
|
|
11
12
|
formatCostSummary,
|
|
@@ -189,6 +190,57 @@ const HELP_MESSAGE = [
|
|
|
189
190
|
.replace(/^ {2}\/.+?(?= - )/gm, (m) => styleText("cyan", m))
|
|
190
191
|
.replace(/^ {2}.+?(?= - )/gm, (m) => styleText("blue", m));
|
|
191
192
|
|
|
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
|
+
|
|
192
244
|
/**
|
|
193
245
|
* @typedef {object} CliOptions
|
|
194
246
|
* @property {UserEventEmitter} userEventEmitter
|
|
@@ -232,9 +284,16 @@ export function startInteractiveSession({
|
|
|
232
284
|
const agentRoles = await loadAgentRoles(claudeCodePlugins);
|
|
233
285
|
const agent = agentRoles.get(id);
|
|
234
286
|
const name = agent ? id : `custom:${id}`;
|
|
235
|
-
const message = `Delegate to "${name}" agent with goal: ${goal}`;
|
|
236
287
|
|
|
237
|
-
|
|
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
|
+
]);
|
|
238
297
|
}
|
|
239
298
|
|
|
240
299
|
/**
|
|
@@ -254,12 +313,21 @@ export function startInteractiveSession({
|
|
|
254
313
|
return;
|
|
255
314
|
}
|
|
256
315
|
|
|
257
|
-
const
|
|
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}` : ""}`;
|
|
258
323
|
const message = prompt.isSkill
|
|
259
324
|
? `System: This prompt was invoked as "${invocation}".\nPrompt path: ${prompt.filePath}\n\n${prompt.content}`
|
|
260
325
|
: `System: This prompt was invoked as "${invocation}".\n\n${prompt.content}`;
|
|
261
326
|
|
|
262
|
-
userEventEmitter.emit("userInput", [
|
|
327
|
+
userEventEmitter.emit("userInput", [
|
|
328
|
+
{ type: "text", text: message },
|
|
329
|
+
...argsImages,
|
|
330
|
+
]);
|
|
263
331
|
}
|
|
264
332
|
|
|
265
333
|
const getCliPrompt = (subagentName = "") =>
|
|
@@ -275,9 +343,89 @@ export function startInteractiveSession({
|
|
|
275
343
|
"> ",
|
|
276
344
|
].join("\n");
|
|
277
345
|
|
|
346
|
+
// Indirect reference for exit handler (assigned after confirmExit is defined)
|
|
347
|
+
let onExitRequest = () => {};
|
|
348
|
+
|
|
349
|
+
// Create a transform stream to handle bracketed paste before readline
|
|
350
|
+
let inPasteMode = false;
|
|
351
|
+
let pasteBuffer = "";
|
|
352
|
+
|
|
353
|
+
const pasteTransform = new Transform({
|
|
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
|
+
}
|
|
364
|
+
|
|
365
|
+
while (data.length > 0) {
|
|
366
|
+
if (inPasteMode) {
|
|
367
|
+
const endIdx = data.indexOf(BRACKETED_PASTE_END);
|
|
368
|
+
if (endIdx !== -1) {
|
|
369
|
+
// End of paste
|
|
370
|
+
pasteBuffer += data.slice(0, endIdx);
|
|
371
|
+
data = data.slice(endIdx + BRACKETED_PASTE_END.length);
|
|
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
|
+
}
|
|
413
|
+
|
|
414
|
+
callback();
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Set up transformed stdin for readline
|
|
419
|
+
process.stdin.pipe(pasteTransform);
|
|
420
|
+
|
|
421
|
+
// Enable bracketed paste mode
|
|
422
|
+
if (process.stdout.isTTY) {
|
|
423
|
+
process.stdout.write("\x1b[?2004h");
|
|
424
|
+
}
|
|
425
|
+
|
|
278
426
|
let currentCliPrompt = getCliPrompt();
|
|
279
427
|
const cli = readline.createInterface({
|
|
280
|
-
input:
|
|
428
|
+
input: pasteTransform,
|
|
281
429
|
output: process.stdout,
|
|
282
430
|
prompt: currentCliPrompt,
|
|
283
431
|
/**
|
|
@@ -364,22 +512,46 @@ export function startInteractiveSession({
|
|
|
364
512
|
if (process.stdin.isTTY) {
|
|
365
513
|
process.stdin.setRawMode(true);
|
|
366
514
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
console.log();
|
|
372
|
-
console.log(formatCostSummary(summary));
|
|
373
|
-
await onStop();
|
|
515
|
+
// Cleanup handler to disable bracketed paste mode on exit
|
|
516
|
+
const cleanup = () => {
|
|
517
|
+
if (process.stdout.isTTY) {
|
|
518
|
+
process.stdout.write("\x1b[?2004l");
|
|
374
519
|
}
|
|
520
|
+
};
|
|
375
521
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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;
|
|
381
545
|
}
|
|
382
|
-
|
|
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
|
+
|
|
553
|
+
// Handle readline close (e.g., stdin closed externally)
|
|
554
|
+
cli.on("close", handleExit);
|
|
383
555
|
|
|
384
556
|
/**
|
|
385
557
|
* Process the complete user input.
|
|
@@ -390,7 +562,9 @@ export function startInteractiveSession({
|
|
|
390
562
|
// Prevent concurrent input processing from multi-line paste
|
|
391
563
|
state.turn = false;
|
|
392
564
|
|
|
393
|
-
|
|
565
|
+
// Resolve paste placeholders to original content
|
|
566
|
+
const resolvedInput = resolvePastePlaceholders(input);
|
|
567
|
+
const inputTrimmed = resolvedInput.trim();
|
|
394
568
|
|
|
395
569
|
if (inputTrimmed.length === 0) {
|
|
396
570
|
state.turn = true;
|
|
@@ -495,7 +669,7 @@ export function startInteractiveSession({
|
|
|
495
669
|
}
|
|
496
670
|
|
|
497
671
|
if (inputTrimmed.startsWith("/prompts:")) {
|
|
498
|
-
const match = inputTrimmed.match(/^\/prompts:([^ ]+)(?:\s+(.*))?$/);
|
|
672
|
+
const match = inputTrimmed.match(/^\/prompts:([^ ]+)(?:\s+(.*))?$/s);
|
|
499
673
|
if (!match) {
|
|
500
674
|
console.log(styleText("red", "\nInvalid prompt invocation format."));
|
|
501
675
|
state.turn = true;
|
|
@@ -508,7 +682,7 @@ export function startInteractiveSession({
|
|
|
508
682
|
}
|
|
509
683
|
|
|
510
684
|
if (inputTrimmed.startsWith("/agents:")) {
|
|
511
|
-
const match = inputTrimmed.match(/^\/agents:([^ ]+)(?:\s+(.*))?$/);
|
|
685
|
+
const match = inputTrimmed.match(/^\/agents:([^ ]+)(?:\s+(.*))?$/s);
|
|
512
686
|
if (!match) {
|
|
513
687
|
console.log(styleText("red", "\nInvalid agent invocation format."));
|
|
514
688
|
state.turn = true;
|
|
@@ -590,7 +764,7 @@ export function startInteractiveSession({
|
|
|
590
764
|
return;
|
|
591
765
|
}
|
|
592
766
|
|
|
593
|
-
//
|
|
767
|
+
// Check for multi-line delimiter
|
|
594
768
|
if (lineInput.trim() === '"""') {
|
|
595
769
|
if (state.multiLineBuffer === null) {
|
|
596
770
|
state.multiLineBuffer = [];
|
|
@@ -687,6 +861,10 @@ export function startInteractiveSession({
|
|
|
687
861
|
});
|
|
688
862
|
|
|
689
863
|
cli.prompt();
|
|
864
|
+
|
|
865
|
+
// Register cleanup handlers
|
|
866
|
+
process.on("exit", cleanup);
|
|
867
|
+
process.on("SIGTERM", cleanup);
|
|
690
868
|
}
|
|
691
869
|
|
|
692
870
|
/**
|