@link-assistant/agent 0.22.10 → 0.24.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/README.md CHANGED
@@ -293,6 +293,18 @@ Options:
293
293
  --append-system-message Append to the default system message
294
294
  --append-system-message-file Append to the default system message from file
295
295
 
296
+ Permission Options:
297
+ --read-only Enforceable read-only / planning mode.
298
+ Disables all filesystem-mutating and shell
299
+ tools (bash, edit, write, multiedit, patch)
300
+ so the agent can only read, search and plan.
301
+ Env: LINK_ASSISTANT_AGENT_READ_ONLY=true
302
+ --disable-tools <list> Comma-separated list of tool ids to disable
303
+ (e.g. "bash,write,edit"). Disabled tools are
304
+ never exposed to the model and are rejected if
305
+ invoked via batch.
306
+ Env: LINK_ASSISTANT_AGENT_DISABLE_TOOLS
307
+
296
308
  Stdin Mode Options:
297
309
  -p, --prompt Direct prompt (bypasses stdin reading)
298
310
  --disable-stdin Disable stdin streaming (requires --prompt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.22.10",
3
+ "version": "0.24.0",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -8,8 +8,9 @@ import { Instance } from '../project/instance.ts';
8
8
  import { Bus } from '../bus/index.ts';
9
9
  import { Session } from '../session/index.ts';
10
10
  import { SessionPrompt } from '../session/prompt.ts';
11
- import { createEventHandler } from '../json-standard/index.ts';
11
+ import { createEventHandler, serializeOutput } from '../json-standard/index.ts';
12
12
  import { createContinuousStdinReader } from './input-queue.js';
13
+ import { Permission } from '../permission/index.ts';
13
14
  import { Log } from '../util/log.ts';
14
15
  import { config } from '../config/config.ts';
15
16
  import { createVerboseFetch } from '../util/verbose-fetch.ts';
@@ -40,6 +41,64 @@ export function getHasError() {
40
41
  // Logger for resume operations
41
42
  const log = Log.create({ service: 'resume' });
42
43
 
44
+ function getInputFormat(argv) {
45
+ return argv['input-format'] || argv.inputFormat || 'text';
46
+ }
47
+
48
+ function outputConsumedInput({
49
+ message,
50
+ jsonStandard,
51
+ sessionID,
52
+ compactJson,
53
+ }) {
54
+ const raw = message.raw || message.message || message.system || '';
55
+ if (
56
+ jsonStandard === 'claude' &&
57
+ message.kind === 'message' &&
58
+ message.format === 'stream-json'
59
+ ) {
60
+ process.stdout.write(
61
+ serializeOutput(
62
+ {
63
+ type: 'message',
64
+ timestamp: new Date().toISOString(),
65
+ session_id: sessionID,
66
+ role: 'user',
67
+ content: [{ type: 'text', text: message.message ?? '' }],
68
+ },
69
+ 'claude'
70
+ )
71
+ );
72
+ return;
73
+ }
74
+
75
+ if (jsonStandard === 'claude') {
76
+ return;
77
+ }
78
+
79
+ outputInput(
80
+ {
81
+ raw,
82
+ parsed: message.parsed || message,
83
+ format: message.format || 'text',
84
+ kind: message.kind || 'message',
85
+ },
86
+ compactJson
87
+ );
88
+ }
89
+
90
+ function outputInputParseError(error, line, compactJson) {
91
+ hasError = true;
92
+ outputError(
93
+ {
94
+ errorType: 'ValidationError',
95
+ message: error instanceof Error ? error.message : String(error),
96
+ raw: line,
97
+ },
98
+ compactJson
99
+ );
100
+ }
101
+
43
102
  /**
44
103
  * Resolve the session to use based on --resume, --continue, and --no-fork options.
45
104
  * Returns the session ID to use, handling forking as needed.
@@ -200,8 +259,12 @@ export async function runContinuousServerMode(
200
259
  ) {
201
260
  // Check both CLI flag and environment variable for compact JSON mode
202
261
  const compactJson = argv['compact-json'] === true || config.compactJson;
262
+ const inputFormat = getInputFormat(argv);
203
263
  const isInteractive = argv.interactive !== false;
204
- const autoMerge = argv['auto-merge-queued-messages'] !== false;
264
+ const autoMerge =
265
+ inputFormat === 'stream-json'
266
+ ? false
267
+ : argv['auto-merge-queued-messages'] !== false;
205
268
 
206
269
  // Start server like OpenCode does
207
270
  const server = Server.listen({ port: 0, hostname: '127.0.0.1' });
@@ -244,9 +307,36 @@ export async function runContinuousServerMode(
244
307
  // Track if we're currently processing a message
245
308
  let isProcessing = false;
246
309
  const pendingMessages = [];
310
+ let currentSystemMessage = systemMessage;
311
+ const currentAppendSystemMessage = appendSystemMessage;
247
312
 
248
313
  // Process messages from the queue
249
314
  const processMessage = async (message) => {
315
+ if (message.kind === 'interrupt') {
316
+ SessionPrompt.cancel(sessionID);
317
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
318
+ return;
319
+ }
320
+
321
+ if (message.kind === 'system') {
322
+ currentSystemMessage = message.system;
323
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
324
+ return;
325
+ }
326
+
327
+ // Permission approval reply (issue #271). Resolve a pending tool
328
+ // permission request without interrupting the in-flight turn — this must
329
+ // bypass the `isProcessing` queue, since the turn is blocked waiting for it.
330
+ if (message.kind === 'permission_response') {
331
+ Permission.respond({
332
+ sessionID,
333
+ permissionID: message.permissionID,
334
+ response: message.response,
335
+ });
336
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
337
+ return;
338
+ }
339
+
250
340
  if (isProcessing) {
251
341
  pendingMessages.push(message);
252
342
  return;
@@ -255,16 +345,9 @@ export async function runContinuousServerMode(
255
345
  isProcessing = true;
256
346
 
257
347
  // Output input confirmation in JSON format
258
- outputInput(
259
- {
260
- raw: message.raw || message.message,
261
- parsed: message,
262
- format: message.format || 'text',
263
- },
264
- compactJson
265
- );
348
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
266
349
 
267
- const messageText = message.message || 'hi';
350
+ const messageText = message.message ?? 'hi';
268
351
  const parts = [{ type: 'text', text: messageText }];
269
352
 
270
353
  // Create a promise to wait for this message to complete
@@ -290,8 +373,8 @@ export async function runContinuousServerMode(
290
373
  parts,
291
374
  model: { providerID, modelID },
292
375
  compactionModel,
293
- system: systemMessage,
294
- appendSystem: appendSystemMessage,
376
+ system: currentSystemMessage,
377
+ appendSystem: currentAppendSystemMessage,
295
378
  temperature,
296
379
  }),
297
380
  }
@@ -377,11 +460,15 @@ export async function runContinuousServerMode(
377
460
 
378
461
  // Create continuous stdin reader
379
462
  stdinReader = createContinuousStdinReader({
380
- interactive: isInteractive,
463
+ interactive: inputFormat === 'stream-json' ? false : isInteractive,
381
464
  autoMerge,
465
+ inputFormat,
382
466
  onMessage: (message) => {
383
467
  processMessage(message);
384
468
  },
469
+ onError: (error, line) => {
470
+ outputInputParseError(error, line, compactJson);
471
+ },
385
472
  });
386
473
 
387
474
  // Wait for stdin to end (EOF or close)
@@ -454,8 +541,12 @@ export async function runContinuousDirectMode(
454
541
  ) {
455
542
  // Check both CLI flag and environment variable for compact JSON mode
456
543
  const compactJson = argv['compact-json'] === true || config.compactJson;
544
+ const inputFormat = getInputFormat(argv);
457
545
  const isInteractive = argv.interactive !== false;
458
- const autoMerge = argv['auto-merge-queued-messages'] !== false;
546
+ const autoMerge =
547
+ inputFormat === 'stream-json'
548
+ ? false
549
+ : argv['auto-merge-queued-messages'] !== false;
459
550
 
460
551
  let unsub = null;
461
552
  let stdinReader = null;
@@ -483,9 +574,36 @@ export async function runContinuousDirectMode(
483
574
  // Track if we're currently processing a message
484
575
  let isProcessing = false;
485
576
  const pendingMessages = [];
577
+ let currentSystemMessage = systemMessage;
578
+ const currentAppendSystemMessage = appendSystemMessage;
486
579
 
487
580
  // Process messages from the queue
488
581
  const processMessage = async (message) => {
582
+ if (message.kind === 'interrupt') {
583
+ SessionPrompt.cancel(sessionID);
584
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
585
+ return;
586
+ }
587
+
588
+ if (message.kind === 'system') {
589
+ currentSystemMessage = message.system;
590
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
591
+ return;
592
+ }
593
+
594
+ // Permission approval reply (issue #271). Resolve a pending tool
595
+ // permission request without interrupting the in-flight turn — this must
596
+ // bypass the `isProcessing` queue, since the turn is blocked waiting for it.
597
+ if (message.kind === 'permission_response') {
598
+ Permission.respond({
599
+ sessionID,
600
+ permissionID: message.permissionID,
601
+ response: message.response,
602
+ });
603
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
604
+ return;
605
+ }
606
+
489
607
  if (isProcessing) {
490
608
  pendingMessages.push(message);
491
609
  return;
@@ -494,16 +612,9 @@ export async function runContinuousDirectMode(
494
612
  isProcessing = true;
495
613
 
496
614
  // Output input confirmation in JSON format
497
- outputInput(
498
- {
499
- raw: message.raw || message.message,
500
- parsed: message,
501
- format: message.format || 'text',
502
- },
503
- compactJson
504
- );
615
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
505
616
 
506
- const messageText = message.message || 'hi';
617
+ const messageText = message.message ?? 'hi';
507
618
  const parts = [{ type: 'text', text: messageText }];
508
619
 
509
620
  // Create a promise to wait for this message to complete
@@ -525,8 +636,8 @@ export async function runContinuousDirectMode(
525
636
  parts,
526
637
  model: { providerID, modelID },
527
638
  compactionModel,
528
- system: systemMessage,
529
- appendSystem: appendSystemMessage,
639
+ system: currentSystemMessage,
640
+ appendSystem: currentAppendSystemMessage,
530
641
  temperature,
531
642
  }).catch((error) => {
532
643
  hasError = true;
@@ -610,11 +721,15 @@ export async function runContinuousDirectMode(
610
721
 
611
722
  // Create continuous stdin reader
612
723
  stdinReader = createContinuousStdinReader({
613
- interactive: isInteractive,
724
+ interactive: inputFormat === 'stream-json' ? false : isInteractive,
614
725
  autoMerge,
726
+ inputFormat,
615
727
  onMessage: (message) => {
616
728
  processMessage(message);
617
729
  },
730
+ onError: (error, line) => {
731
+ outputInputParseError(error, line, compactJson);
732
+ },
618
733
  });
619
734
 
620
735
  // Wait for stdin to end (EOF or close)
@@ -81,6 +81,27 @@ export function createBusEventSubscription({
81
81
  }
82
82
  }
83
83
 
84
+ // Emit a JSON permission request when a tool needs approval (issue #271).
85
+ // The consumer replies with a `permission_response` frame over stdin which
86
+ // is routed to Permission.respond. No TUI is involved.
87
+ if (event.type === 'permission.updated') {
88
+ const permission = event.properties;
89
+ if (permission.sessionID !== sessionID) {
90
+ return;
91
+ }
92
+ eventHandler.output({
93
+ type: 'permission_request',
94
+ timestamp: Date.now(),
95
+ sessionID,
96
+ permissionID: permission.id,
97
+ callID: permission.callID,
98
+ tool: permission.type,
99
+ pattern: permission.pattern,
100
+ title: permission.title,
101
+ metadata: permission.metadata,
102
+ });
103
+ }
104
+
84
105
  // Handle session idle to know when to stop
85
106
  if (
86
107
  event.type === 'session.idle' &&
@@ -5,11 +5,13 @@
5
5
  export class InputQueue {
6
6
  constructor(options = {}) {
7
7
  this.queue = [];
8
+ this.inputFormat = options.inputFormat || 'text';
8
9
  this.autoMerge = options.autoMerge !== false; // enabled by default
9
10
  this.mergeDelayMs = options.mergeDelayMs || 50; // delay to wait for more lines
10
11
  this.pendingLines = [];
11
12
  this.mergeTimer = null;
12
13
  this.onMessage = options.onMessage || (() => {});
14
+ this.onError = options.onError || (() => {});
13
15
  this.interactive = options.interactive !== false; // enabled by default
14
16
  }
15
17
 
@@ -24,18 +26,51 @@ export class InputQueue {
24
26
  return null;
25
27
  }
26
28
 
29
+ if (this.inputFormat === 'stream-json') {
30
+ return parseStreamJsonInput(trimmed);
31
+ }
32
+
33
+ let parsed;
27
34
  try {
28
- const parsed = JSON.parse(trimmed);
29
- // If it has a message field, use it directly
30
- if (typeof parsed === 'object' && parsed !== null) {
31
- return parsed;
32
- }
33
- // Otherwise wrap it
34
- return { message: JSON.stringify(parsed) };
35
+ parsed = JSON.parse(trimmed);
35
36
  } catch (_e) {
36
37
  // Not JSON, treat as plain text message
37
38
  return { message: trimmed };
38
39
  }
40
+
41
+ // If it has a message field, use it directly
42
+ if (typeof parsed === 'object' && parsed !== null) {
43
+ // Permission approval reply (issue #271) — normalize to a `kind` frame
44
+ // so it is routed to Permission.respond, mirroring stream-json mode.
45
+ if (parsed.type === 'permission_response') {
46
+ const permissionID = parsed.permissionID ?? parsed.permission_id;
47
+ const response = parsed.response;
48
+ if (typeof permissionID !== 'string' || !permissionID) {
49
+ throw new Error(
50
+ 'Invalid permission_response: expected string "permissionID"'
51
+ );
52
+ }
53
+ if (
54
+ response !== 'once' &&
55
+ response !== 'always' &&
56
+ response !== 'reject'
57
+ ) {
58
+ throw new Error(
59
+ 'Invalid permission_response: "response" must be "once", "always" or "reject"'
60
+ );
61
+ }
62
+ return {
63
+ kind: 'permission_response',
64
+ permissionID,
65
+ response,
66
+ raw: trimmed,
67
+ parsed,
68
+ };
69
+ }
70
+ return parsed;
71
+ }
72
+ // Otherwise wrap it
73
+ return { message: JSON.stringify(parsed) };
39
74
  }
40
75
 
41
76
  /**
@@ -43,6 +78,11 @@ export class InputQueue {
43
78
  * @param {string} line - Input line
44
79
  */
45
80
  addLine(line) {
81
+ if (this.inputFormat === 'stream-json') {
82
+ this.queueLine(line);
83
+ return;
84
+ }
85
+
46
86
  if (!this.interactive && !line.trim().startsWith('{')) {
47
87
  // In non-interactive mode, only accept JSON
48
88
  return;
@@ -54,11 +94,7 @@ export class InputQueue {
54
94
  this.scheduleMerge();
55
95
  } else {
56
96
  // No merging, queue immediately
57
- const parsed = this.parseInput(line);
58
- if (parsed) {
59
- this.queue.push(parsed);
60
- this.notifyMessage(parsed);
61
- }
97
+ this.queueLine(line);
62
98
  }
63
99
  }
64
100
 
@@ -130,6 +166,33 @@ export class InputQueue {
130
166
  }
131
167
  }
132
168
 
169
+ /**
170
+ * Notify listener of an input parse error
171
+ * @param {Error} error - Parse error
172
+ * @param {string} line - Raw input line
173
+ */
174
+ notifyError(error, line) {
175
+ if (this.onError) {
176
+ this.onError(error, line);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Parse and enqueue one complete input line
182
+ * @param {string} line - Raw input line
183
+ */
184
+ queueLine(line) {
185
+ try {
186
+ const parsed = this.parseInput(line);
187
+ if (parsed) {
188
+ this.queue.push(parsed);
189
+ this.notifyMessage(parsed);
190
+ }
191
+ } catch (error) {
192
+ this.notifyError(error, line);
193
+ }
194
+ }
195
+
133
196
  /**
134
197
  * Get queue size
135
198
  * @returns {number}
@@ -139,6 +202,163 @@ export class InputQueue {
139
202
  }
140
203
  }
141
204
 
205
+ function extractContentText(content) {
206
+ if (typeof content === 'string') {
207
+ return content;
208
+ }
209
+
210
+ if (Array.isArray(content)) {
211
+ const parts = [];
212
+ for (const part of content) {
213
+ if (typeof part === 'string') {
214
+ parts.push(part);
215
+ } else if (part && typeof part === 'object') {
216
+ if (typeof part.text === 'string') {
217
+ parts.push(part.text);
218
+ } else if (typeof part.content === 'string') {
219
+ parts.push(part.content);
220
+ }
221
+ }
222
+ }
223
+ return parts.length > 0 ? parts.join('\n') : null;
224
+ }
225
+
226
+ if (content && typeof content === 'object') {
227
+ if (typeof content.text === 'string') {
228
+ return content.text;
229
+ }
230
+ if ('content' in content) {
231
+ return extractContentText(content.content);
232
+ }
233
+ }
234
+
235
+ return null;
236
+ }
237
+
238
+ function extractFrameText(frame) {
239
+ if (typeof frame.message === 'string') {
240
+ return frame.message;
241
+ }
242
+
243
+ if (frame.message && typeof frame.message === 'object') {
244
+ const messageText = extractContentText(frame.message.content);
245
+ if (messageText !== null) {
246
+ return messageText;
247
+ }
248
+ if (typeof frame.message.text === 'string') {
249
+ return frame.message.text;
250
+ }
251
+ }
252
+
253
+ const contentText = extractContentText(frame.content);
254
+ if (contentText !== null) {
255
+ return contentText;
256
+ }
257
+
258
+ if (typeof frame.text === 'string') {
259
+ return frame.text;
260
+ }
261
+
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Parse one Claude-compatible stream-json input frame.
267
+ * @param {string} input - One JSONL frame
268
+ * @returns {object} Normalized queue message
269
+ */
270
+ export function parseStreamJsonInput(input) {
271
+ let frame;
272
+ try {
273
+ frame = JSON.parse(input);
274
+ } catch (error) {
275
+ throw new Error(
276
+ `Invalid stream-json input frame: ${
277
+ error instanceof Error ? error.message : String(error)
278
+ }`
279
+ );
280
+ }
281
+
282
+ if (!frame || typeof frame !== 'object' || Array.isArray(frame)) {
283
+ throw new Error('Invalid stream-json input frame: expected JSON object');
284
+ }
285
+
286
+ const type = frame.type;
287
+
288
+ if (type === 'interrupt') {
289
+ return {
290
+ kind: 'interrupt',
291
+ raw: input,
292
+ parsed: frame,
293
+ format: 'stream-json',
294
+ inputType: type,
295
+ };
296
+ }
297
+
298
+ if (type === 'system') {
299
+ const system = extractFrameText(frame);
300
+ if (system === null) {
301
+ throw new Error(
302
+ 'Invalid stream-json system frame: expected content text'
303
+ );
304
+ }
305
+ return {
306
+ kind: 'system',
307
+ system,
308
+ raw: input,
309
+ parsed: frame,
310
+ format: 'stream-json',
311
+ inputType: type,
312
+ };
313
+ }
314
+
315
+ // Permission approval reply (issue #271). Resolves a pending
316
+ // `permission_request` emitted by the agent. `response` is one of
317
+ // `once` | `always` | `reject`.
318
+ if (type === 'permission_response') {
319
+ const permissionID = frame.permissionID ?? frame.permission_id;
320
+ const response = frame.response;
321
+ if (typeof permissionID !== 'string' || !permissionID) {
322
+ throw new Error(
323
+ 'Invalid permission_response frame: expected string "permissionID"'
324
+ );
325
+ }
326
+ if (response !== 'once' && response !== 'always' && response !== 'reject') {
327
+ throw new Error(
328
+ 'Invalid permission_response frame: "response" must be "once", "always" or "reject"'
329
+ );
330
+ }
331
+ return {
332
+ kind: 'permission_response',
333
+ permissionID,
334
+ response,
335
+ raw: input,
336
+ parsed: frame,
337
+ format: 'stream-json',
338
+ inputType: type,
339
+ };
340
+ }
341
+
342
+ if (type === 'user' || type === 'user_prompt' || type === undefined) {
343
+ const message = extractFrameText(frame);
344
+ if (message === null) {
345
+ throw new Error(
346
+ 'Invalid stream-json user frame: expected message content text'
347
+ );
348
+ }
349
+ return {
350
+ kind: 'message',
351
+ message,
352
+ raw: input,
353
+ parsed: frame,
354
+ format: 'stream-json',
355
+ inputType: type || 'message',
356
+ };
357
+ }
358
+
359
+ throw new Error(`Unsupported stream-json input frame type: ${String(type)}`);
360
+ }
361
+
142
362
  /**
143
363
  * Create a continuous stdin reader that queues input lines
144
364
  * @param {object} options - Options for the reader
package/src/cli/output.ts CHANGED
@@ -232,7 +232,7 @@ export function outputInput(
232
232
  input: {
233
233
  raw: string;
234
234
  parsed?: unknown;
235
- format?: 'json' | 'text';
235
+ format?: 'json' | 'text' | 'stream-json';
236
236
  [key: string]: unknown;
237
237
  },
238
238
  compact?: boolean
@@ -29,6 +29,19 @@ export function buildRunOptions(yargs, defaultOptions = {}) {
29
29
  default: 'opencode',
30
30
  choices: ['opencode', 'claude'],
31
31
  })
32
+ .option('output-format', {
33
+ type: 'string',
34
+ description:
35
+ 'Claude-compatible output format alias: "json" (OpenCode JSON) or "stream-json" (Claude NDJSON)',
36
+ choices: ['json', 'stream-json'],
37
+ })
38
+ .option('input-format', {
39
+ type: 'string',
40
+ description:
41
+ 'Input format: "text" (default) or Claude-compatible "stream-json" JSONL frames',
42
+ default: 'text',
43
+ choices: ['text', 'stream-json'],
44
+ })
32
45
  .option('system-message', {
33
46
  type: 'string',
34
47
  description: 'Full override of the system message',
@@ -135,6 +148,13 @@ export function buildRunOptions(yargs, defaultOptions = {}) {
135
148
  argv['no-fork'] = true;
136
149
  argv.noFork = true;
137
150
  }
151
+ if (argv.outputFormat === 'stream-json') {
152
+ argv['json-standard'] = 'claude';
153
+ argv.jsonStandard = 'claude';
154
+ } else if (argv.outputFormat === 'json') {
155
+ argv['json-standard'] = 'opencode';
156
+ argv.jsonStandard = 'opencode';
157
+ }
138
158
  }, true)
139
159
  : parser;
140
160
 
@@ -191,5 +211,28 @@ export function buildRunOptions(yargs, defaultOptions = {}) {
191
211
  type: 'number',
192
212
  description:
193
213
  'Override the temperature for model completions. When not set, the default per-model temperature is used.',
214
+ })
215
+ .option('read-only', {
216
+ type: 'boolean',
217
+ description:
218
+ 'Enforceable read-only / planning mode. Disables all filesystem-mutating and shell tools (bash, edit, write, multiedit, patch) so the agent can only read, search and plan. Maps to agent-commander --read-only.',
219
+ default: false,
220
+ })
221
+ .option('disable-tools', {
222
+ type: 'string',
223
+ description:
224
+ 'Comma-separated list of tool ids to disable (e.g. "bash,write,edit"). Disabled tools are never exposed to the model and are rejected if invoked via batch.',
225
+ })
226
+ .option('permission-mode', {
227
+ type: 'string',
228
+ choices: ['auto', 'plan', 'readonly', 'ask'],
229
+ description:
230
+ 'Permission policy for mutating tools. "auto" (default) allows everything; "plan" allows read-only shell and asks before mutations; "readonly" denies all mutations; "ask" asks before every mutating tool. Approvals are driven over JSON (permission_request / permission_response).',
231
+ default: 'auto',
232
+ })
233
+ .option('permission', {
234
+ type: 'string',
235
+ description:
236
+ 'OpenCode-compatible permission override as JSON, merged on top of --permission-mode. Example: \'{"edit":"ask","bash":{"git push*":"ask","*":"allow"},"webfetch":"allow"}\'.',
194
237
  });
195
238
  }