@link-assistant/agent 0.8.7 → 0.8.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.8.7",
3
+ "version": "0.8.10",
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",
@@ -18,7 +18,7 @@ export const ExportCommand = cmd({
18
18
  handler: async (args) => {
19
19
  await bootstrap(process.cwd(), async () => {
20
20
  let sessionID = args.sessionID;
21
- process.stderr.write(`Exporting session: ${sessionID ?? 'latest'}`);
21
+ process.stdout.write(`Exporting session: ${sessionID ?? 'latest'}`);
22
22
 
23
23
  if (!sessionID) {
24
24
  UI.empty();
@@ -11,6 +11,8 @@ import { SessionPrompt } from '../session/prompt.ts';
11
11
  import { createEventHandler } from '../json-standard/index.ts';
12
12
  import { createContinuousStdinReader } from './input-queue.js';
13
13
  import { Log } from '../util/log.ts';
14
+ import { Flag } from '../flag/flag.ts';
15
+ import { outputStatus, outputError, outputInput } from './output.ts';
14
16
 
15
17
  // Shared error tracking
16
18
  let hasError = false;
@@ -31,17 +33,8 @@ export function getHasError() {
31
33
  return hasError;
32
34
  }
33
35
 
34
- /**
35
- * Output JSON status message to stderr
36
- * @param {object} status - Status object to output
37
- * @param {boolean} compact - If true, output compact JSON (single line)
38
- */
39
- function outputStatus(status, compact = false) {
40
- const json = compact
41
- ? JSON.stringify(status)
42
- : JSON.stringify(status, null, 2);
43
- console.error(json);
44
- }
36
+ // outputStatus is now imported from './output.ts'
37
+ // It outputs to stdout for non-error messages, stderr for errors
45
38
 
46
39
  // Logger for resume operations
47
40
  const log = Log.create({ service: 'resume' });
@@ -84,9 +77,8 @@ export async function resolveResumeSession(argv, compactJson) {
84
77
  }
85
78
 
86
79
  if (!mostRecentSession) {
87
- outputStatus(
80
+ outputError(
88
81
  {
89
- type: 'error',
90
82
  errorType: 'SessionNotFound',
91
83
  message:
92
84
  'No existing sessions found to continue. Start a new session first.',
@@ -110,9 +102,8 @@ export async function resolveResumeSession(argv, compactJson) {
110
102
  existingSession = await Session.get(targetSessionID);
111
103
  } catch (_error) {
112
104
  // Session.get throws an error when the session doesn't exist
113
- outputStatus(
105
+ outputError(
114
106
  {
115
- type: 'error',
116
107
  errorType: 'SessionNotFound',
117
108
  message: `Session not found: ${targetSessionID}`,
118
109
  },
@@ -122,9 +113,8 @@ export async function resolveResumeSession(argv, compactJson) {
122
113
  }
123
114
 
124
115
  if (!existingSession) {
125
- outputStatus(
116
+ outputError(
126
117
  {
127
- type: 'error',
128
118
  errorType: 'SessionNotFound',
129
119
  message: `Session not found: ${targetSessionID}`,
130
120
  },
@@ -204,7 +194,8 @@ export async function runContinuousServerMode(
204
194
  appendSystemMessage,
205
195
  jsonStandard
206
196
  ) {
207
- const compactJson = argv['compact-json'] === true;
197
+ // Check both CLI flag and environment variable for compact JSON mode
198
+ const compactJson = argv['compact-json'] === true || Flag.COMPACT_JSON();
208
199
  const isInteractive = argv.interactive !== false;
209
200
  const autoMerge = argv['auto-merge-queued-messages'] !== false;
210
201
 
@@ -255,6 +246,17 @@ export async function runContinuousServerMode(
255
246
  }
256
247
 
257
248
  isProcessing = true;
249
+
250
+ // Output input confirmation in JSON format
251
+ outputInput(
252
+ {
253
+ raw: message.raw || message.message,
254
+ parsed: message,
255
+ format: message.format || 'text',
256
+ },
257
+ compactJson
258
+ );
259
+
258
260
  const messageText = message.message || 'hi';
259
261
  const parts = [{ type: 'text', text: messageText }];
260
262
 
@@ -339,7 +341,7 @@ export async function runContinuousServerMode(
339
341
  });
340
342
  }
341
343
 
342
- if (part.type === 'tool' && part.state.status === 'completed') {
344
+ if (part.type === 'tool') {
343
345
  eventHandler.output({
344
346
  type: 'tool_use',
345
347
  timestamp: Date.now(),
@@ -427,7 +429,8 @@ export async function runContinuousDirectMode(
427
429
  appendSystemMessage,
428
430
  jsonStandard
429
431
  ) {
430
- const compactJson = argv['compact-json'] === true;
432
+ // Check both CLI flag and environment variable for compact JSON mode
433
+ const compactJson = argv['compact-json'] === true || Flag.COMPACT_JSON();
431
434
  const isInteractive = argv.interactive !== false;
432
435
  const autoMerge = argv['auto-merge-queued-messages'] !== false;
433
436
 
@@ -466,6 +469,17 @@ export async function runContinuousDirectMode(
466
469
  }
467
470
 
468
471
  isProcessing = true;
472
+
473
+ // Output input confirmation in JSON format
474
+ outputInput(
475
+ {
476
+ raw: message.raw || message.message,
477
+ parsed: message,
478
+ format: message.format || 'text',
479
+ },
480
+ compactJson
481
+ );
482
+
469
483
  const messageText = message.message || 'hi';
470
484
  const parts = [{ type: 'text', text: messageText }];
471
485
 
@@ -544,7 +558,7 @@ export async function runContinuousDirectMode(
544
558
  });
545
559
  }
546
560
 
547
- if (part.type === 'tool' && part.state.status === 'completed') {
561
+ if (part.type === 'tool') {
548
562
  eventHandler.output({
549
563
  type: 'tool_use',
550
564
  timestamp: Date.now(),
@@ -61,13 +61,23 @@ export function createBusEventSubscription({
61
61
  });
62
62
  }
63
63
 
64
- if (part.type === 'tool' && part.state.status === 'completed') {
64
+ if (part.type === 'tool') {
65
65
  eventHandler.output({
66
66
  type: 'tool_use',
67
67
  timestamp: Date.now(),
68
68
  sessionID,
69
69
  part,
70
70
  });
71
+
72
+ // If tool failed, also output an error event
73
+ if (part.state?.status === 'failed') {
74
+ eventHandler.output({
75
+ type: 'error',
76
+ timestamp: Date.now(),
77
+ sessionID,
78
+ error: part.state.error || 'Tool execution failed',
79
+ });
80
+ }
71
81
  }
72
82
  }
73
83
 
@@ -0,0 +1,203 @@
1
+ /**
2
+ * CLI Output Module
3
+ *
4
+ * Centralized output handling for the Agent CLI.
5
+ * Ensures consistent JSON formatting and proper stream routing:
6
+ * - stdout: Normal output (status, events, data, logs, warnings)
7
+ * - stderr: Errors only
8
+ *
9
+ * All output includes a `type` field for easy parsing.
10
+ */
11
+
12
+ import { EOL } from 'os';
13
+ import { Flag } from '../flag/flag';
14
+
15
+ /**
16
+ * Output types for JSON messages
17
+ */
18
+ export type OutputType =
19
+ | 'status'
20
+ | 'error'
21
+ | 'warning'
22
+ | 'log'
23
+ | 'step_start'
24
+ | 'step_finish'
25
+ | 'text'
26
+ | 'tool_use'
27
+ | 'result'
28
+ | 'init'
29
+ | 'message'
30
+ | 'tool_result'
31
+ | 'input';
32
+
33
+ /**
34
+ * Base interface for all output messages
35
+ */
36
+ export interface OutputMessage {
37
+ type: OutputType;
38
+ [key: string]: unknown;
39
+ }
40
+
41
+ /**
42
+ * Global compact JSON setting (can be set once at startup)
43
+ * Initialized lazily from Flag.COMPACT_JSON() which checks AGENT_CLI_COMPACT env var
44
+ */
45
+ let globalCompactJson: boolean | null = null;
46
+
47
+ /**
48
+ * Set the global compact JSON setting
49
+ */
50
+ export function setCompactJson(compact: boolean): void {
51
+ globalCompactJson = compact;
52
+ // Also update the Flag so other modules stay in sync
53
+ Flag.setCompactJson(compact);
54
+ }
55
+
56
+ /**
57
+ * Get the current compact JSON setting
58
+ */
59
+ export function isCompactJson(): boolean {
60
+ if (globalCompactJson !== null) return globalCompactJson;
61
+ return Flag.COMPACT_JSON();
62
+ }
63
+
64
+ /**
65
+ * Format a message as JSON string
66
+ * @param message - The message object to format
67
+ * @param compact - Override the global compact setting
68
+ */
69
+ export function formatJson(message: OutputMessage, compact?: boolean): string {
70
+ // Check local, global, and Flag settings for compact mode
71
+ const useCompact = compact ?? isCompactJson();
72
+ return useCompact
73
+ ? JSON.stringify(message)
74
+ : JSON.stringify(message, null, 2);
75
+ }
76
+
77
+ /**
78
+ * Write a message to stdout (for normal output)
79
+ * @param message - The message object to output
80
+ * @param compact - Override the global compact setting
81
+ */
82
+ export function writeStdout(message: OutputMessage, compact?: boolean): void {
83
+ const json = formatJson(message, compact);
84
+ process.stdout.write(json + EOL);
85
+ }
86
+
87
+ /**
88
+ * Write a message to stderr (for errors only)
89
+ * @param message - The message object to output
90
+ * @param compact - Override the global compact setting
91
+ */
92
+ export function writeStderr(message: OutputMessage, compact?: boolean): void {
93
+ const json = formatJson(message, compact);
94
+ process.stderr.write(json + EOL);
95
+ }
96
+
97
+ /**
98
+ * Output a message to stdout (for normal output)
99
+ * - stdout: All output except errors (status, events, data, logs, warnings)
100
+ *
101
+ * @param message - The message object to output
102
+ * @param compact - Override the global compact setting
103
+ */
104
+ export function output(message: OutputMessage, compact?: boolean): void {
105
+ writeStdout(message, compact);
106
+ }
107
+
108
+ /**
109
+ * Output a status message to stdout
110
+ */
111
+ export function outputStatus(
112
+ status: Omit<OutputMessage, 'type'> & {
113
+ type?: 'status' | 'error' | 'warning';
114
+ },
115
+ compact?: boolean
116
+ ): void {
117
+ const message: OutputMessage = {
118
+ type: status.type || 'status',
119
+ ...status,
120
+ };
121
+ output(message, compact);
122
+ }
123
+
124
+ /**
125
+ * Output an error message to stderr
126
+ */
127
+ export function outputError(
128
+ error: {
129
+ errorType?: string;
130
+ message: string;
131
+ hint?: string;
132
+ stack?: string;
133
+ [key: string]: unknown;
134
+ },
135
+ compact?: boolean
136
+ ): void {
137
+ const message: OutputMessage = {
138
+ type: 'error',
139
+ ...error,
140
+ };
141
+ writeStderr(message, compact);
142
+ }
143
+
144
+ /**
145
+ * Output a warning message to stdout
146
+ */
147
+ export function outputWarning(
148
+ warning: {
149
+ message: string;
150
+ hint?: string;
151
+ [key: string]: unknown;
152
+ },
153
+ compact?: boolean
154
+ ): void {
155
+ const message: OutputMessage = {
156
+ type: 'warning',
157
+ ...warning,
158
+ };
159
+ writeStdout(message, compact);
160
+ }
161
+
162
+ /**
163
+ * Output a log message to stdout
164
+ * This uses the flattened format: { "type": "log", "level": "...", ... }
165
+ */
166
+ export function outputLog(
167
+ log: {
168
+ level: 'debug' | 'info' | 'warn' | 'error';
169
+ message?: string;
170
+ timestamp?: string;
171
+ [key: string]: unknown;
172
+ },
173
+ compact?: boolean
174
+ ): void {
175
+ const message: OutputMessage = {
176
+ type: 'log',
177
+ ...log,
178
+ };
179
+ writeStdout(message, compact);
180
+ }
181
+
182
+ /**
183
+ * Output user input confirmation to stdout
184
+ */
185
+ export function outputInput(
186
+ input: {
187
+ raw: string;
188
+ parsed?: unknown;
189
+ format?: 'json' | 'text';
190
+ [key: string]: unknown;
191
+ },
192
+ compact?: boolean
193
+ ): void {
194
+ const message: OutputMessage = {
195
+ type: 'input',
196
+ timestamp: new Date().toISOString(),
197
+ ...input,
198
+ };
199
+ writeStdout(message, compact);
200
+ }
201
+
202
+ // Re-export for backward compatibility
203
+ export { output as write };
package/src/cli/ui.ts CHANGED
@@ -23,31 +23,31 @@ export namespace UI {
23
23
 
24
24
  // Print an empty line
25
25
  export function empty() {
26
- process.stderr.write('\n');
26
+ process.stdout.write('\n');
27
27
  }
28
28
 
29
29
  // Print a line with optional formatting
30
30
  export function println(...args: string[]) {
31
- process.stderr.write(args.join('') + Style.TEXT_NORMAL + '\n');
31
+ process.stdout.write(args.join('') + Style.TEXT_NORMAL + '\n');
32
32
  }
33
33
 
34
34
  // Print an error message
35
35
  export function error(message: string) {
36
- process.stderr.write(
36
+ process.stdout.write(
37
37
  Style.TEXT_DANGER_BOLD + 'Error: ' + Style.TEXT_NORMAL + message + '\n'
38
38
  );
39
39
  }
40
40
 
41
41
  // Print a success message
42
42
  export function success(message: string) {
43
- process.stderr.write(
43
+ process.stdout.write(
44
44
  Style.TEXT_SUCCESS_BOLD + 'Success: ' + Style.TEXT_NORMAL + message + '\n'
45
45
  );
46
46
  }
47
47
 
48
48
  // Print an info message
49
49
  export function info(message: string) {
50
- process.stderr.write(
50
+ process.stdout.write(
51
51
  Style.TEXT_INFO_BOLD + 'Info: ' + Style.TEXT_NORMAL + message + '\n'
52
52
  );
53
53
  }
package/src/flag/flag.ts CHANGED
@@ -63,6 +63,19 @@ export namespace Flag {
63
63
  'OPENCODE_DRY_RUN'
64
64
  );
65
65
 
66
+ // Compact JSON mode - output JSON on single lines (NDJSON format)
67
+ // Enabled by AGENT_CLI_COMPACT env var or --compact-json flag
68
+ // Uses getter to check env var at runtime for tests
69
+ let _compactJson: boolean | null = null;
70
+
71
+ export function COMPACT_JSON(): boolean {
72
+ if (_compactJson !== null) return _compactJson;
73
+ return (
74
+ truthy('AGENT_CLI_COMPACT') ||
75
+ truthyCompat('LINK_ASSISTANT_AGENT_COMPACT_JSON', 'OPENCODE_COMPACT_JSON')
76
+ );
77
+ }
78
+
66
79
  // Allow setting verbose mode programmatically (e.g., from CLI --verbose flag)
67
80
  export function setVerbose(value: boolean) {
68
81
  OPENCODE_VERBOSE = value;
@@ -73,6 +86,11 @@ export namespace Flag {
73
86
  OPENCODE_DRY_RUN = value;
74
87
  }
75
88
 
89
+ // Allow setting compact JSON mode programmatically (e.g., from CLI --compact-json flag)
90
+ export function setCompactJson(value: boolean) {
91
+ _compactJson = value;
92
+ }
93
+
76
94
  function truthy(key: string) {
77
95
  const value = process.env[key]?.toLowerCase();
78
96
  return value === 'true' || value === '1';
package/src/index.js CHANGED
@@ -24,6 +24,12 @@ import {
24
24
  resolveResumeSession,
25
25
  } from './cli/continuous-mode.js';
26
26
  import { createBusEventSubscription } from './cli/event-handler.js';
27
+ import {
28
+ outputStatus,
29
+ outputError,
30
+ setCompactJson,
31
+ outputInput,
32
+ } from './cli/output.ts';
27
33
  import { createRequire } from 'module';
28
34
  import { readFileSync } from 'fs';
29
35
  import { dirname, join } from 'path';
@@ -47,35 +53,21 @@ let hasError = false;
47
53
  // Install global error handlers to ensure non-zero exit codes
48
54
  process.on('uncaughtException', (error) => {
49
55
  hasError = true;
50
- console.error(
51
- JSON.stringify(
52
- {
53
- type: 'error',
54
- errorType: error.name || 'UncaughtException',
55
- message: error.message,
56
- stack: error.stack,
57
- },
58
- null,
59
- 2
60
- )
61
- );
56
+ outputError({
57
+ errorType: error.name || 'UncaughtException',
58
+ message: error.message,
59
+ stack: error.stack,
60
+ });
62
61
  process.exit(1);
63
62
  });
64
63
 
65
64
  process.on('unhandledRejection', (reason, _promise) => {
66
65
  hasError = true;
67
- console.error(
68
- JSON.stringify(
69
- {
70
- type: 'error',
71
- errorType: 'UnhandledRejection',
72
- message: reason?.message || String(reason),
73
- stack: reason?.stack,
74
- },
75
- null,
76
- 2
77
- )
78
- );
66
+ outputError({
67
+ errorType: 'UnhandledRejection',
68
+ message: reason?.message || String(reason),
69
+ stack: reason?.stack,
70
+ });
79
71
  process.exit(1);
80
72
  });
81
73
 
@@ -134,18 +126,8 @@ function readStdinWithTimeout(timeout = null) {
134
126
  });
135
127
  }
136
128
 
137
- /**
138
- * Output JSON status message to stderr
139
- * This prevents the status message from interfering with JSON output parsing
140
- * @param {object} status - Status object to output
141
- * @param {boolean} compact - If true, output compact JSON (single line)
142
- */
143
- function outputStatus(status, compact = false) {
144
- const json = compact
145
- ? JSON.stringify(status)
146
- : JSON.stringify(status, null, 2);
147
- console.error(json);
148
- }
129
+ // outputStatus is now imported from './cli/output.ts'
130
+ // It outputs to stdout for non-error messages, stderr for errors
149
131
 
150
132
  /**
151
133
  * Parse model configuration from argv
@@ -168,9 +150,8 @@ async function parseModelConfig(argv) {
168
150
 
169
151
  if (!creds?.accessToken) {
170
152
  const compactJson = argv['compact-json'] === true;
171
- outputStatus(
153
+ outputError(
172
154
  {
173
- type: 'error',
174
155
  errorType: 'AuthenticationError',
175
156
  message:
176
157
  'No Claude OAuth credentials found in ~/.claude/.credentials.json. Either authenticate with Claude Code CLI first, or use: agent auth login (select Anthropic > Claude Pro/Max)',
@@ -221,9 +202,10 @@ async function readSystemMessages(argv) {
221
202
  );
222
203
  const file = Bun.file(resolvedPath);
223
204
  if (!(await file.exists())) {
224
- console.error(
225
- `System message file not found: ${argv['system-message-file']}`
226
- );
205
+ outputError({
206
+ errorType: 'FileNotFound',
207
+ message: `System message file not found: ${argv['system-message-file']}`,
208
+ });
227
209
  process.exit(1);
228
210
  }
229
211
  systemMessage = await file.text();
@@ -236,9 +218,10 @@ async function readSystemMessages(argv) {
236
218
  );
237
219
  const file = Bun.file(resolvedPath);
238
220
  if (!(await file.exists())) {
239
- console.error(
240
- `Append system message file not found: ${argv['append-system-message-file']}`
241
- );
221
+ outputError({
222
+ errorType: 'FileNotFound',
223
+ message: `Append system message file not found: ${argv['append-system-message-file']}`,
224
+ });
242
225
  process.exit(1);
243
226
  }
244
227
  appendSystemMessage = await file.text();
@@ -692,7 +675,9 @@ async function main() {
692
675
  default: false,
693
676
  }),
694
677
  handler: async (argv) => {
695
- const compactJson = argv['compact-json'] === true;
678
+ // Check both CLI flag and environment variable for compact JSON mode
679
+ const compactJson =
680
+ argv['compact-json'] === true || Flag.COMPACT_JSON();
696
681
 
697
682
  // Check if --prompt flag was provided
698
683
  if (argv.prompt) {
@@ -705,9 +690,9 @@ async function main() {
705
690
  // Check if --disable-stdin was set without --prompt
706
691
  if (argv['disable-stdin']) {
707
692
  // Output a helpful message suggesting to use --prompt
708
- outputStatus(
693
+ outputError(
709
694
  {
710
- type: 'error',
695
+ errorType: 'ValidationError',
711
696
  message:
712
697
  'No prompt provided. Use -p/--prompt to specify a message, or remove --disable-stdin to read from stdin.',
713
698
  hint: 'Example: agent -p "Hello, how are you?"',
@@ -726,9 +711,9 @@ async function main() {
726
711
 
727
712
  // Exit if --no-always-accept-stdin is set (single message mode not supported in TTY)
728
713
  if (!alwaysAcceptStdin) {
729
- outputStatus(
714
+ outputError(
730
715
  {
731
- type: 'error',
716
+ errorType: 'ValidationError',
732
717
  message:
733
718
  'Single message mode (--no-always-accept-stdin) is not supported in interactive terminal mode.',
734
719
  hint: 'Use piped input or --prompt for single messages.',
@@ -820,9 +805,9 @@ async function main() {
820
805
  // Not JSON
821
806
  if (!isInteractive) {
822
807
  // In non-interactive mode, only accept JSON
823
- outputStatus(
808
+ outputError(
824
809
  {
825
- type: 'error',
810
+ errorType: 'ValidationError',
826
811
  message:
827
812
  'Invalid JSON input. In non-interactive mode (--no-interactive), only JSON input is accepted.',
828
813
  hint: 'Use --interactive to accept plain text, or provide valid JSON: {"message": "your text"}',
@@ -837,6 +822,16 @@ async function main() {
837
822
  };
838
823
  }
839
824
 
825
+ // Output input confirmation in JSON format
826
+ outputInput(
827
+ {
828
+ raw: trimmedInput,
829
+ parsed: request,
830
+ format: isInteractive ? 'text' : 'json',
831
+ },
832
+ compactJson
833
+ );
834
+
840
835
  // Run agent mode
841
836
  await runAgentMode(argv, request);
842
837
  },
@@ -844,6 +839,12 @@ async function main() {
844
839
  // Initialize logging early for all CLI commands
845
840
  // This prevents debug output from appearing in CLI unless --verbose is used
846
841
  .middleware(async (argv) => {
842
+ // Set global compact JSON setting (CLI flag or environment variable)
843
+ const isCompact = argv['compact-json'] === true || Flag.COMPACT_JSON();
844
+ if (isCompact) {
845
+ setCompactJson(true);
846
+ }
847
+
847
848
  // Set verbose flag if requested
848
849
  if (argv.verbose) {
849
850
  Flag.setVerbose(true);
@@ -855,11 +856,12 @@ async function main() {
855
856
  }
856
857
 
857
858
  // Initialize logging system
858
- // - If verbose: print logs to stderr for debugging in JSON format
859
- // - Otherwise: write logs to file to keep CLI output clean
859
+ // - Print logs to stdout only when verbose for clean CLI output
860
+ // - Use verbose flag to enable DEBUG level logging
860
861
  await Log.init({
861
- print: Flag.OPENCODE_VERBOSE,
862
+ print: Flag.OPENCODE_VERBOSE, // Output logs only when verbose for clean CLI output
862
863
  level: Flag.OPENCODE_VERBOSE ? 'DEBUG' : 'INFO',
864
+ compactJson: isCompact,
863
865
  });
864
866
  })
865
867
  .fail((msg, err, yargs) => {
@@ -874,18 +876,27 @@ async function main() {
874
876
  // Format other errors using FormatError
875
877
  const formatted = FormatError(err);
876
878
  if (formatted) {
877
- console.error(formatted);
879
+ outputError({
880
+ errorType: err.name || 'Error',
881
+ message: formatted,
882
+ });
878
883
  } else {
879
884
  // Fallback to default error formatting
880
- console.error(err.message || err);
885
+ outputError({
886
+ errorType: err.name || 'Error',
887
+ message: err.message || String(err),
888
+ });
881
889
  }
882
890
  process.exit(1);
883
891
  }
884
892
 
885
893
  // Handle validation errors (msg without err)
886
894
  if (msg) {
887
- console.error(msg);
888
- console.error(`\n${yargs.help()}`);
895
+ outputError({
896
+ errorType: 'ValidationError',
897
+ message: msg,
898
+ hint: yargs.help(),
899
+ });
889
900
  process.exit(1);
890
901
  }
891
902
  })
@@ -895,19 +906,12 @@ async function main() {
895
906
  await yargsInstance.argv;
896
907
  } catch (error) {
897
908
  hasError = true;
898
- console.error(
899
- JSON.stringify(
900
- {
901
- type: 'error',
902
- timestamp: Date.now(),
903
- errorType: error instanceof Error ? error.name : 'Error',
904
- message: error instanceof Error ? error.message : String(error),
905
- stack: error instanceof Error ? error.stack : undefined,
906
- },
907
- null,
908
- 2
909
- )
910
- );
909
+ outputError({
910
+ timestamp: Date.now(),
911
+ errorType: error instanceof Error ? error.name : 'Error',
912
+ message: error instanceof Error ? error.message : String(error),
913
+ stack: error instanceof Error ? error.stack : undefined,
914
+ });
911
915
  process.exit(1);
912
916
  }
913
917
  }
@@ -2,11 +2,15 @@
2
2
  * JSON Standard Format Handlers
3
3
  *
4
4
  * Provides adapters for different JSON output formats:
5
- * - opencode: OpenCode format (default) - pretty-printed JSON events
5
+ * - opencode: OpenCode format (default) - configurable JSON formatting
6
6
  * - claude: Claude CLI stream-json format - NDJSON (newline-delimited JSON)
7
+ *
8
+ * Output goes to stdout for normal messages, stderr for errors.
9
+ * Use AGENT_CLI_COMPACT env var or --compact-json flag for NDJSON output.
7
10
  */
8
11
 
9
12
  import { EOL } from 'os';
13
+ import { Flag } from '../flag/flag';
10
14
 
11
15
  export type JsonStandard = 'opencode' | 'claude';
12
16
 
@@ -46,16 +50,20 @@ export interface ClaudeEvent {
46
50
 
47
51
  /**
48
52
  * Serialize JSON output based on the selected standard
53
+ * Respects AGENT_CLI_COMPACT env var for OpenCode format
49
54
  */
50
55
  export function serializeOutput(
51
56
  event: OpenCodeEvent | ClaudeEvent,
52
57
  standard: JsonStandard
53
58
  ): string {
54
59
  if (standard === 'claude') {
55
- // NDJSON format - compact, one line
60
+ // NDJSON format - always compact, one line
61
+ return JSON.stringify(event) + EOL;
62
+ }
63
+ // OpenCode format - compact if AGENT_CLI_COMPACT is set
64
+ if (Flag.COMPACT_JSON()) {
56
65
  return JSON.stringify(event) + EOL;
57
66
  }
58
- // OpenCode format - pretty-printed
59
67
  return JSON.stringify(event, null, 2) + EOL;
60
68
  }
61
69
 
@@ -146,13 +154,15 @@ export function createEventHandler(standard: JsonStandard, sessionID: string) {
146
154
  * Format and output an event
147
155
  */
148
156
  output(event: OpenCodeEvent): void {
157
+ const outputStream =
158
+ event.type === 'error' ? process.stderr : process.stdout;
149
159
  if (standard === 'claude') {
150
160
  const claudeEvent = convertOpenCodeToClaude(event, startTime);
151
161
  if (claudeEvent) {
152
- process.stdout.write(serializeOutput(claudeEvent, standard));
162
+ outputStream.write(serializeOutput(claudeEvent, standard));
153
163
  }
154
164
  } else {
155
- process.stdout.write(serializeOutput(event, standard));
165
+ outputStream.write(serializeOutput(event, standard));
156
166
  }
157
167
  },
158
168
 
@@ -3,6 +3,7 @@
3
3
  // Permalink: https://github.com/sst/opencode/blob/main/packages/opencode/src/provider/provider.ts
4
4
 
5
5
  import { ToolRegistry } from '../tool/registry.ts';
6
+ import { outputError } from '../cli/output.ts';
6
7
 
7
8
  export class Agent {
8
9
  constructor() {
@@ -96,22 +97,17 @@ export class Agent {
96
97
  const errorTime = Date.now();
97
98
  const callID = `call_${Math.floor(Math.random() * 100000000)}`;
98
99
 
99
- // Log full error to stderr for debugging in JSON format
100
- console.error(
101
- JSON.stringify({
102
- log: {
103
- level: 'error',
104
- timestamp: new Date().toISOString(),
105
- message: 'Tool execution error',
106
- tool: tool.name,
107
- error: {
108
- name: error.name,
109
- message: error.message,
110
- stack: error.stack,
111
- },
112
- },
113
- })
114
- );
100
+ // Log full error to stderr in flattened JSON format
101
+ outputError({
102
+ errorType: 'ToolExecutionError',
103
+ message: 'Tool execution error',
104
+ tool: tool.name,
105
+ error: {
106
+ name: error.name,
107
+ message: error.message,
108
+ stack: error.stack,
109
+ },
110
+ });
115
111
 
116
112
  // Emit tool_use event with error
117
113
  this.emitEvent('tool_use', {
@@ -217,8 +213,8 @@ export class Agent {
217
213
  // Pretty-print JSON for human readability, compact for programmatic use
218
214
  // Use AGENT_CLI_COMPACT=1 for compact output (tests, automation)
219
215
  const compact = process.env.AGENT_CLI_COMPACT === '1';
220
- console.log(
221
- compact ? JSON.stringify(event) : JSON.stringify(event, null, 2)
216
+ process.stdout.write(
217
+ `${compact ? JSON.stringify(event) : JSON.stringify(event, null, 2)}\n`
222
218
  );
223
219
  }
224
220
  }
@@ -547,6 +547,18 @@ export namespace Session {
547
547
  return obj.reason;
548
548
  }
549
549
 
550
+ // Handle AI SDK unified/raw format: {unified: "tool-calls", raw: "tool_calls"}
551
+ // See: https://github.com/link-assistant/agent/issues/129
552
+ if (typeof obj.unified === 'string') {
553
+ if (Flag.OPENCODE_VERBOSE) {
554
+ log.debug(() => ({
555
+ message: 'toFinishReason extracted unified from object',
556
+ result: obj.unified,
557
+ }));
558
+ }
559
+ return obj.unified;
560
+ }
561
+
550
562
  // If we can't extract a specific field, return JSON representation
551
563
  if (Flag.OPENCODE_VERBOSE) {
552
564
  log.debug(() => ({
@@ -188,7 +188,7 @@ export namespace SessionProcessor {
188
188
  await Session.updatePart({
189
189
  ...match,
190
190
  state: {
191
- status: 'error',
191
+ status: 'failed',
192
192
  input: value.input,
193
193
  error: (value.error as any).toString(),
194
194
  metadata: undefined,
@@ -367,13 +367,13 @@ export namespace SessionProcessor {
367
367
  if (
368
368
  part.type === 'tool' &&
369
369
  part.state.status !== 'completed' &&
370
- part.state.status !== 'error'
370
+ part.state.status !== 'failed'
371
371
  ) {
372
372
  await Session.updatePart({
373
373
  ...part,
374
374
  state: {
375
375
  ...part.state,
376
- status: 'error',
376
+ status: 'failed',
377
377
  error: 'Tool execution aborted',
378
378
  time: {
379
379
  start: Date.now(),
package/src/tool/read.ts CHANGED
@@ -29,7 +29,7 @@ export const ReadTool = Tool.define('read', {
29
29
  async execute(params, ctx) {
30
30
  let filepath = params.filePath;
31
31
  if (!path.isAbsolute(filepath)) {
32
- filepath = path.join(process.cwd(), filepath);
32
+ filepath = path.join(Instance.worktree, filepath);
33
33
  }
34
34
  const title = path.relative(Instance.worktree, filepath);
35
35
 
@@ -70,14 +70,13 @@ export const ReadTool = Tool.define('read', {
70
70
  return model.info.modalities?.input?.includes('image') ?? false;
71
71
  })();
72
72
  if (isImage) {
73
- if (!supportsImages) {
73
+ // Image format validation (can be disabled via environment variable)
74
+ const verifyImages = process.env.VERIFY_IMAGES_AT_READ_TOOL !== 'false';
75
+ if (verifyImages && !supportsImages) {
74
76
  throw new Error(
75
77
  `Failed to read image: ${filepath}, model may not be able to read images`
76
78
  );
77
79
  }
78
-
79
- // Image format validation (can be disabled via environment variable)
80
- const verifyImages = process.env.VERIFY_IMAGES_AT_READ_TOOL !== 'false';
81
80
  if (verifyImages) {
82
81
  const bytes = new Uint8Array(await file.arrayBuffer());
83
82
 
@@ -1,16 +1,17 @@
1
1
  import makeLog, { levels, LogLevel } from 'log-lazy';
2
- import { Flag } from '../flag/flag.ts';
2
+ import { Flag } from '../flag/flag';
3
3
 
4
4
  /**
5
5
  * JSON Lazy Logger
6
6
  *
7
7
  * Implements lazy logging pattern using log-lazy library.
8
- * All log output is JSON formatted and wrapped in { log: { ... } } structure
9
- * for easy parsing alongside regular JSON output.
8
+ * All log output is JSON formatted with { "type": "log", "level": "...", ... } structure
9
+ * for consistent parsing with other CLI JSON output.
10
10
  *
11
11
  * Key features:
12
12
  * - Lazy evaluation: log arguments are only computed if logging is enabled
13
- * - JSON output: all logs are parsable JSON in { log: { ... } } format
13
+ * - JSON output: all logs are parsable JSON with `type: "log"` field
14
+ * - Stdout output: logs go to stdout (not stderr) following Unix conventions
14
15
  * - Level control: logs respect --verbose flag and LINK_ASSISTANT_AGENT_VERBOSE env
15
16
  * - Type-safe: full TypeScript support
16
17
  *
@@ -52,8 +53,21 @@ const LEVEL_PRESETS = {
52
53
 
53
54
  type LevelPreset = keyof typeof LEVEL_PRESETS;
54
55
 
56
+ import { Flag } from '../flag/flag';
57
+
58
+ // Compact JSON mode (can be set at runtime, initialized from Flag)
59
+ let compactJsonMode = Flag.COMPACT_JSON();
60
+
61
+ /**
62
+ * Set compact JSON output mode
63
+ */
64
+ export function setCompactJson(compact: boolean): void {
65
+ compactJsonMode = compact;
66
+ }
67
+
55
68
  /**
56
- * Format a log entry as JSON object wrapped in { log: { ... } }
69
+ * Format a log entry as JSON object with { "type": "log", "level": "...", ... } structure
70
+ * This flattened format is consistent with other CLI JSON output.
57
71
  */
58
72
  function formatLogEntry(
59
73
  level: string,
@@ -62,6 +76,7 @@ function formatLogEntry(
62
76
  ): string {
63
77
  const timestamp = new Date().toISOString();
64
78
  const logEntry: Record<string, unknown> = {
79
+ type: 'log',
65
80
  level,
66
81
  timestamp,
67
82
  ...tags,
@@ -84,11 +99,16 @@ function formatLogEntry(
84
99
  logEntry.message = String(data);
85
100
  }
86
101
 
87
- return JSON.stringify({ log: logEntry });
102
+ // Check both local setting and global Flag
103
+ const useCompact = compactJsonMode || Flag.COMPACT_JSON();
104
+ return useCompact
105
+ ? JSON.stringify(logEntry)
106
+ : JSON.stringify(logEntry, null, 2);
88
107
  }
89
108
 
90
109
  /**
91
- * Create the output function that writes to stderr
110
+ * Create the output function that writes to stdout
111
+ * Using stdout follows Unix conventions (stdout for data, stderr for errors)
92
112
  */
93
113
  function createOutput(
94
114
  level: string,
@@ -96,8 +116,8 @@ function createOutput(
96
116
  ): (data: unknown) => void {
97
117
  return (data: unknown) => {
98
118
  const json = formatLogEntry(level, data, tags);
99
- // Use stderr to avoid interfering with stdout JSON output
100
- process.stderr.write(json + '\n');
119
+ // Use stdout following Unix conventions
120
+ process.stdout.write(json + '\n');
101
121
  };
102
122
  }
103
123
 
package/src/util/log.ts CHANGED
@@ -3,18 +3,19 @@ import fs from 'fs/promises';
3
3
  import { Global } from '../global';
4
4
  import z from 'zod';
5
5
  import makeLog, { levels } from 'log-lazy';
6
- import { Flag } from '../flag/flag.ts';
6
+ import { Flag } from '../flag/flag';
7
7
 
8
8
  /**
9
9
  * Logging module with JSON output and lazy evaluation support.
10
10
  *
11
11
  * Features:
12
- * - JSON formatted output: All logs are wrapped in { log: { ... } } structure
12
+ * - JSON formatted output: All logs use { "type": "log", "level": "...", ... } structure
13
13
  * - Lazy evaluation: Use lazy() methods to defer expensive computations
14
14
  * - Level control: Respects --verbose flag and log level settings
15
15
  * - File logging: Writes to file when not in verbose/print mode
16
+ * - Stdout by default: Logs go to stdout for JSON output consistency
16
17
  *
17
- * The JSON format ensures all output is parsable, separating logs from regular output.
18
+ * The JSON format with `type` field ensures all output is consistent with other CLI output.
18
19
  */
19
20
  export namespace Log {
20
21
  export const Level = z
@@ -31,6 +32,7 @@ export namespace Log {
31
32
 
32
33
  let level: Level = 'INFO';
33
34
  let jsonOutput = false; // Whether to output JSON format (enabled in verbose mode)
35
+ let compactJsonOutput = Flag.COMPACT_JSON(); // Whether to use compact JSON (single line)
34
36
 
35
37
  function shouldLog(input: Level): boolean {
36
38
  return levelPriority[input] >= levelPriority[level];
@@ -84,19 +86,24 @@ export namespace Log {
84
86
  print: boolean;
85
87
  dev?: boolean;
86
88
  level?: Level;
89
+ compactJson?: boolean;
87
90
  }
88
91
 
89
92
  let logpath = '';
90
93
  export function file() {
91
94
  return logpath;
92
95
  }
93
- let write = (msg: any) => Bun.stderr.write(msg);
96
+ // Default to file for log output (to keep CLI output clean)
97
+ let write = (msg: any) => {}; // Placeholder, set in init
98
+ let fileWrite = (msg: any) => {}; // Placeholder, set in init
94
99
 
95
100
  // Initialize log-lazy for controlling lazy log execution
96
101
  let lazyLogInstance = makeLog({ level: 0 }); // Start disabled
97
102
 
98
103
  export async function init(options: Options) {
99
104
  if (options.level) level = options.level;
105
+ if (options.compactJson !== undefined)
106
+ compactJsonOutput = options.compactJson;
100
107
  cleanup(Global.Path.log);
101
108
 
102
109
  // Always use JSON output format for logs
@@ -109,30 +116,36 @@ export namespace Log {
109
116
  level: levels.debug | levels.info | levels.warn | levels.error,
110
117
  });
111
118
  } else {
112
- // Disable lazy logging when not verbose
113
- lazyLogInstance = makeLog({ level: 0 });
119
+ // Enable info, warn, error by default for JSON output consistency
120
+ lazyLogInstance = makeLog({
121
+ level: levels.info | levels.warn | levels.error,
122
+ });
114
123
  }
115
124
 
116
- if (options.print) {
117
- // In print mode, output to stderr
118
- // No file logging needed
119
- } else {
120
- // In normal mode, write to file
121
- logpath = path.join(
122
- Global.Path.log,
123
- options.dev
124
- ? 'dev.log'
125
- : new Date().toISOString().split('.')[0].replace(/:/g, '') + '.log'
126
- );
127
- const logfile = Bun.file(logpath);
128
- await fs.truncate(logpath).catch(() => {});
129
- const writer = logfile.writer();
130
- write = async (msg: any) => {
131
- const num = writer.write(msg);
132
- writer.flush();
133
- return num;
134
- };
135
- }
125
+ // Output logs to stdout by default for JSON formatting consistency
126
+ // Also write to file for debugging purposes
127
+ logpath = path.join(
128
+ Global.Path.log,
129
+ options.dev
130
+ ? 'dev.log'
131
+ : new Date().toISOString().split('.')[0].replace(/:/g, '') + '.log'
132
+ );
133
+ const logfile = Bun.file(logpath);
134
+ await fs.truncate(logpath).catch(() => {});
135
+ const writer = logfile.writer();
136
+ // Write to file
137
+ fileWrite = async (msg: any) => {
138
+ const num = writer.write(msg);
139
+ writer.flush();
140
+ return num;
141
+ };
142
+
143
+ // Always write to stdout for JSON output consistency
144
+ // Also write to file for debugging purposes
145
+ write = async (msg: any) => {
146
+ process.stdout.write(msg);
147
+ fileWrite(msg);
148
+ };
136
149
  }
137
150
 
138
151
  async function cleanup(dir: string) {
@@ -159,7 +172,8 @@ export namespace Log {
159
172
  }
160
173
 
161
174
  /**
162
- * Format log entry as JSON object wrapped in { log: { ... } }
175
+ * Format log entry as JSON object with { "type": "log", "level": "...", ... } structure
176
+ * This flattened format is consistent with other CLI JSON output.
163
177
  */
164
178
  function formatJson(
165
179
  logLevel: Level,
@@ -169,6 +183,7 @@ export namespace Log {
169
183
  ): string {
170
184
  const timestamp = new Date().toISOString();
171
185
  const logEntry: Record<string, any> = {
186
+ type: 'log',
172
187
  level: logLevel.toLowerCase(),
173
188
  timestamp,
174
189
  ...tags,
@@ -192,7 +207,12 @@ export namespace Log {
192
207
  }
193
208
  }
194
209
 
195
- return JSON.stringify({ log: logEntry });
210
+ // Use compact or pretty format based on configuration
211
+ // Check both local setting and global Flag
212
+ const useCompact = compactJsonOutput || Flag.COMPACT_JSON();
213
+ return useCompact
214
+ ? JSON.stringify(logEntry)
215
+ : JSON.stringify(logEntry, null, 2);
196
216
  }
197
217
 
198
218
  let last = Date.now();
@@ -239,7 +259,9 @@ export namespace Log {
239
259
  ) {
240
260
  if (jsonOutput) {
241
261
  // Use our custom JSON formatting for { log: { ... } } format
242
- write(formatJson(logLevel, message, tags || {}, extra) + '\n');
262
+ const jsonMsg = formatJson(logLevel, message, tags || {}, extra) + '\n';
263
+ // All logs go to stdout for consistency with other JSON output
264
+ write(jsonMsg);
243
265
  } else {
244
266
  write(logLevel.padEnd(5) + ' ' + buildLegacy(message, extra));
245
267
  }
@@ -354,6 +376,8 @@ export namespace Log {
354
376
  export function syncWithVerboseFlag(): void {
355
377
  if (Flag.OPENCODE_VERBOSE) {
356
378
  jsonOutput = true;
379
+ // Use stdout for verbose output (following Unix conventions)
380
+ write = (msg: any) => process.stdout.write(msg);
357
381
  lazyLogInstance = makeLog({
358
382
  level: levels.debug | levels.info | levels.warn | levels.error,
359
383
  });
@@ -361,4 +385,18 @@ export namespace Log {
361
385
  lazyLogInstance = makeLog({ level: 0 });
362
386
  }
363
387
  }
388
+
389
+ /**
390
+ * Set compact JSON output mode
391
+ */
392
+ export function setCompactJson(compact: boolean): void {
393
+ compactJsonOutput = compact;
394
+ }
395
+
396
+ /**
397
+ * Check if compact JSON output mode is enabled
398
+ */
399
+ export function isCompactJson(): boolean {
400
+ return compactJsonOutput;
401
+ }
364
402
  }