@iinm/plain-agent 1.10.15 → 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,33 +96,43 @@ 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": [
104
+ // Bedrock: Requires the AWS CLI for authentication
106
105
  {
107
- // Requires Azure CLI to get access token
108
- "name": "azure",
109
- "variant": "openai",
110
- "baseURL": "https://<resource>.openai.azure.com/openai",
111
- // Optional
112
- "azureConfigDir": "/home/xxx/.azure-for-agent"
113
- },
114
- {
115
- // Requires AWS CLI to get credentials
116
106
  "name": "bedrock",
117
107
  "variant": "default",
118
108
  "baseURL": "https://bedrock-runtime.<region>.amazonaws.com",
119
109
  "awsProfile": "<AWS_PROFILE>"
120
110
  },
111
+
112
+ // Vertex AI: Requires the gcloud CLI for authentication
121
113
  {
122
- // Requires gcloud CLI to get authentication token
123
114
  "name": "vertex-ai",
124
115
  "variant": "default",
125
116
  "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project>/locations/<location>",
126
- // (Optional) Impersonate this service account to obtain an auth token
117
+ // Optional: impersonate this service account to obtain an auth token
127
118
  "account": "<SERVICE_ACCOUNT_EMAIL>"
119
+ },
120
+
121
+ // Azure: Requires the Azure CLI for authentication
122
+ {
123
+ "name": "azure",
124
+ "variant": "default",
125
+ "baseURL": "https://<resource>.services.ai.azure.com",
126
+ // Optional
127
+ "azureConfigDir": "/home/xxx/.azure-for-agent"
128
+ },
129
+ // Azure OpenAI
130
+ {
131
+ "name": "azure",
132
+ "variant": "openai",
133
+ "baseURL": "https://<resource>.openai.azure.com/openai",
134
+ // Optional
135
+ "azureConfigDir": "/home/xxx/.azure-for-agent"
128
136
  }
129
137
  ]
130
138
  }
@@ -132,7 +140,7 @@ Create the configuration.
132
140
  </details>
133
141
 
134
142
  <details>
135
- <summary><b>OpenAI compatible provider examples</b></summary>
143
+ <summary><b>OpenAI-compatible provider examples</b></summary>
136
144
 
137
145
  ```js
138
146
  {
@@ -185,7 +193,7 @@ Create the configuration.
185
193
  </details>
186
194
 
187
195
  <details>
188
- <summary><b>Bedrock example using Claude Japan inference profiles</b></summary>
196
+ <summary><b>Bedrock example with Claude Geo Cross-Region inference</b></summary>
189
197
 
190
198
  ```js
191
199
  {
@@ -260,22 +268,21 @@ Run the agent.
260
268
 
261
269
  ```sh
262
270
  plain
263
- ```
264
271
 
265
- ```
272
+ # Or
266
273
  plain -m <model+variant>
267
274
  ```
268
275
 
269
- 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.
270
277
 
271
- Display the help message.
278
+ Show the help message.
272
279
 
273
280
  ```
274
281
  /help
275
282
  ```
276
283
 
277
- Run in batch mode (non-interactive).
278
- 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.
279
286
 
280
287
  ```sh
281
288
  plain batch \
@@ -284,34 +291,32 @@ plain batch \
284
291
  "Add tests for ..."
285
292
  ```
286
293
 
287
- Show daily token costs across sessions. `plain cost` reads
294
+ Show daily token cost. `plain cost` reads
288
295
  `~/.local/share/plain-agent/usage.jsonl`; use `--from` / `--to` to set the
289
- period. Currencies are shown separately.
296
+ period. Costs are shown separately by currency.
290
297
 
291
298
  ```sh
292
299
  plain cost
293
- ```
294
300
 
295
- ```
301
+ # Or
296
302
  plain cost --from 2026-04-01 --to 2026-04-30
297
303
  ```
298
304
 
299
305
  Resume a previously interrupted interactive session. Sessions are
300
- auto-saved to `.plain-agent/sessions/` and can be removed with `rm` when
301
- 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
302
308
  is resumed. Use `--list` to see resumable sessions. Switching models is
303
- not supported (`-m` is rejected).
309
+ not supported (`-m` is not allowed).
304
310
 
305
311
  ```sh
306
312
  plain resume
307
- ```
308
313
 
309
- ```
314
+ # Or
310
315
  plain resume --list
311
316
  plain resume 2026-05-10-0803-a7k
312
317
  ```
313
318
 
314
- Configure plain-agent for your project.
319
+ Set up Plain Agent for your project.
315
320
 
316
321
  ```
317
322
  /configure Auto-approve file writes and patches
@@ -343,7 +348,7 @@ Files are loaded in the following order. Settings in later files override earlie
343
348
  ### Example
344
349
 
345
350
  <details>
346
- <summary><b>YOLO mode example (requires sandbox for safety)</b></summary>
351
+ <summary><b>YOLO mode example (requires a sandbox for safety)</b></summary>
347
352
 
348
353
  ```js
349
354
  {
@@ -363,7 +368,7 @@ Files are loaded in the following order. Settings in later files override earlie
363
368
  "toolName": { "$regex": "^(web_search|web_fetch)$" },
364
369
  "action": "allow"
365
370
  }
366
- // ⚠️ 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.
367
372
  // {
368
373
  // "toolName": { "$regex": "." },
369
374
  // "action": "allow"
@@ -395,10 +400,10 @@ Files are loaded in the following order. Settings in later files override earlie
395
400
  ```js
396
401
  {
397
402
  "autoApproval": {
398
- // 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.
399
404
  "allowedPaths": ["/tmp"],
400
405
  "defaultAction": "ask",
401
- // The maximum number of automatic approvals.
406
+ // Maximum number of automatic approvals.
402
407
  "maxApprovals": 50,
403
408
  // Patterns are evaluated in order. First match wins.
404
409
  "patterns": [
@@ -425,7 +430,7 @@ Files are loaded in the following order. Settings in later files override earlie
425
430
  "action": "allow"
426
431
  },
427
432
 
428
- // MCP Tool naming convention: mcp__<serverName>__<toolName>
433
+ // MCP tool naming convention: mcp__<serverName>__<toolName>
429
434
  {
430
435
  "toolName": { "$regex": "mcp__slack__slack_(read|search)_.+" },
431
436
  "action": "allow"
@@ -467,7 +472,7 @@ Files are loaded in the following order. Settings in later files override earlie
467
472
  "command": "npx",
468
473
  "args": ["-y", "chrome-devtools-mcp@latest", "--isolated"]
469
474
  },
470
- // ⚠️ Add to config.local.json to avoid committing secrets to Git
475
+ // ⚠️ Add this to config.local.json to avoid committing secrets to Git
471
476
  "slack": {
472
477
  "command": "npx",
473
478
  "args": ["-y", "mcp-remote", "https://mcp.slack.com/mcp", "--header", "Authorization:Bearer <SLACK_TOKEN>"],
@@ -476,7 +481,7 @@ Files are loaded in the following order. Settings in later files override earlie
476
481
  "command": "npx",
477
482
  "args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"],
478
483
  "options": {
479
- // Enable only specific tools (optional - if not specified, all tools are enabled)
484
+ // Enable only specific tools. If not specified, all tools are enabled.
480
485
  "enabledTools": ["notion-search", "notion-fetch"]
481
486
  }
482
487
  },
@@ -484,18 +489,18 @@ Files are loaded in the following order. Settings in later files override earlie
484
489
  "command": "npx",
485
490
  "args": ["-y", "mcp-remote", "https://knowledge-mcp.global.api.aws"]
486
491
  },
487
- // ⚠️ Add to config.local.json to avoid committing secrets to Git
492
+ // ⚠️ Add this to config.local.json to avoid committing secrets to Git
488
493
  "google_developer-knowledge": {
489
494
  "command": "npx",
490
495
  "args": ["-y", "mcp-remote", "https://developerknowledge.googleapis.com/mcp", "--header", "X-Goog-Api-Key:<GOOGLE_API_KEY>"]
491
496
  }
492
497
  },
493
498
 
494
- // Override default notification command
499
+ // Override the default notification command
495
500
  "notifyCmd": { "command": "plain-notify-desktop", "args": [] }
496
501
 
497
502
  // Voice input. See "Voice Input" below.
498
- // ⚠️ Add to config.local.json to avoid committing secrets to Git
503
+ // ⚠️ Add this to config.local.json to avoid committing secrets to Git
499
504
  "voiceInput": {
500
505
  "provider": "openai",
501
506
  "apiKey": "<OPENAI_API_KEY>"
@@ -506,7 +511,7 @@ Files are loaded in the following order. Settings in later files override earlie
506
511
 
507
512
  ## Available Tools
508
513
 
509
- The agent can use the following tools to assist with tasks:
514
+ The agent can use the following tools:
510
515
 
511
516
  - **read_file**: Read a file with line numbers (1-indexed). Supports `offset` and `limit` to read a specific range.
512
517
  - **write_file**: Write a file.
@@ -517,7 +522,7 @@ The agent can use the following tools to assist with tasks:
517
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`).
518
523
  - **switch_to_subagent**: Switch to a subagent role within the same conversation, focusing on the specified goal.
519
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.
520
- - **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.
521
526
 
522
527
  ## Prompts
523
528
 
@@ -551,7 +556,7 @@ Prompts located in a `shortcuts/` subdirectory (e.g., `.plain-agent/prompts/shor
551
556
 
552
557
  ## Subagents
553
558
 
554
- Subagents are specialized agents designed for specific tasks.
559
+ Subagents are specialized helpers for specific tasks.
555
560
 
556
561
  ### Locations
557
562
 
@@ -577,12 +582,7 @@ You are a web content reader and analyzer. Given a URL and a question, you:
577
582
 
578
583
  ## Claude Code Plugin Support
579
584
 
580
- Plugins are installed under `.plain-agent/claude-code-plugins/` and must be
581
- installed per project by running `plain install-claude-code-plugins` from
582
- the project root. Global installation (e.g., under `~/.plain-agent`) is not
583
- supported, because plugins may include skills that the agent invokes
584
- autonomously, and scoping them to the project keeps approval rules and
585
- 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.
586
586
 
587
587
  Example:
588
588
 
@@ -613,15 +613,13 @@ plain install-claude-code-plugins
613
613
 
614
614
  ## Voice Input
615
615
 
616
- Press **Ctrl-O** to start recording, press it again to stop. Partial
617
- transcripts are inserted into the prompt as you speak so you can edit
618
- 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.
619
617
 
620
618
  ### Requirements
621
619
 
622
620
  - A recording command on `PATH`: `arecord`, `sox`, or `ffmpeg`.
623
621
  - An API key for the chosen provider.
624
- - Your host must have microphone access. The sandbox does not need to.
622
+ - Your host must have microphone access.
625
623
 
626
624
  ### Providers
627
625
 
@@ -655,17 +653,17 @@ and send them like regular text.
655
653
 
656
654
  ### Options
657
655
 
658
- - `toggleKey` — Rebind the toggle. Accepts `"ctrl-<char>"` where `<char>`
656
+ - `toggleKey` — Rebind the toggle key. Accepts `"ctrl-<char>"` where `<char>`
659
657
  is a letter (a-z) or one of `[ \ ] ^ _`. Defaults to `"ctrl-o"`.
660
- - `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).
661
659
 
662
660
  ## Development
663
661
 
664
662
  ```sh
665
- # Run lint, typecheck, and test
663
+ # Run lint, typecheck, and tests
666
664
  npm run check
667
665
 
668
- # Fix lint errors
666
+ # Fix lint issues
669
667
  npm run fix
670
668
  # or
671
669
  npm run fix -- --unsafe
@@ -692,7 +690,7 @@ npm publish --access public
692
690
  <summary><b>Amazon Bedrock</b></summary>
693
691
 
694
692
  ```sh
695
- # IAM Identity Center
693
+ # IAM Identity Center
696
694
  identity_center_instance_arn="<IDENTITY_CENTER_INSTANCE_ARN>" # e.g., arn:aws:sso:::instance/ssoins-xxxxxxxxxxxxxxxx"
697
695
  identity_store_id=<IDENTITY_STORE_ID>
698
696
  aws_account_id=<AWS_ACCOUNT_ID>
@@ -700,7 +698,7 @@ aws_account_id=<AWS_ACCOUNT_ID>
700
698
  # Create a permission set
701
699
  permission_set_arn=$(aws sso-admin create-permission-set \
702
700
  --instance-arn "$identity_center_instance_arn" \
703
- --name "BedrockForCodingAgent" \
701
+ --name "BedrockCodingAgent" \
704
702
  --description "Allows only Bedrock model invocation" \
705
703
  --query "PermissionSet.PermissionSetArn" --output text)
706
704
 
@@ -775,7 +773,7 @@ aws bedrock-runtime invoke-model \
775
773
 
776
774
  ```sh
777
775
  resource_group=<RESOURCE_GROUP>
778
- account_name=<ACCOUNT_NAME> # resource name
776
+ account_name=<ACCOUNT_NAME> # Resource name
779
777
 
780
778
  # Create a service principal
781
779
  service_principal=$(az ad sp create-for-rbac --name "CodingAgentServicePrincipal" --skip-assignment)
@@ -798,7 +796,7 @@ az role assignment create \
798
796
  export app_secret=$(echo "$service_principal" | jq -r .password)
799
797
  export tenant_id=$(echo "$service_principal" | jq -r .tenant)
800
798
 
801
- 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
802
800
  az login --service-principal -u "$app_id" -p "$app_secret" --tenant "$tenant_id"
803
801
  ```
804
802
  </details>
@@ -916,7 +916,7 @@
916
916
  },
917
917
 
918
918
  {
919
- "name": "gpt-5.2-chat",
919
+ "name": "gpt-5.4-mini",
920
920
  "variant": "thinking-medium-azure",
921
921
  "platform": {
922
922
  "name": "azure",
@@ -925,8 +925,149 @@
925
925
  "model": {
926
926
  "format": "openai-responses",
927
927
  "config": {
928
- "model": "gpt-5.2-latest",
929
- "reasoning": { "effort": "medium", "summary": "auto" }
928
+ "model": "gpt-5.4-mini",
929
+ "reasoning": { "effort": "medium", "summary": "auto" },
930
+ "store": false,
931
+ "include": ["reasoning.encrypted_content"]
932
+ }
933
+ },
934
+ "cost": {
935
+ "currency": "USD",
936
+ "unit": "1M",
937
+ "costs": {
938
+ "input_tokens": 0.75,
939
+ "input_tokens_details.cached_tokens": -0.675,
940
+ "output_tokens": 4.5
941
+ }
942
+ }
943
+ },
944
+ {
945
+ "name": "gpt-5.4-mini",
946
+ "variant": "thinking-high-azure",
947
+ "platform": {
948
+ "name": "azure",
949
+ "variant": "openai"
950
+ },
951
+ "model": {
952
+ "format": "openai-responses",
953
+ "config": {
954
+ "model": "gpt-5.4-mini",
955
+ "reasoning": { "effort": "high", "summary": "auto" },
956
+ "store": false,
957
+ "include": ["reasoning.encrypted_content"]
958
+ }
959
+ },
960
+ "cost": {
961
+ "currency": "USD",
962
+ "unit": "1M",
963
+ "costs": {
964
+ "input_tokens": 0.75,
965
+ "input_tokens_details.cached_tokens": -0.675,
966
+ "output_tokens": 4.5
967
+ }
968
+ }
969
+ },
970
+ {
971
+ "name": "gpt-5.4-mini",
972
+ "variant": "thinking-xhigh-azure",
973
+ "platform": {
974
+ "name": "azure",
975
+ "variant": "openai"
976
+ },
977
+ "model": {
978
+ "format": "openai-responses",
979
+ "config": {
980
+ "model": "gpt-5.4-mini",
981
+ "reasoning": { "effort": "xhigh", "summary": "auto" },
982
+ "store": false,
983
+ "include": ["reasoning.encrypted_content"]
984
+ }
985
+ },
986
+ "cost": {
987
+ "currency": "USD",
988
+ "unit": "1M",
989
+ "costs": {
990
+ "input_tokens": 0.75,
991
+ "input_tokens_details.cached_tokens": -0.675,
992
+ "output_tokens": 4.5
993
+ }
994
+ }
995
+ },
996
+ {
997
+ "name": "gpt-5.5",
998
+ "variant": "thinking-medium-azure",
999
+ "platform": {
1000
+ "name": "azure",
1001
+ "variant": "openai"
1002
+ },
1003
+ "model": {
1004
+ "format": "openai-responses",
1005
+ "config": {
1006
+ "model": "gpt-5.5",
1007
+ "reasoning": { "effort": "medium", "summary": "auto" },
1008
+ "store": false,
1009
+ "include": ["reasoning.encrypted_content"]
1010
+ }
1011
+ },
1012
+ "cost": {
1013
+ "currency": "USD",
1014
+ "unit": "1M",
1015
+ "costs": {
1016
+ "input_tokens": 5,
1017
+ "input_tokens_details.cached_tokens": -4.5,
1018
+ "output_tokens": 30
1019
+ }
1020
+ }
1021
+ },
1022
+ {
1023
+ "name": "gpt-5.5",
1024
+ "variant": "thinking-high-azure",
1025
+ "platform": {
1026
+ "name": "azure",
1027
+ "variant": "openai"
1028
+ },
1029
+ "model": {
1030
+ "format": "openai-responses",
1031
+ "config": {
1032
+ "model": "gpt-5.5",
1033
+ "reasoning": { "effort": "high", "summary": "auto" },
1034
+ "store": false,
1035
+ "include": ["reasoning.encrypted_content"]
1036
+ }
1037
+ },
1038
+ "cost": {
1039
+ "currency": "USD",
1040
+ "unit": "1M",
1041
+ "costs": {
1042
+ "input_tokens": 5,
1043
+ "input_tokens_details.cached_tokens": -4.5,
1044
+ "output_tokens": 30
1045
+ }
1046
+ }
1047
+ },
1048
+ {
1049
+ "name": "gpt-5.5",
1050
+ "variant": "thinking-xhigh-azure",
1051
+ "platform": {
1052
+ "name": "azure",
1053
+ "variant": "openai"
1054
+ },
1055
+ "model": {
1056
+ "format": "openai-responses",
1057
+ "config": {
1058
+ "model": "gpt-5.5",
1059
+ "reasoning": { "effort": "xhigh", "summary": "auto" },
1060
+ "store": false,
1061
+ "include": ["reasoning.encrypted_content"]
1062
+ }
1063
+ },
1064
+ "cost": {
1065
+ "currency": "USD",
1066
+ "unit": "1M",
1067
+ "costs": {
1068
+ "input_tokens": 5,
1069
+ "input_tokens_details.cached_tokens": -4.5,
1070
+ "output_tokens": 30
930
1071
  }
931
1072
  }
932
1073
  },
@@ -978,6 +1119,30 @@
978
1119
  }
979
1120
  }
980
1121
  },
1122
+ {
1123
+ "name": "glm-5",
1124
+ "variant": "bedrock",
1125
+ "platform": {
1126
+ "name": "bedrock",
1127
+ "variant": "default"
1128
+ },
1129
+ "model": {
1130
+ "format": "openai-messages",
1131
+ "config": {
1132
+ "model": "zai.glm-5",
1133
+ "reasoning_effort": "high"
1134
+ }
1135
+ },
1136
+ "cost": {
1137
+ "currency": "USD",
1138
+ "unit": "1M",
1139
+ "costs": {
1140
+ "prompt_tokens": 1,
1141
+ "completion_tokens": 3.2
1142
+ }
1143
+ }
1144
+ },
1145
+
981
1146
  {
982
1147
  "name": "glm-5.1",
983
1148
  "variant": "fireworks",
@@ -1071,6 +1236,21 @@
1071
1236
  }
1072
1237
  }
1073
1238
  },
1239
+ {
1240
+ "name": "kimi-k2.6",
1241
+ "variant": "azure",
1242
+ "platform": {
1243
+ "name": "azure",
1244
+ "variant": "default"
1245
+ },
1246
+ "model": {
1247
+ "format": "openai-messages",
1248
+ "config": {
1249
+ "model": "Kimi-K2.6",
1250
+ "reasoning_effort": "high"
1251
+ }
1252
+ }
1253
+ },
1074
1254
 
1075
1255
  {
1076
1256
  "name": "deepseek-v4-pro",
@@ -1118,6 +1298,21 @@
1118
1298
  }
1119
1299
  }
1120
1300
  },
1301
+ {
1302
+ "name": "deepseek-v4-pro",
1303
+ "variant": "azure",
1304
+ "platform": {
1305
+ "name": "azure",
1306
+ "variant": "default"
1307
+ },
1308
+ "model": {
1309
+ "format": "openai-messages",
1310
+ "config": {
1311
+ "model": "DeepSeek-V4-Pro",
1312
+ "reasoning_effort": "high"
1313
+ }
1314
+ }
1315
+ },
1121
1316
 
1122
1317
  {
1123
1318
  "name": "minimax-m2.7",
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.10.15",
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
  /**
@@ -8,6 +8,7 @@ import { styleText } from "node:util";
8
8
  import { noThrow } from "../utils/noThrow.mjs";
9
9
  import { retryOnError } from "../utils/retryOnError.mjs";
10
10
  import { loadAwsCredentials, signAwsRequest } from "./platform/awsSigV4.mjs";
11
+ import { getAzureAccessToken } from "./platform/azure.mjs";
11
12
  import { readBedrockStreamEvents } from "./platform/bedrock.mjs";
12
13
  import { getGoogleCloudAccessToken } from "./platform/googleCloud.mjs";
13
14
 
@@ -43,6 +44,8 @@ export async function callOpenAICompatibleModel(
43
44
  return `${baseURL}/model/${modelConfig.model}/invoke-with-response-stream`;
44
45
  case "vertex-ai":
45
46
  return `${baseURL}/endpoints/openapi/chat/completions`;
47
+ case "azure":
48
+ return `${baseURL}/models/chat/completions?api-version=2024-05-01-preview`;
46
49
  default:
47
50
  throw new Error(`Unsupported platform: ${platformConfig.name}`);
48
51
  }
@@ -64,6 +67,15 @@ export async function callOpenAICompatibleModel(
64
67
  ...platformConfig.customHeaders,
65
68
  Authorization: `Bearer ${await getGoogleCloudAccessToken()}`,
66
69
  };
70
+ case "azure":
71
+ return {
72
+ ...platformConfig.customHeaders,
73
+ Authorization: `Bearer ${await getAzureAccessToken(
74
+ platformConfig.azureConfigDir
75
+ ? { azureConfigDir: platformConfig.azureConfigDir }
76
+ : undefined,
77
+ )}`,
78
+ };
67
79
  }
68
80
  })();
69
81
 
@@ -83,7 +95,11 @@ export async function callOpenAICompatibleModel(
83
95
  case "vertex-ai":
84
96
  return {
85
97
  ...modelConfig,
86
- model: modelConfig.model,
98
+ stream: true,
99
+ };
100
+ case "azure":
101
+ return {
102
+ ...modelConfig,
87
103
  stream: true,
88
104
  };
89
105
  }
@@ -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