@iinm/plain-agent 1.10.5 → 1.10.7

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/README.md CHANGED
@@ -381,7 +381,7 @@ Files are loaded in the following order. Settings in later files override earlie
381
381
  "args": ["ci"]
382
382
  },
383
383
  "mode": "sandbox",
384
- "extraArgs": ["--allow-net", "0.0.0.0/0"]
384
+ "additionalArgs": ["--allow-net", "0.0.0.0/0"]
385
385
  }
386
386
  ]
387
387
  }
package/bin/plain CHANGED
@@ -1,3 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import("../src/main.mjs");
3
+ import { main } from "../src/main.mjs";
4
+
5
+ main().catch((err) => {
6
+ console.error(err);
7
+ process.exit(1);
8
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.10.5",
3
+ "version": "1.10.7",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "scripts": {
32
32
  "check": "npm run lint && tsc && npm run test",
33
33
  "test": "node --test",
34
+ "coverage": "node --experimental-test-coverage --test",
34
35
  "lint": "npx @biomejs/biome check",
35
36
  "fix": "npx @biomejs/biome check --fix"
36
37
  },
@@ -428,6 +428,21 @@ export async function printMessage(message) {
428
428
  }
429
429
  }
430
430
  }
431
+ /**
432
+ * Convert **bold** Markdown to ANSI bold terminal escape codes.
433
+ * Only matches when ** is preceded by whitespace or line start
434
+ * and followed by whitespace, line end, or punctuation — so inline
435
+ * code like `**bold**` is left untouched.
436
+ * @param {string} text
437
+ * @returns {string}
438
+ */
439
+ export function applyInlineMarkdown(text) {
440
+ return text.replace(
441
+ /(?<=\s|^)\*\*(.+?)\*\*(?=[\s.,;:!?)〕)】」』]|$)/g,
442
+ (_, c) => styleText("bold", c),
443
+ );
444
+ }
445
+
431
446
  /**
432
447
  * Format markdown table lines with aligned columns.
433
448
  * Input lines may have leading/trailing pipes.
@@ -22,7 +22,7 @@ import {
22
22
  import { createInterruptTransform } from "./interruptTransform.mjs";
23
23
  import { createMuteTransform } from "./muteTransform.mjs";
24
24
  import { createPasteHandler } from "./pasteTransform.mjs";
25
- import { createTableDetector } from "./tableDetector.mjs";
25
+ import { createStreamFormatter } from "./streamFormatter.mjs";
26
26
 
27
27
  const HELP_MESSAGE = [
28
28
  "Commands:",
@@ -130,8 +130,8 @@ export function startInteractiveSession({
130
130
  */
131
131
  let voice = null;
132
132
 
133
- // Create the table buffer instance for this session
134
- const tableBuffer = createTableBuffer();
133
+ // Create the stream buffer instance for this session
134
+ const streamBuffer = createStreamBuffer();
135
135
 
136
136
  // Parse the voice toggle key once at startup so misconfiguration fails
137
137
  // loudly instead of silently falling back.
@@ -494,7 +494,7 @@ export function startInteractiveSession({
494
494
  );
495
495
  }
496
496
  } else if (partialContent.type === "text") {
497
- tableBuffer.feed(partialContent.content);
497
+ streamBuffer.feed(partialContent.content);
498
498
  } else {
499
499
  process.stdout.write(partialContent.content);
500
500
  }
@@ -502,9 +502,11 @@ export function startInteractiveSession({
502
502
  if (partialContent.position === "stop") {
503
503
  if (partialContent.type === "tool_use") {
504
504
  process.stdout.write(
505
- `\r\x1b[K${styleText("gray", `<${partialContent.type}>`)} ${styleText("green", "✓")}\n`,
505
+ `\r\x1b[K${styleText("gray", `<${partialContent.type}>`)}\n`,
506
506
  );
507
507
  } else {
508
+ // Flush any buffered text before printing the closing tag
509
+ streamBuffer.forceFlush();
508
510
  console.log(styleText("gray", `\n</${partialContent.type}>`));
509
511
  }
510
512
  }
@@ -556,8 +558,8 @@ export function startInteractiveSession({
556
558
  });
557
559
 
558
560
  agentEventEmitter.on("turnEnd", async () => {
559
- // Flush any remaining table buffer content
560
- tableBuffer.forceFlush();
561
+ // Flush any remaining stream buffer content
562
+ streamBuffer.forceFlush();
561
563
 
562
564
  const err = notify(notifyCmd);
563
565
  if (err) {
@@ -584,21 +586,20 @@ export function startInteractiveSession({
584
586
  }
585
587
 
586
588
  /**
587
- * Creates a table buffer for detecting and formatting markdown tables
588
- * in streaming text output.
589
- * Thin shell: delegates pure logic to createTableDetector and handles I/O.
589
+ * Creates a stream buffer for formatting streaming text output.
590
+ * Thin shell: delegates pure logic to createStreamFormatter and handles I/O.
590
591
  */
591
- function createTableBuffer() {
592
- const detector = createTableDetector();
592
+ function createStreamBuffer() {
593
+ const formatter = createStreamFormatter();
593
594
 
594
595
  function feed(/** @type {string} */ chunk) {
595
- const { output, warnings } = detector.feed(chunk);
596
+ const { output, warnings } = formatter.feed(chunk);
596
597
  for (const s of output) process.stdout.write(s);
597
598
  for (const w of warnings) console.error(styleText("yellow", w));
598
599
  }
599
600
 
600
601
  function forceFlush() {
601
- const { output, warnings } = detector.forceFlush();
602
+ const { output, warnings } = formatter.forceFlush();
602
603
  for (const s of output) process.stdout.write(s);
603
604
  for (const w of warnings) console.error(styleText("yellow", w));
604
605
  }
@@ -1,18 +1,24 @@
1
- import { formatMarkdownTable } from "./formatter.mjs";
1
+ import { applyInlineMarkdown, formatMarkdownTable } from "./formatter.mjs";
2
2
 
3
3
  /**
4
- * @typedef {{ output: string[], warnings: string[] }} DetectorResult
4
+ * @typedef {{ output: string[], warnings: string[] }} StreamFormatterResult
5
5
  */
6
6
 
7
7
  /**
8
- * Creates a table detector for detecting and formatting markdown tables
9
- * in streaming text output. This is a pure logic module with no I/O side effects.
8
+ * Creates a stream formatter for formatting streaming text output
9
+ * in a terminal. Applies **bold** Markdown styling
10
+ * on completed lines, and detects + formats markdown tables.
11
+ * This is a pure logic module with no I/O side effects.
12
+ *
13
+ * All output is deferred until line completion (\n or forceFlush),
14
+ * so inline Markdown patterns spanning chunk boundaries are handled
15
+ * correctly without special boundary-detection logic.
10
16
  *
11
17
  * @param {(lines: string[], maxWidth?: number) => string} [formatTable=formatMarkdownTable] - Table formatting function (injectable for testing)
12
18
  * @param {number} [maxWidth] - Maximum terminal display width (defaults to process.stdout.columns - 4 or 80)
13
- * @returns {{ feed: (chunk: string) => DetectorResult, forceFlush: () => DetectorResult }}
19
+ * @returns {{ feed: (chunk: string) => StreamFormatterResult, forceFlush: () => StreamFormatterResult }}
14
20
  */
15
- export function createTableDetector(
21
+ export function createStreamFormatter(
16
22
  formatTable = formatMarkdownTable,
17
23
  maxWidth = process.stdout.columns ? process.stdout.columns - 4 : 80,
18
24
  ) {
@@ -25,9 +31,9 @@ export function createTableDetector(
25
31
  const MAX_TABLE_LINES = 200;
26
32
 
27
33
  /**
28
- * Feed a text chunk to the detector.
34
+ * Feed a text chunk to the formatter.
29
35
  * @param {string} chunk
30
- * @returns {DetectorResult}
36
+ * @returns {StreamFormatterResult}
31
37
  */
32
38
  function feed(chunk) {
33
39
  if (chunk.length === 0) return { output: [], warnings: [] };
@@ -48,19 +54,12 @@ export function createTableDetector(
48
54
  warnings.push(...result.warnings);
49
55
  }
50
56
 
51
- // If not buffering a table and pendingLine has no pipe, output immediately
52
- // This ensures non-table text is streamed without delay
53
- if (tableLines.length === 0 && !pendingLine.includes("|")) {
54
- output.push(pendingLine);
55
- pendingLine = "";
56
- }
57
-
58
57
  return { output, warnings };
59
58
  }
60
59
 
61
60
  /**
62
61
  * Force flush any pending content (call on turn end).
63
- * @returns {DetectorResult}
62
+ * @returns {StreamFormatterResult}
64
63
  */
65
64
  function forceFlush() {
66
65
  /** @type {string[]} */
@@ -68,14 +67,11 @@ export function createTableDetector(
68
67
  /** @type {string[]} */
69
68
  const warnings = [];
70
69
 
71
- // Process any remaining pending line
70
+ // Process any remaining pending line as a completed line
72
71
  if (pendingLine.length > 0) {
73
- // If we have a table buffer, add pending line to it or output directly
74
- if (tableLines.length > 0) {
75
- tableLines.push(`${pendingLine}\n`);
76
- } else {
77
- output.push(pendingLine);
78
- }
72
+ const result = processLine(pendingLine);
73
+ output.push(...result.output);
74
+ warnings.push(...result.warnings);
79
75
  pendingLine = "";
80
76
  }
81
77
  const flushResult = flushTable();
@@ -87,30 +83,33 @@ export function createTableDetector(
87
83
 
88
84
  /**
89
85
  * Process a complete line.
90
- * @param {string} line - Line including trailing newline
91
- * @returns {DetectorResult}
86
+ * @param {string} rawLine - Line (may or may not include trailing newline)
87
+ * @returns {StreamFormatterResult}
92
88
  */
93
- function processLine(line) {
89
+ function processLine(rawLine) {
94
90
  /** @type {string[]} */
95
91
  const output = [];
96
92
  /** @type {string[]} */
97
93
  const warnings = [];
98
94
 
99
- // Code block detection
100
- if (line.trimStart().startsWith("```")) {
95
+ // Code block detection (before Markdown conversion — code blocks stay raw)
96
+ if (rawLine.trimStart().startsWith("```")) {
101
97
  inCodeBlock = !inCodeBlock;
102
98
  const flushResult = flushTable(); // Code block terminates any ongoing table
103
99
  output.push(...flushResult.output);
104
100
  warnings.push(...flushResult.warnings);
105
- output.push(line);
101
+ output.push(rawLine);
106
102
  return { output, warnings };
107
103
  }
108
104
 
109
105
  if (inCodeBlock) {
110
- output.push(line);
106
+ output.push(rawLine);
111
107
  return { output, warnings };
112
108
  }
113
109
 
110
+ // Apply inline Markdown styling on completed lines
111
+ const line = applyInlineMarkdown(rawLine);
112
+
114
113
  // Table start: line begins with pipe
115
114
  if (isTableStart(line)) {
116
115
  tableLines.push(line);
@@ -145,7 +144,7 @@ export function createTableDetector(
145
144
 
146
145
  /**
147
146
  * Flush table buffer with formatting.
148
- * @returns {DetectorResult}
147
+ * @returns {StreamFormatterResult}
149
148
  */
150
149
  function flushTable() {
151
150
  if (tableLines.length === 0) return { output: [], warnings: [] };
@@ -193,7 +192,7 @@ export function createTableDetector(
193
192
 
194
193
  /**
195
194
  * Flush table buffer without formatting (for oversized tables).
196
- * @returns {DetectorResult}
195
+ * @returns {StreamFormatterResult}
197
196
  */
198
197
  function flushTableAsIs() {
199
198
  if (tableLines.length === 0) return { output: [], warnings: [] };
package/src/main.mjs CHANGED
@@ -34,64 +34,70 @@ import { createWebSearchTool } from "./tools/webSearch.mjs";
34
34
  import { writeFileTool } from "./tools/writeFile.mjs";
35
35
  import { createToolUseApprover } from "./toolUseApprover.mjs";
36
36
 
37
- const cliArgs = parseCliArgs(process.argv);
38
- if (cliArgs.subcommand.type === "help") {
39
- printHelp();
40
- }
41
-
42
- if (cliArgs.subcommand.type === "list-models") {
43
- const { appConfig } = await loadAppConfig({ skipTrustCheck: true });
44
- if (!appConfig.models || appConfig.models.length === 0) {
45
- console.error("No models found in configuration.");
46
- process.exit(1);
47
- }
48
- for (const model of appConfig.models) {
49
- const platform = model.platform;
50
- console.log(
51
- `${model.name}+${model.variant} (platform: ${platform.name}+${platform.variant})`,
52
- );
37
+ /**
38
+ * CLI entry point. Separated from top-level so that importing this module
39
+ * does not start the application — required for code-coverage smoke tests.
40
+ *
41
+ * @param {string[]} argv - Typically `process.argv`.
42
+ */
43
+ export async function main(argv = process.argv) {
44
+ const cliArgs = parseCliArgs(argv);
45
+ if (cliArgs.subcommand.type === "help") {
46
+ printHelp();
53
47
  }
54
- process.exit(0);
55
- }
56
48
 
57
- if (cliArgs.subcommand.type === "install-claude-code-plugins") {
58
- await installClaudeCodePlugins();
59
- process.exit(0);
60
- }
61
-
62
- if (cliArgs.subcommand.type === "cost") {
63
- try {
64
- const exitCode = await runCostCommand({
65
- from: cliArgs.subcommand.from,
66
- to: cliArgs.subcommand.to,
67
- });
68
- process.exit(exitCode);
69
- } catch (err) {
70
- const message = err instanceof Error ? err.message : String(err);
71
- console.error(message);
72
- process.exit(1);
49
+ if (cliArgs.subcommand.type === "list-models") {
50
+ const { appConfig } = await loadAppConfig({ skipTrustCheck: true });
51
+ if (!appConfig.models || appConfig.models.length === 0) {
52
+ console.error("No models found in configuration.");
53
+ process.exit(1);
54
+ }
55
+ for (const model of appConfig.models) {
56
+ const platform = model.platform;
57
+ console.log(
58
+ `${model.name}+${model.variant} (platform: ${platform.name}+${platform.variant})`,
59
+ );
60
+ }
61
+ process.exit(0);
73
62
  }
74
- }
75
63
 
76
- if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
77
- const sessions = await listSessions();
78
- if (sessions.length === 0) {
79
- console.log("No resumable sessions in .plain-agent/sessions/.");
64
+ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
65
+ await installClaudeCodePlugins();
80
66
  process.exit(0);
81
67
  }
82
- console.log("Resumable sessions (most recently updated first):\n");
83
- for (const s of sessions) {
84
- console.log(
85
- ` ${s.sessionId} ${s.modelName} (updated ${formatLocalDateTime(s.lastUpdatedAt)}, ${s.messageCount} messages)`,
86
- );
87
- if (s.workingDir !== process.cwd()) {
88
- console.log(` workingDir: ${s.workingDir}`);
68
+
69
+ if (cliArgs.subcommand.type === "cost") {
70
+ try {
71
+ const exitCode = await runCostCommand({
72
+ from: cliArgs.subcommand.from,
73
+ to: cliArgs.subcommand.to,
74
+ });
75
+ process.exit(exitCode);
76
+ } catch (err) {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ console.error(message);
79
+ process.exit(1);
89
80
  }
90
81
  }
91
- process.exit(0);
92
- }
93
82
 
94
- (async () => {
83
+ if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
84
+ const sessions = await listSessions();
85
+ if (sessions.length === 0) {
86
+ console.log("No resumable sessions in .plain-agent/sessions/.");
87
+ process.exit(0);
88
+ }
89
+ console.log("Resumable sessions (most recently updated first):\n");
90
+ for (const s of sessions) {
91
+ console.log(
92
+ ` ${s.sessionId} ${s.modelName} (updated ${formatLocalDateTime(s.lastUpdatedAt)}, ${s.messageCount} messages)`,
93
+ );
94
+ if (s.workingDir !== process.cwd()) {
95
+ console.log(` workingDir: ${s.workingDir}`);
96
+ }
97
+ }
98
+ process.exit(0);
99
+ }
100
+
95
101
  /** @type {SessionState | null} */
96
102
  let resumedState = null;
97
103
 
@@ -430,10 +436,7 @@ if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
430
436
  voiceInput: appConfig.voiceInput,
431
437
  });
432
438
  }
433
- })().catch((err) => {
434
- console.error(err);
435
- process.exit(1);
436
- });
439
+ }
437
440
 
438
441
  /**
439
442
  * Generate a session id of the form `YYYY-MM-DD-HHMM-<3 random base36 chars>`.
package/src/prompt.mjs CHANGED
@@ -70,6 +70,10 @@ Memory files should include:
70
70
 
71
71
  Call multiple tools at once when they don't depend on each other's results.
72
72
 
73
+ ## patch_file
74
+
75
+ Always read the target lines with \`read_file\` first to verify line numbers and their 2-char hashes before calling \`patch_file\`.
76
+
73
77
  ## exec_command
74
78
 
75
79
  - Use relative paths.