@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 +31 -0
- package/package.json +1 -1
- package/src/cli/continuous-mode.js +44 -112
- package/src/cli/event-handler.js +108 -66
- package/src/cli/input-queue.js +63 -7
- package/src/cli/run-options.js +23 -0
- package/src/config/config.ts +37 -0
- package/src/json-standard/index.ts +15 -2
- package/src/permission/index.ts +564 -0
- package/src/session/prompt.ts +6 -0
- package/src/session/system.ts +14 -0
- package/src/tool/bash.ts +41 -1
- package/src/tool/batch.ts +8 -0
- package/src/tool/edit.ts +12 -1
- package/src/tool/patch.ts +19 -1
- package/src/tool/registry.ts +36 -0
- package/src/tool/webfetch.ts +12 -1
- package/src/tool/write.ts +13 -1
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
|
@@ -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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
package/src/cli/event-handler.js
CHANGED
|
@@ -26,84 +26,126 @@ export function createBusEventSubscription({
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
const unsub = Bus.subscribeAll((event) => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
type: 'step_finish',
|
|
49
|
-
timestamp: Date.now(),
|
|
50
|
-
sessionID,
|
|
51
|
-
part,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
38
|
+
return { unsub, idlePromise };
|
|
39
|
+
}
|
|
54
40
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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: '
|
|
85
|
+
type: 'tool_use',
|
|
101
86
|
timestamp: Date.now(),
|
|
102
87
|
sessionID,
|
|
103
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/input-queue.js
CHANGED
|
@@ -30,18 +30,47 @@ export class InputQueue {
|
|
|
30
30
|
return parseStreamJsonInput(trimmed);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
let parsed;
|
|
33
34
|
try {
|
|
34
|
-
|
|
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) {
|
package/src/cli/run-options.js
CHANGED
|
@@ -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
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -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:
|
|
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',
|