@iinm/plain-agent 1.10.16 → 1.10.17

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
@@ -1,26 +1,24 @@
1
1
  # Plain Agent
2
2
 
3
- A lightweight, capable coding agent for the terminal.
3
+ A lightweight coding agent for the terminal.
4
4
 
5
- - **Multi-provider** — Use Claude, GPT, Gemini, or any OpenAI-compatible model via Bedrock, Vertex AI, or direct APIs.
6
- - **Fine-grained auto-approval** — Auto-approve tool calls by name and arguments using regex patterns, with path validation on tool arguments.
5
+ - **Multi-provider** — Use Claude, GPT, Gemini, or any OpenAI-compatible model via direct APIs or through Bedrock, Vertex AI, or Azure.
6
+ - **Fine-grained auto-approval** — Auto-approve tool calls by matching tool names and inputs against configurable patterns, while validating string inputs as paths for safety.
7
7
  - **Sandboxed execution** — Run commands in a Docker container with filesystem and network isolation.
8
- - **Claude Code compatible** — Use Claude Code plugins, commands, subagents, and skills from `.claude/`.
9
- - **Zero external dependencies** — Built with Node.js standard libraries only.
8
+ - **Supports Claude Code resources** — Use Claude Code plugins, commands, subagents, and skills from `.claude/`.
9
+ - **Zero external dependencies** — Built using only Node.js standard libraries.
10
10
 
11
11
  ## Limitations
12
12
 
13
- - **Path validation only covers tool arguments** — It blocks paths outside the working directory, directory traversal (`..`), symlinks escaping the project, and git-ignored files but only for paths explicitly passed as tool-use arguments; it cannot control file access inside arbitrary scripts. Always use sandboxed execution when allowing arbitrary script execution.
14
- - **Sequential subagent execution** — Subagents run one at a time rather than
15
- in parallel. The trade-off is full visibility: every step is streamed to
16
- your terminal so you can follow exactly what each subagent is doing.
13
+ - **Path validation only covers tool arguments** — It blocks paths outside the working directory, directory traversal (`..`), symlinks that escape the project, and git-ignored files. However, it only applies to paths explicitly passed as tool-use arguments, so it cannot control file access inside arbitrary scripts. Always use sandboxed execution when running arbitrary scripts.
14
+ - **Sequential subagent execution** — Subagents run one at a time rather than in parallel. The trade-off is that every step is streamed to your terminal, so you can follow exactly what each subagent is doing.
17
15
 
18
16
  ## Requirements
19
17
 
20
18
  - Node.js 22 or later
21
- - LLM provider credentials
19
+ - Credentials for your LLM provider
22
20
  - [ripgrep](https://github.com/burntsushi/ripgrep), [fd](https://github.com/sharkdp/fd)
23
- - Bash / Docker for sandboxed execution
21
+ - Bash and Docker for sandboxed execution
24
22
 
25
23
  ## Quick Start
26
24
 
@@ -28,13 +26,13 @@ A lightweight, capable coding agent for the terminal.
28
26
  npm install -g @iinm/plain-agent
29
27
  ```
30
28
 
31
- List available models.
29
+ List the available models.
32
30
 
33
31
  ```sh
34
32
  plain list-models
35
33
  ```
36
34
 
37
- Create the configuration.
35
+ Create a configuration file.
38
36
 
39
37
  ```js
40
38
  // ~/.config/plain-agent/config.local.json
@@ -63,14 +61,14 @@ Create the configuration.
63
61
  }
64
62
  ],
65
63
 
66
- // (Optional) Enable web tools
64
+ // Optional: enable web tools
67
65
  "tools": {
68
66
  "webSearch": {
69
67
  "provider": "gemini",
70
68
  "apiKey": "<GEMINI_API_KEY>",
71
69
  "model": "gemini-3.5-flash"
72
70
 
73
- // Or use Vertex AI (Requires gcloud CLI to get authentication token)
71
+ // Or use Vertex AI (requires the gcloud CLI for authentication)
74
72
  // "provider": "gemini-vertex-ai",
75
73
  // "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project_id>/locations/<location>",
76
74
  // "model": "gemini-3.5-flash"
@@ -86,7 +84,7 @@ Create the configuration.
86
84
  "apiKey": "<GEMINI_API_KEY>",
87
85
  "model": "gemini-3.5-flash"
88
86
 
89
- // Or use Vertex AI (Requires gcloud CLI to get authentication token)
87
+ // Or use Vertex AI (requires the gcloud CLI for authentication)
90
88
 
91
89
  // Or use a custom command
92
90
  // "provider": "command",
@@ -98,12 +96,12 @@ Create the configuration.
98
96
  ```
99
97
 
100
98
  <details>
101
- <summary><b>Azure / Bedrock / Vertex AI provider examples</b></summary>
99
+ <summary><b>Bedrock / Vertex AI / Azure provider examples</b></summary>
102
100
 
103
101
  ```js
104
102
  {
105
103
  "platforms": [
106
- // Bedrock: Requires AWS CLI to get credentials
104
+ // Bedrock: Requires the AWS CLI for authentication
107
105
  {
108
106
  "name": "bedrock",
109
107
  "variant": "default",
@@ -111,16 +109,16 @@ Create the configuration.
111
109
  "awsProfile": "<AWS_PROFILE>"
112
110
  },
113
111
 
114
- // Vertex AI: Requires gcloud CLI to get authentication token
112
+ // Vertex AI: Requires the gcloud CLI for authentication
115
113
  {
116
114
  "name": "vertex-ai",
117
115
  "variant": "default",
118
116
  "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project>/locations/<location>",
119
- // (Optional) Impersonate this service account to obtain an auth token
117
+ // Optional: impersonate this service account to obtain an auth token
120
118
  "account": "<SERVICE_ACCOUNT_EMAIL>"
121
119
  },
122
120
 
123
- // Azure: Requires Azure CLI to get access token
121
+ // Azure: Requires the Azure CLI for authentication
124
122
  {
125
123
  "name": "azure",
126
124
  "variant": "default",
@@ -142,7 +140,7 @@ Create the configuration.
142
140
  </details>
143
141
 
144
142
  <details>
145
- <summary><b>OpenAI compatible provider examples</b></summary>
143
+ <summary><b>OpenAI-compatible provider examples</b></summary>
146
144
 
147
145
  ```js
148
146
  {
@@ -195,7 +193,7 @@ Create the configuration.
195
193
  </details>
196
194
 
197
195
  <details>
198
- <summary><b>Bedrock example using Claude Japan inference profiles</b></summary>
196
+ <summary><b>Bedrock example with Claude Geo Cross-Region inference</b></summary>
199
197
 
200
198
  ```js
201
199
  {
@@ -270,22 +268,21 @@ Run the agent.
270
268
 
271
269
  ```sh
272
270
  plain
273
- ```
274
271
 
275
- ```
272
+ # Or
276
273
  plain -m <model+variant>
277
274
  ```
278
275
 
279
- Press **Ctrl-C** to pause auto-approve. The agent will finish the current tool call, then return to the prompt.
276
+ Press **Ctrl-C** to pause auto-approval. The agent will finish the current tool call, then return to the prompt.
280
277
 
281
- Display the help message.
278
+ Show the help message.
282
279
 
283
280
  ```
284
281
  /help
285
282
  ```
286
283
 
287
- Run in batch mode (non-interactive).
288
- In batch mode, config files are not loaded automatically. Only the files specified with `-c` are loaded.
284
+ Run in non-interactive batch mode.
285
+ In batch mode, configuration files are not loaded automatically. Only the files specified with `-c` are loaded.
289
286
 
290
287
  ```sh
291
288
  plain batch \
@@ -294,34 +291,32 @@ plain batch \
294
291
  "Add tests for ..."
295
292
  ```
296
293
 
297
- Show daily token costs across sessions. `plain cost` reads
294
+ Show daily token cost. `plain cost` reads
298
295
  `~/.local/share/plain-agent/usage.jsonl`; use `--from` / `--to` to set the
299
- period. Currencies are shown separately.
296
+ period. Costs are shown separately by currency.
300
297
 
301
298
  ```sh
302
299
  plain cost
303
- ```
304
300
 
305
- ```
301
+ # Or
306
302
  plain cost --from 2026-04-01 --to 2026-04-30
307
303
  ```
308
304
 
309
305
  Resume a previously interrupted interactive session. Sessions are
310
- auto-saved to `.plain-agent/sessions/` and can be removed with `rm` when
311
- no longer needed. Without an argument, the most recently updated session
306
+ automatically saved to `.plain-agent/sessions/` and can be deleted with `rm` when
307
+ no longer needed. If no argument is provided, the most recently updated session
312
308
  is resumed. Use `--list` to see resumable sessions. Switching models is
313
- not supported (`-m` is rejected).
309
+ not supported (`-m` is not allowed).
314
310
 
315
311
  ```sh
316
312
  plain resume
317
- ```
318
313
 
319
- ```
314
+ # Or
320
315
  plain resume --list
321
316
  plain resume 2026-05-10-0803-a7k
322
317
  ```
323
318
 
324
- Configure plain-agent for your project.
319
+ Set up Plain Agent for your project.
325
320
 
326
321
  ```
327
322
  /configure Auto-approve file writes and patches
@@ -353,7 +348,7 @@ Files are loaded in the following order. Settings in later files override earlie
353
348
  ### Example
354
349
 
355
350
  <details>
356
- <summary><b>YOLO mode example (requires sandbox for safety)</b></summary>
351
+ <summary><b>YOLO mode example (requires a sandbox for safety)</b></summary>
357
352
 
358
353
  ```js
359
354
  {
@@ -373,7 +368,7 @@ Files are loaded in the following order. Settings in later files override earlie
373
368
  "toolName": { "$regex": "^(web_search|web_fetch)$" },
374
369
  "action": "allow"
375
370
  }
376
- // ⚠️ Never do this. mcp run outside the sandbox, so they can send anything externally.
371
+ // ⚠️ Never do this. MCP runs outside the sandbox, so it can send anything externally.
377
372
  // {
378
373
  // "toolName": { "$regex": "." },
379
374
  // "action": "allow"
@@ -405,10 +400,10 @@ Files are loaded in the following order. Settings in later files override earlie
405
400
  ```js
406
401
  {
407
402
  "autoApproval": {
408
- // Absolute paths outside the working directory to allow access to. Relative paths are ignored.
403
+ // Absolute paths outside the working directory that are allowed. Relative paths are ignored.
409
404
  "allowedPaths": ["/tmp"],
410
405
  "defaultAction": "ask",
411
- // The maximum number of automatic approvals.
406
+ // Maximum number of automatic approvals.
412
407
  "maxApprovals": 50,
413
408
  // Patterns are evaluated in order. First match wins.
414
409
  "patterns": [
@@ -435,7 +430,7 @@ Files are loaded in the following order. Settings in later files override earlie
435
430
  "action": "allow"
436
431
  },
437
432
 
438
- // MCP Tool naming convention: mcp__<serverName>__<toolName>
433
+ // MCP tool naming convention: mcp__<serverName>__<toolName>
439
434
  {
440
435
  "toolName": { "$regex": "mcp__slack__slack_(read|search)_.+" },
441
436
  "action": "allow"
@@ -477,7 +472,7 @@ Files are loaded in the following order. Settings in later files override earlie
477
472
  "command": "npx",
478
473
  "args": ["-y", "chrome-devtools-mcp@latest", "--isolated"]
479
474
  },
480
- // ⚠️ Add to config.local.json to avoid committing secrets to Git
475
+ // ⚠️ Add this to config.local.json to avoid committing secrets to Git
481
476
  "slack": {
482
477
  "command": "npx",
483
478
  "args": ["-y", "mcp-remote", "https://mcp.slack.com/mcp", "--header", "Authorization:Bearer <SLACK_TOKEN>"],
@@ -486,7 +481,7 @@ Files are loaded in the following order. Settings in later files override earlie
486
481
  "command": "npx",
487
482
  "args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"],
488
483
  "options": {
489
- // Enable only specific tools (optional - if not specified, all tools are enabled)
484
+ // Enable only specific tools. If not specified, all tools are enabled.
490
485
  "enabledTools": ["notion-search", "notion-fetch"]
491
486
  }
492
487
  },
@@ -494,18 +489,18 @@ Files are loaded in the following order. Settings in later files override earlie
494
489
  "command": "npx",
495
490
  "args": ["-y", "mcp-remote", "https://knowledge-mcp.global.api.aws"]
496
491
  },
497
- // ⚠️ Add to config.local.json to avoid committing secrets to Git
492
+ // ⚠️ Add this to config.local.json to avoid committing secrets to Git
498
493
  "google_developer-knowledge": {
499
494
  "command": "npx",
500
495
  "args": ["-y", "mcp-remote", "https://developerknowledge.googleapis.com/mcp", "--header", "X-Goog-Api-Key:<GOOGLE_API_KEY>"]
501
496
  }
502
497
  },
503
498
 
504
- // Override default notification command
499
+ // Override the default notification command
505
500
  "notifyCmd": { "command": "plain-notify-desktop", "args": [] }
506
501
 
507
502
  // Voice input. See "Voice Input" below.
508
- // ⚠️ Add to config.local.json to avoid committing secrets to Git
503
+ // ⚠️ Add this to config.local.json to avoid committing secrets to Git
509
504
  "voiceInput": {
510
505
  "provider": "openai",
511
506
  "apiKey": "<OPENAI_API_KEY>"
@@ -516,7 +511,7 @@ Files are loaded in the following order. Settings in later files override earlie
516
511
 
517
512
  ## Available Tools
518
513
 
519
- The agent can use the following tools to assist with tasks:
514
+ The agent can use the following tools:
520
515
 
521
516
  - **read_file**: Read a file with line numbers (1-indexed). Supports `offset` and `limit` to read a specific range.
522
517
  - **write_file**: Write a file.
@@ -527,7 +522,7 @@ The agent can use the following tools to assist with tasks:
527
522
  - **web_fetch**: Fetch the contents of a single URL and answer a question based on it (requires Google API key, Vertex AI configuration, or the `command` provider with a local fetch command such as `w3m`, `curl`, or `lynx`).
528
523
  - **switch_to_subagent**: Switch to a subagent role within the same conversation, focusing on the specified goal.
529
524
  - **switch_to_main_agent**: Switch back to the main agent role and report the result. After reporting, the subagent's conversation history is removed from the context.
530
- - **compact_context**: Compact the conversation context by discarding prior messages and reloading task state from a memory file. Use when the context has grown large but the task is not yet complete. Can also be invoked via the `/compact` slash command.
525
+ - **compact_context**: Compact the conversation context by discarding earlier messages and reloading task state from a memory file. Use this when the context has grown large but the task is not yet complete. You can also invoke it with the `/compact` slash command.
531
526
 
532
527
  ## Prompts
533
528
 
@@ -561,7 +556,7 @@ Prompts located in a `shortcuts/` subdirectory (e.g., `.plain-agent/prompts/shor
561
556
 
562
557
  ## Subagents
563
558
 
564
- Subagents are specialized agents designed for specific tasks.
559
+ Subagents are specialized helpers for specific tasks.
565
560
 
566
561
  ### Locations
567
562
 
@@ -587,12 +582,7 @@ You are a web content reader and analyzer. Given a URL and a question, you:
587
582
 
588
583
  ## Claude Code Plugin Support
589
584
 
590
- Plugins are installed under `.plain-agent/claude-code-plugins/` and must be
591
- installed per project by running `plain install-claude-code-plugins` from
592
- the project root. Global installation (e.g., under `~/.plain-agent`) is not
593
- supported, because plugins may include skills that the agent invokes
594
- autonomously, and scoping them to the project keeps approval rules and
595
- permission management straightforward.
585
+ Plugins are installed under `.plain-agent/claude-code-plugins/` and must be installed per project by running `plain install-claude-code-plugins` from the project root. Global installation (e.g., under `~/.plain-agent`) is not supported because plugins may include skills the agent invokes autonomously. Keeping them scoped to the project keeps approval rules and permission management straightforward.
596
586
 
597
587
  Example:
598
588
 
@@ -623,15 +613,13 @@ plain install-claude-code-plugins
623
613
 
624
614
  ## Voice Input
625
615
 
626
- Press **Ctrl-O** to start recording, press it again to stop. Partial
627
- transcripts are inserted into the prompt as you speak so you can edit
628
- and send them like regular text.
616
+ Press **Ctrl-O** to start recording, then press it again to stop. Partial transcripts are inserted into the prompt as you speak, so you can edit and send them like regular text.
629
617
 
630
618
  ### Requirements
631
619
 
632
620
  - A recording command on `PATH`: `arecord`, `sox`, or `ffmpeg`.
633
621
  - An API key for the chosen provider.
634
- - Your host must have microphone access. The sandbox does not need to.
622
+ - Your host must have microphone access.
635
623
 
636
624
  ### Providers
637
625
 
@@ -665,17 +653,17 @@ and send them like regular text.
665
653
 
666
654
  ### Options
667
655
 
668
- - `toggleKey` — Rebind the toggle. Accepts `"ctrl-<char>"` where `<char>`
656
+ - `toggleKey` — Rebind the toggle key. Accepts `"ctrl-<char>"` where `<char>`
669
657
  is a letter (a-z) or one of `[ \ ] ^ _`. Defaults to `"ctrl-o"`.
670
- - `recorder` — Override recorder auto-detection, e.g. `{ "command": "sox", "args": ["-q", "-d", "-b", "16", "-c", "1", "-r", "24000", "-e", "signed-integer", "-t", "raw", "-"] }`. Must write raw 16-bit little-endian mono PCM to stdout at 24 kHz (OpenAI) or 16 kHz (Gemini).
658
+ - `recorder` — Override automatic recorder detection, e.g. `{ "command": "sox", "args": ["-q", "-d", "-b", "16", "-c", "1", "-r", "24000", "-e", "signed-integer", "-t", "raw", "-"] }`. It must write raw 16-bit little-endian mono PCM to stdout at 24 kHz (OpenAI) or 16 kHz (Gemini).
671
659
 
672
660
  ## Development
673
661
 
674
662
  ```sh
675
- # Run lint, typecheck, and test
663
+ # Run lint, typecheck, and tests
676
664
  npm run check
677
665
 
678
- # Fix lint errors
666
+ # Fix lint issues
679
667
  npm run fix
680
668
  # or
681
669
  npm run fix -- --unsafe
@@ -702,7 +690,7 @@ npm publish --access public
702
690
  <summary><b>Amazon Bedrock</b></summary>
703
691
 
704
692
  ```sh
705
- # IAM Identity Center
693
+ # IAM Identity Center
706
694
  identity_center_instance_arn="<IDENTITY_CENTER_INSTANCE_ARN>" # e.g., arn:aws:sso:::instance/ssoins-xxxxxxxxxxxxxxxx"
707
695
  identity_store_id=<IDENTITY_STORE_ID>
708
696
  aws_account_id=<AWS_ACCOUNT_ID>
@@ -710,7 +698,7 @@ aws_account_id=<AWS_ACCOUNT_ID>
710
698
  # Create a permission set
711
699
  permission_set_arn=$(aws sso-admin create-permission-set \
712
700
  --instance-arn "$identity_center_instance_arn" \
713
- --name "BedrockForCodingAgent" \
701
+ --name "BedrockCodingAgent" \
714
702
  --description "Allows only Bedrock model invocation" \
715
703
  --query "PermissionSet.PermissionSetArn" --output text)
716
704
 
@@ -785,7 +773,7 @@ aws bedrock-runtime invoke-model \
785
773
 
786
774
  ```sh
787
775
  resource_group=<RESOURCE_GROUP>
788
- account_name=<ACCOUNT_NAME> # resource name
776
+ account_name=<ACCOUNT_NAME> # Resource name
789
777
 
790
778
  # Create a service principal
791
779
  service_principal=$(az ad sp create-for-rbac --name "CodingAgentServicePrincipal" --skip-assignment)
@@ -808,7 +796,7 @@ az role assignment create \
808
796
  export app_secret=$(echo "$service_principal" | jq -r .password)
809
797
  export tenant_id=$(echo "$service_principal" | jq -r .tenant)
810
798
 
811
- export AZURE_CONFIG_DIR=$HOME/.azure-for-agent # Change the location to store credentials
799
+ export AZURE_CONFIG_DIR=$HOME/.azure-for-agent # Change this to store credentials elsewhere
812
800
  az login --service-principal -u "$app_id" -p "$app_secret" --tenant "$tenant_id"
813
801
  ```
814
802
  </details>
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.10.16",
4
- "description": "A lightweight CLI-based coding agent",
3
+ "version": "1.10.17",
4
+ "description": "A lightweight coding agent for the terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/iinm/plain-agent.git"
10
10
  },
11
+ "keywords": [
12
+ "coding-agent"
13
+ ],
11
14
  "bin": {
12
15
  "plain": "bin/plain",
13
16
  "plain-notify-desktop": "bin/plain-notify-desktop",
@@ -844,7 +844,7 @@ function renderPatchBlock(block, originalLines, nonce) {
844
844
  out.push(
845
845
  styleText(
846
846
  "cyan",
847
- `@@@ ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
847
+ `REPLACE ${nonce} ${block.start}:${block.startHash}-${block.end}:${block.endHash}`,
848
848
  ),
849
849
  );
850
850
  if (originalLines) {
@@ -872,7 +872,9 @@ function renderPatchBlock(block, originalLines, nonce) {
872
872
  }
873
873
  } else {
874
874
  const afterSuffix = block.afterHash ? `:${block.afterHash}` : "";
875
- out.push(styleText("cyan", `@@@ ${nonce} ${block.after}${afterSuffix}+`));
875
+ out.push(
876
+ styleText("cyan", `INSERT_AFTER ${nonce} ${block.after}${afterSuffix}`),
877
+ );
876
878
  for (const line of block.body) {
877
879
  out.push(styleText("green", `+ ${line}`));
878
880
  }
@@ -890,8 +892,8 @@ function highlightPatchPlain(patch) {
890
892
  if (!patch) {
891
893
  return "";
892
894
  }
893
- // Patch headers look like "@@@ <nonce> ...".
894
- const headerRegex = /^@@@\s+\S+(\s.*)?$/;
895
+ // Patch headers look like "REPLACE <nonce> ..." or "INSERT_AFTER <nonce> ...".
896
+ const headerRegex = /^(REPLACE|INSERT_AFTER)\s+\S+(\s.*)?$/;
895
897
  return patch
896
898
  .split("\n")
897
899
  .map((line) => {
@@ -912,8 +914,8 @@ function highlightPatchPlain(patch) {
912
914
  * @returns {string | null}
913
915
  */
914
916
  function extractPatchNonce(patch) {
915
- const match = patch.match(/^@@@\s+(\S+)/m);
916
- return match ? match[1] : null;
917
+ const match = patch.match(/^(REPLACE|INSERT_AFTER)\s+(\S+)/m);
918
+ return match ? match[2] : null;
917
919
  }
918
920
 
919
921
  /**
@@ -29,18 +29,18 @@ When editing multiple locations in the same file, include all blocks in a single
29
29
  patch: {
30
30
  description: `
31
31
  Format — a single patch string may contain multiple blocks:
32
- @@@ ${nonce} {start}:{startHash}-{end}:{endHash}
32
+ REPLACE ${nonce} {start}:{startHash}-{end}:{endHash}
33
33
  replacement for lines {start}-{end}
34
- @@@ ${nonce} {N}:{hash}
34
+ REPLACE ${nonce} {N}:{hash}
35
35
  replace just that one line
36
- @@@ ${nonce} {start}:{startHash}-{end}:{endHash}
36
+ REPLACE ${nonce} {start}:{startHash}-{end}:{endHash}
37
37
  (empty body deletes the range)
38
- @@@ ${nonce} {N}:{afterHash}+
39
- appended content after line N
40
- @@@ ${nonce} 0+
41
- prepended content at beginning of file
38
+ INSERT_AFTER ${nonce} {N}:{afterHash}
39
+ new content after line N
40
+ INSERT_AFTER ${nonce} 0
41
+ content at beginning of file
42
42
 
43
- - Each block's content starts right after its @@@ header line and ends at the next @@@ or the end of the string. Any blank lines between the header and the content become part of the replacement.
43
+ - Each block's content starts right after its header line and ends at the next header or the end of the string. Any blank lines between the header and the content become part of the replacement.
44
44
  - The nonce "${nonce}" is constant; always use the exact value shown above.
45
45
  - Hashes are 2-character hex hashes of each line's full content as shown by read_file.
46
46
  `.trim(),
@@ -61,7 +61,7 @@ prepended content at beginning of file
61
61
  const blocks = parseBlocks(patch, nonce);
62
62
  if (blocks.length === 0) {
63
63
  throw new Error(
64
- `No patch blocks found. Each block must start with "@@@ ${nonce} ...".`,
64
+ `No patch blocks found. Each block must start with "REPLACE ${nonce} ..." or "INSERT_AFTER ${nonce} ...".`,
65
65
  );
66
66
  }
67
67
 
@@ -91,7 +91,8 @@ prepended content at beginning of file
91
91
  * @returns {PatchBlock[]}
92
92
  */
93
93
  export function parseBlocks(patch, nonce) {
94
- const openPrefix = `@@@ ${nonce} `;
94
+ const replacePrefix = `REPLACE ${nonce} `;
95
+ const insertPrefix = `INSERT_AFTER ${nonce} `;
95
96
  const lines = patch.split("\n");
96
97
  // Drop trailing empty element produced by split() when patch ends with \n.
97
98
  if (lines.length > 0 && lines[lines.length - 1] === "") {
@@ -101,14 +102,17 @@ export function parseBlocks(patch, nonce) {
101
102
  /** @type {number[]} */
102
103
  const headerIndices = [];
103
104
  for (let i = 0; i < lines.length; i++) {
104
- if (lines[i].startsWith(openPrefix)) {
105
+ if (
106
+ lines[i].startsWith(replacePrefix) ||
107
+ lines[i].startsWith(insertPrefix)
108
+ ) {
105
109
  headerIndices.push(i);
106
110
  }
107
111
  }
108
112
 
109
113
  if (headerIndices.length === 0) {
110
114
  throw new Error(
111
- `No patch blocks found. Each block must start with "@@@ ${nonce} ...".`,
115
+ `No patch blocks found. Each block must start with "REPLACE ${nonce} ..." or "INSERT_AFTER ${nonce} ...".`,
112
116
  );
113
117
  }
114
118
 
@@ -117,8 +121,19 @@ export function parseBlocks(patch, nonce) {
117
121
  for (let i = 0; i < headerIndices.length; i++) {
118
122
  const headerLineIdx = headerIndices[i];
119
123
  const headerLine = lines[headerLineIdx];
120
- const headerArgs = headerLine.slice(openPrefix.length);
121
- const header = parseHeaderArgs(headerArgs);
124
+
125
+ /** @type {"replace" | "insert"} */
126
+ let op;
127
+ let headerArgs;
128
+ if (headerLine.startsWith(replacePrefix)) {
129
+ op = "replace";
130
+ headerArgs = headerLine.slice(replacePrefix.length);
131
+ } else {
132
+ op = "insert";
133
+ headerArgs = headerLine.slice(insertPrefix.length);
134
+ }
135
+
136
+ const header = parseHeaderArgs(headerArgs, op);
122
137
 
123
138
  // Body: from the line after the header to the line before the next header (or EOF)
124
139
  const bodyStart = headerLineIdx + 1;
@@ -126,9 +141,9 @@ export function parseBlocks(patch, nonce) {
126
141
  i + 1 < headerIndices.length ? headerIndices[i + 1] : lines.length;
127
142
  const body = lines.slice(bodyStart, bodyEnd);
128
143
 
129
- if (header.op === "insert" && body.length === 0) {
144
+ if (op === "insert" && body.length === 0) {
130
145
  throw new Error(
131
- `Insert block "@@@ ${nonce} ${headerArgs}" has empty body. Use a replace block to delete content.`,
146
+ "Insert block has empty body. Use a replace block to delete content.",
132
147
  );
133
148
  }
134
149
  blocks.push({ ...header, body });
@@ -221,61 +236,69 @@ export function applyBlocks(original, blocks) {
221
236
 
222
237
  /**
223
238
  * @param {string} headerArgs
239
+ * @param {"replace" | "insert"} op
224
240
  * @returns {{ op: "replace"; start: number; end: number; startHash: string; endHash: string } | { op: "insert"; after: number; afterHash: string }}
225
241
  */
226
- function parseHeaderArgs(headerArgs) {
227
- // Replace form: "{start}:{startHash}-{end}:{endHash}"
228
- const replaceMatch = headerArgs.match(
229
- /^(\d+):([a-f0-9]{2})-(\d+):([a-f0-9]{2})\s*$/,
230
- );
242
+ function parseHeaderArgs(headerArgs, op) {
243
+ if (op === "replace") {
244
+ // Replace form: "{start}:{startHash}-{end}:{endHash}"
245
+ const rangeMatch = headerArgs.match(
246
+ /^(\d+):([a-f0-9]{2})-(\d+):([a-f0-9]{2})\s*$/,
247
+ );
231
248
 
232
- if (replaceMatch) {
233
- const start = Number(replaceMatch[1]);
234
- const end = Number(replaceMatch[3]);
235
- if (start < 1) {
236
- throw new Error(
237
- `Invalid replace range "${headerArgs}": start must be >= 1.`,
238
- );
239
- }
240
- if (end < start) {
241
- throw new Error(
242
- `Invalid replace range "${headerArgs}": end (${end}) must be >= start (${start}).`,
243
- );
249
+ if (rangeMatch) {
250
+ const start = Number(rangeMatch[1]);
251
+ const end = Number(rangeMatch[3]);
252
+ if (start < 1) {
253
+ throw new Error(
254
+ `Invalid replace range "${headerArgs}": start must be >= 1.`,
255
+ );
256
+ }
257
+ if (end < start) {
258
+ throw new Error(
259
+ `Invalid replace range "${headerArgs}": end (${end}) must be >= start (${start}).`,
260
+ );
261
+ }
262
+ return {
263
+ op: "replace",
264
+ start,
265
+ end,
266
+ startHash: rangeMatch[2],
267
+ endHash: rangeMatch[4],
268
+ };
244
269
  }
245
- return {
246
- op: "replace",
247
- start,
248
- end,
249
- startHash: replaceMatch[2],
250
- endHash: replaceMatch[4],
251
- };
252
- }
253
270
 
254
- // Replace form: "{N}:{hash}" (single line replace — shorthand for N:hash-N:hash)
255
- const singleReplaceMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\s*$/);
256
- if (singleReplaceMatch) {
257
- const start = Number(singleReplaceMatch[1]);
258
- if (start < 1) {
259
- throw new Error(
260
- `Invalid replace range "${headerArgs}": start must be >= 1.`,
261
- );
271
+ // Replace form: "{N}:{hash}" (single line replace — shorthand for N:hash-N:hash)
272
+ const singleMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\s*$/);
273
+ if (singleMatch) {
274
+ const start = Number(singleMatch[1]);
275
+ if (start < 1) {
276
+ throw new Error(
277
+ `Invalid replace range "${headerArgs}": start must be >= 1.`,
278
+ );
279
+ }
280
+ return {
281
+ op: "replace",
282
+ start,
283
+ end: start,
284
+ startHash: singleMatch[2],
285
+ endHash: singleMatch[2],
286
+ };
262
287
  }
263
- return {
264
- op: "replace",
265
- start,
266
- end: start,
267
- startHash: singleReplaceMatch[2],
268
- endHash: singleReplaceMatch[2],
269
- };
288
+
289
+ throw new Error(
290
+ `Invalid replace header arguments: ${JSON.stringify(headerArgs)}. Expected "{start}:{startHash}-{end}:{endHash}" or "{N}:{hash}".`,
291
+ );
270
292
  }
271
293
 
272
- // Insert form: "0+" (no hash — there is no line 0 to verify)
273
- if (/^0\+\s*$/.test(headerArgs)) {
294
+ // op === "insert"
295
+ // Insert form: "0" (no hash — there is no line 0 to verify)
296
+ if (/^0\s*$/.test(headerArgs)) {
274
297
  return { op: "insert", after: 0, afterHash: "" };
275
298
  }
276
299
 
277
- // Insert form: "{N}:{afterHash}+"
278
- const insertMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\+\s*$/);
300
+ // Insert form: "{N}:{afterHash}"
301
+ const insertMatch = headerArgs.match(/^(\d+):([a-f0-9]{2})\s*$/);
279
302
 
280
303
  if (insertMatch) {
281
304
  return {
@@ -286,7 +309,7 @@ function parseHeaderArgs(headerArgs) {
286
309
  }
287
310
 
288
311
  throw new Error(
289
- `Invalid block header arguments: ${JSON.stringify(headerArgs)}. Expected "{start}:{startHash}-{end}:{endHash}" or "{N}:{hash}" or "{N}:{afterHash}+" or "0+".`,
312
+ `Invalid insert header arguments: ${JSON.stringify(headerArgs)}. Expected "{N}:{afterHash}" or "0".`,
290
313
  );
291
314
  }
292
315