@link-assistant/agent 0.23.0 → 0.24.1

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
@@ -286,6 +286,10 @@ Options:
286
286
  Default: opencode/minimax-m2.5-free
287
287
  --json-standard JSON output format standard
288
288
  Choices: "opencode" (default), "claude" (experimental)
289
+ --output-format Alias for JSON output format
290
+ Choices: "json" (OpenCode), "stream-json" (Claude NDJSON)
291
+ --input-format Input format
292
+ Choices: "text" (default), "stream-json" (Claude JSONL)
289
293
  --use-existing-claude-oauth Use existing Claude OAuth credentials
290
294
  from ~/.claude/.credentials.json
291
295
  --system-message Full override of the system message
@@ -293,6 +297,18 @@ Options:
293
297
  --append-system-message Append to the default system message
294
298
  --append-system-message-file Append to the default system message from file
295
299
 
300
+ Permission Options:
301
+ --read-only Enforceable read-only / planning mode.
302
+ Disables all filesystem-mutating and shell
303
+ tools (bash, edit, write, multiedit, patch)
304
+ so the agent can only read, search and plan.
305
+ Env: LINK_ASSISTANT_AGENT_READ_ONLY=true
306
+ --disable-tools <list> Comma-separated list of tool ids to disable
307
+ (e.g. "bash,write,edit"). Disabled tools are
308
+ never exposed to the model and are rejected if
309
+ invoked via batch.
310
+ Env: LINK_ASSISTANT_AGENT_DISABLE_TOOLS
311
+
296
312
  Stdin Mode Options:
297
313
  -p, --prompt Direct prompt (bypasses stdin reading)
298
314
  --disable-stdin Disable stdin streaming (requires --prompt)
@@ -378,6 +394,21 @@ echo "your message here" | agent
378
394
  }
379
395
  ```
380
396
 
397
+ **Live `stream-json`:**
398
+
399
+ For bidirectional drivers, use:
400
+
401
+ ```bash
402
+ agent --input-format stream-json --output-format stream-json
403
+ ```
404
+
405
+ The live contract supports `user_prompt`, `system`, `interrupt`, and
406
+ `permission_response` frames on stdin. It emits replay acknowledgements for
407
+ consumed user frames and an explicit idle/turn-boundary event
408
+ (`session_idle` in OpenCode output, `idle` in Claude output). See
409
+ [docs/stdin-mode.md](../docs/stdin-mode.md#live-stream-json-contract) for the
410
+ session resume and steering semantics.
411
+
381
412
  ## Supported Tools
382
413
 
383
414
  All 13 tools are **enabled by default** with **no configuration required**. See [TOOLS.md](../TOOLS.md) for complete documentation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
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",
@@ -10,6 +10,8 @@ import { Session } from '../session/index.ts';
10
10
  import { SessionPrompt } from '../session/prompt.ts';
11
11
  import { createEventHandler, serializeOutput } from '../json-standard/index.ts';
12
12
  import { createContinuousStdinReader } from './input-queue.js';
13
+ import { outputBusEvent } from './event-handler.js';
14
+ import { Permission } from '../permission/index.ts';
13
15
  import { Log } from '../util/log.ts';
14
16
  import { config } from '../config/config.ts';
15
17
  import { createVerboseFetch } from '../util/verbose-fetch.ts';
@@ -323,6 +325,19 @@ export async function runContinuousServerMode(
323
325
  return;
324
326
  }
325
327
 
328
+ // Permission approval reply (issue #271). Resolve a pending tool
329
+ // permission request without interrupting the in-flight turn — this must
330
+ // bypass the `isProcessing` queue, since the turn is blocked waiting for it.
331
+ if (message.kind === 'permission_response') {
332
+ Permission.respond({
333
+ sessionID,
334
+ permissionID: message.permissionID,
335
+ response: message.response,
336
+ });
337
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
338
+ return;
339
+ }
340
+
326
341
  if (isProcessing) {
327
342
  pendingMessages.push(message);
328
343
  return;
@@ -386,62 +401,14 @@ export async function runContinuousServerMode(
386
401
 
387
402
  // Subscribe to all bus events and output in selected format
388
403
  unsub = Bus.subscribeAll((event) => {
389
- if (event.type === 'message.part.updated') {
390
- const part = event.properties.part;
391
- if (part.sessionID !== sessionID) {
392
- return;
393
- }
394
-
395
- if (part.type === 'step-start') {
396
- eventHandler.output({
397
- type: 'step_start',
398
- timestamp: Date.now(),
399
- sessionID,
400
- part,
401
- });
402
- }
403
-
404
- if (part.type === 'step-finish') {
405
- eventHandler.output({
406
- type: 'step_finish',
407
- timestamp: Date.now(),
408
- sessionID,
409
- part,
410
- });
411
- }
412
-
413
- if (part.type === 'text' && part.time?.end) {
414
- eventHandler.output({
415
- type: 'text',
416
- timestamp: Date.now(),
417
- sessionID,
418
- part,
419
- });
420
- }
421
-
422
- if (part.type === 'tool') {
423
- eventHandler.output({
424
- type: 'tool_use',
425
- timestamp: Date.now(),
426
- sessionID,
427
- part,
428
- });
429
- }
430
- }
431
-
432
- if (event.type === 'session.error') {
433
- const props = event.properties;
434
- if (props.sessionID !== sessionID || !props.error) {
435
- return;
436
- }
437
- hasError = true;
438
- eventHandler.output({
439
- type: 'error',
440
- timestamp: Date.now(),
441
- sessionID,
442
- error: props.error,
443
- });
444
- }
404
+ outputBusEvent({
405
+ event,
406
+ sessionID,
407
+ eventHandler,
408
+ onError: () => {
409
+ hasError = true;
410
+ },
411
+ });
445
412
  });
446
413
 
447
414
  // Create continuous stdin reader
@@ -577,6 +544,19 @@ export async function runContinuousDirectMode(
577
544
  return;
578
545
  }
579
546
 
547
+ // Permission approval reply (issue #271). Resolve a pending tool
548
+ // permission request without interrupting the in-flight turn — this must
549
+ // bypass the `isProcessing` queue, since the turn is blocked waiting for it.
550
+ if (message.kind === 'permission_response') {
551
+ Permission.respond({
552
+ sessionID,
553
+ permissionID: message.permissionID,
554
+ response: message.response,
555
+ });
556
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
557
+ return;
558
+ }
559
+
580
560
  if (isProcessing) {
581
561
  pendingMessages.push(message);
582
562
  return;
@@ -634,62 +614,14 @@ export async function runContinuousDirectMode(
634
614
 
635
615
  // Subscribe to all bus events and output in selected format
636
616
  unsub = Bus.subscribeAll((event) => {
637
- if (event.type === 'message.part.updated') {
638
- const part = event.properties.part;
639
- if (part.sessionID !== sessionID) {
640
- return;
641
- }
642
-
643
- if (part.type === 'step-start') {
644
- eventHandler.output({
645
- type: 'step_start',
646
- timestamp: Date.now(),
647
- sessionID,
648
- part,
649
- });
650
- }
651
-
652
- if (part.type === 'step-finish') {
653
- eventHandler.output({
654
- type: 'step_finish',
655
- timestamp: Date.now(),
656
- sessionID,
657
- part,
658
- });
659
- }
660
-
661
- if (part.type === 'text' && part.time?.end) {
662
- eventHandler.output({
663
- type: 'text',
664
- timestamp: Date.now(),
665
- sessionID,
666
- part,
667
- });
668
- }
669
-
670
- if (part.type === 'tool') {
671
- eventHandler.output({
672
- type: 'tool_use',
673
- timestamp: Date.now(),
674
- sessionID,
675
- part,
676
- });
677
- }
678
- }
679
-
680
- if (event.type === 'session.error') {
681
- const props = event.properties;
682
- if (props.sessionID !== sessionID || !props.error) {
683
- return;
684
- }
685
- hasError = true;
686
- eventHandler.output({
687
- type: 'error',
688
- timestamp: Date.now(),
689
- sessionID,
690
- error: props.error,
691
- });
692
- }
617
+ outputBusEvent({
618
+ event,
619
+ sessionID,
620
+ eventHandler,
621
+ onError: () => {
622
+ hasError = true;
623
+ },
624
+ });
693
625
  });
694
626
 
695
627
  // Create continuous stdin reader
@@ -26,84 +26,126 @@ export function createBusEventSubscription({
26
26
  });
27
27
 
28
28
  const unsub = Bus.subscribeAll((event) => {
29
- // Output events in selected JSON format
30
- if (event.type === 'message.part.updated') {
31
- const part = event.properties.part;
32
- if (part.sessionID !== sessionID) {
33
- return;
34
- }
35
-
36
- // Output different event types
37
- if (part.type === 'step-start') {
38
- eventHandler.output({
39
- type: 'step_start',
40
- timestamp: Date.now(),
41
- sessionID,
42
- part,
43
- });
44
- }
29
+ outputBusEvent({
30
+ event,
31
+ sessionID,
32
+ eventHandler,
33
+ onError,
34
+ onIdle: idleResolve,
35
+ });
36
+ });
45
37
 
46
- if (part.type === 'step-finish') {
47
- eventHandler.output({
48
- type: 'step_finish',
49
- timestamp: Date.now(),
50
- sessionID,
51
- part,
52
- });
53
- }
38
+ return { unsub, idlePromise };
39
+ }
54
40
 
55
- if (part.type === 'text' && part.time?.end) {
56
- eventHandler.output({
57
- type: 'text',
58
- timestamp: Date.now(),
59
- sessionID,
60
- part,
61
- });
62
- }
41
+ export function outputBusEvent({
42
+ event,
43
+ sessionID,
44
+ eventHandler,
45
+ onError,
46
+ onIdle,
47
+ }) {
48
+ // Output events in selected JSON format
49
+ if (event.type === 'message.part.updated') {
50
+ const part = event.properties.part;
51
+ if (part.sessionID !== sessionID) {
52
+ return;
53
+ }
63
54
 
64
- if (part.type === 'tool') {
65
- eventHandler.output({
66
- type: 'tool_use',
67
- timestamp: Date.now(),
68
- sessionID,
69
- part,
70
- });
55
+ // Output different event types
56
+ if (part.type === 'step-start') {
57
+ eventHandler.output({
58
+ type: 'step_start',
59
+ timestamp: Date.now(),
60
+ sessionID,
61
+ part,
62
+ });
63
+ }
71
64
 
72
- // If tool failed, also output an error event
73
- if (part.state?.status === 'error') {
74
- eventHandler.output({
75
- type: 'error',
76
- timestamp: Date.now(),
77
- sessionID,
78
- error: part.state.error || 'Tool execution failed',
79
- });
80
- }
81
- }
65
+ if (part.type === 'step-finish') {
66
+ eventHandler.output({
67
+ type: 'step_finish',
68
+ timestamp: Date.now(),
69
+ sessionID,
70
+ part,
71
+ });
82
72
  }
83
73
 
84
- // Handle session idle to know when to stop
85
- if (
86
- event.type === 'session.idle' &&
87
- event.properties.sessionID === sessionID
88
- ) {
89
- idleResolve();
74
+ if (part.type === 'text' && part.time?.end) {
75
+ eventHandler.output({
76
+ type: 'text',
77
+ timestamp: Date.now(),
78
+ sessionID,
79
+ part,
80
+ });
90
81
  }
91
82
 
92
- // Handle errors
93
- if (event.type === 'session.error') {
94
- const props = event.properties;
95
- if (props.sessionID !== sessionID || !props.error) {
96
- return;
97
- }
98
- onError();
83
+ if (part.type === 'tool') {
99
84
  eventHandler.output({
100
- type: 'error',
85
+ type: 'tool_use',
101
86
  timestamp: Date.now(),
102
87
  sessionID,
103
- error: props.error,
88
+ part,
104
89
  });
90
+
91
+ // If tool failed, also output an error event
92
+ if (part.state?.status === 'error') {
93
+ eventHandler.output({
94
+ type: 'error',
95
+ timestamp: Date.now(),
96
+ sessionID,
97
+ error: part.state.error || 'Tool execution failed',
98
+ });
99
+ }
105
100
  }
106
- });
101
+ }
107
102
 
108
- return { unsub, idlePromise };
103
+ // Emit a JSON permission request when a tool needs approval (issue #271).
104
+ // The consumer replies with a `permission_response` frame over stdin which
105
+ // is routed to Permission.respond. No TUI is involved.
106
+ if (event.type === 'permission.updated') {
107
+ const permission = event.properties;
108
+ if (permission.sessionID !== sessionID) {
109
+ return;
110
+ }
111
+ eventHandler.output({
112
+ type: 'permission_request',
113
+ timestamp: Date.now(),
114
+ sessionID,
115
+ permissionID: permission.id,
116
+ callID: permission.callID,
117
+ tool: permission.type,
118
+ pattern: permission.pattern,
119
+ title: permission.title,
120
+ metadata: permission.metadata,
121
+ });
122
+ }
123
+
124
+ // Handle session idle to know when to stop
125
+ if (
126
+ event.type === 'session.idle' &&
127
+ event.properties.sessionID === sessionID
128
+ ) {
129
+ eventHandler.output({
130
+ type: 'session_idle',
131
+ timestamp: Date.now(),
132
+ sessionID,
133
+ });
134
+ onIdle?.();
135
+ }
136
+
137
+ // Handle errors
138
+ if (event.type === 'session.error') {
139
+ const props = event.properties;
140
+ if (props.sessionID !== sessionID || !props.error) {
141
+ return;
142
+ }
143
+ onError();
144
+ eventHandler.output({
145
+ type: 'error',
146
+ timestamp: Date.now(),
147
+ sessionID,
148
+ error: props.error,
149
+ });
150
+ }
109
151
  }
@@ -30,18 +30,47 @@ export class InputQueue {
30
30
  return parseStreamJsonInput(trimmed);
31
31
  }
32
32
 
33
+ let parsed;
33
34
  try {
34
- const parsed = JSON.parse(trimmed);
35
- // If it has a message field, use it directly
36
- if (typeof parsed === 'object' && parsed !== null) {
37
- return parsed;
38
- }
39
- // Otherwise wrap it
40
- return { message: JSON.stringify(parsed) };
35
+ parsed = JSON.parse(trimmed);
41
36
  } catch (_e) {
42
37
  // Not JSON, treat as plain text message
43
38
  return { message: trimmed };
44
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) };
45
74
  }
46
75
 
47
76
  /**
@@ -283,6 +312,33 @@ export function parseStreamJsonInput(input) {
283
312
  };
284
313
  }
285
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
+
286
342
  if (type === 'user' || type === 'user_prompt' || type === undefined) {
287
343
  const message = extractFrameText(frame);
288
344
  if (message === null) {
@@ -211,5 +211,28 @@ export function buildRunOptions(yargs, defaultOptions = {}) {
211
211
  type: 'number',
212
212
  description:
213
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"}\'.',
214
237
  });
215
238
  }
@@ -52,6 +52,10 @@ export interface AgentConfig {
52
52
  mcpDefaultToolCallTimeout: number;
53
53
  mcpMaxToolCallTimeout: number;
54
54
  verifyImagesAtReadTool: boolean;
55
+ readOnly: boolean;
56
+ disableTools: string;
57
+ permissionMode: string;
58
+ permission: string;
55
59
  }
56
60
 
57
61
  // Fallback helpers for when config is not yet initialized (early imports/tests)
@@ -132,6 +136,10 @@ function defaultConfig(): AgentConfig {
132
136
  })(),
133
137
  verifyImagesAtReadTool:
134
138
  getEnvStr('LINK_ASSISTANT_AGENT_VERIFY_IMAGES_AT_READ_TOOL') !== 'false',
139
+ readOnly: truthyEnv('LINK_ASSISTANT_AGENT_READ_ONLY'),
140
+ disableTools: getEnvStr('LINK_ASSISTANT_AGENT_DISABLE_TOOLS') ?? '',
141
+ permissionMode: getEnvStr('LINK_ASSISTANT_AGENT_PERMISSION_MODE') ?? 'auto',
142
+ permission: getEnvStr('LINK_ASSISTANT_AGENT_PERMISSION') ?? '',
135
143
  };
136
144
  }
137
145
 
@@ -240,6 +248,31 @@ function buildAgentConfigOptions({
240
248
  type: 'boolean',
241
249
  description: 'Verify images when using the read tool',
242
250
  default: getenv('LINK_ASSISTANT_AGENT_VERIFY_IMAGES_AT_READ_TOOL', true),
251
+ })
252
+ .option('read-only', {
253
+ type: 'boolean',
254
+ description:
255
+ 'Enforceable read-only / planning mode. Disables all filesystem-mutating and shell tools (bash, edit, write, multiedit, patch).',
256
+ default: getenv('LINK_ASSISTANT_AGENT_READ_ONLY', false),
257
+ })
258
+ .option('disable-tools', {
259
+ type: 'string',
260
+ description:
261
+ '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.',
262
+ default: getenv('LINK_ASSISTANT_AGENT_DISABLE_TOOLS', ''),
263
+ })
264
+ .option('permission-mode', {
265
+ type: 'string',
266
+ choices: ['auto', 'plan', 'readonly', 'ask'],
267
+ description:
268
+ '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).',
269
+ default: getenv('LINK_ASSISTANT_AGENT_PERMISSION_MODE', 'auto'),
270
+ })
271
+ .option('permission', {
272
+ type: 'string',
273
+ description:
274
+ 'OpenCode-compatible permission override as JSON, merged on top of --permission-mode. Example: \'{"edit":"ask","bash":{"git push*":"ask","*":"allow"},"webfetch":"allow"}\'.',
275
+ default: getenv('LINK_ASSISTANT_AGENT_PERMISSION', ''),
243
276
  });
244
277
  }
245
278
 
@@ -292,6 +325,10 @@ export function initConfig(argv?: string[]): AgentConfig {
292
325
  mcpDefaultToolCallTimeout: parsed.mcpDefaultToolCallTimeout ?? 120000,
293
326
  mcpMaxToolCallTimeout: parsed.mcpMaxToolCallTimeout ?? 600000,
294
327
  verifyImagesAtReadTool: parsed.verifyImagesAtReadTool ?? true,
328
+ readOnly: parsed.readOnly ?? false,
329
+ disableTools: parsed.disableTools ?? '',
330
+ permissionMode: parsed.permissionMode ?? 'auto',
331
+ permission: parsed.permission ?? '',
295
332
  });
296
333
 
297
334
  // Propagate verbose to env var for subprocess resilience (issue #227).
@@ -18,7 +18,13 @@ export type JsonStandard = 'opencode' | 'claude';
18
18
  * OpenCode JSON event types
19
19
  */
20
20
  export interface OpenCodeEvent {
21
- type: 'step_start' | 'step_finish' | 'text' | 'tool_use' | 'error';
21
+ type:
22
+ | 'step_start'
23
+ | 'step_finish'
24
+ | 'text'
25
+ | 'tool_use'
26
+ | 'error'
27
+ | 'session_idle';
22
28
  timestamp: number;
23
29
  sessionID: string;
24
30
  part?: Record<string, unknown>;
@@ -29,7 +35,7 @@ export interface OpenCodeEvent {
29
35
  * Claude JSON event types (stream-json format)
30
36
  */
31
37
  export interface ClaudeEvent {
32
- type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result';
38
+ type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'idle';
33
39
  timestamp?: string;
34
40
  session_id?: string;
35
41
  role?: 'assistant' | 'user';
@@ -126,6 +132,13 @@ export function convertOpenCodeToClaude(
126
132
  duration_ms: event.timestamp - startTime,
127
133
  };
128
134
 
135
+ case 'session_idle':
136
+ return {
137
+ type: 'idle',
138
+ timestamp,
139
+ session_id,
140
+ };
141
+
129
142
  case 'error':
130
143
  return {
131
144
  type: 'result',