@link-assistant/agent 0.22.9 → 0.23.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.
@@ -8,9 +8,11 @@ import { Log } from '../util/log.ts';
8
8
  import {
9
9
  DEFAULT_PROVIDER_ID,
10
10
  DEFAULT_MODEL_ID,
11
- DEFAULT_COMPACTION_MODEL,
12
- DEFAULT_COMPACTION_MODELS,
13
- DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
11
+ getDefaultModel,
12
+ getDefaultModelParts,
13
+ getDefaultCompactionModel,
14
+ getDefaultCompactionModels,
15
+ getDefaultCompactionSafetyMarginPercent,
14
16
  } from './defaults.ts';
15
17
 
16
18
  /**
@@ -20,12 +22,21 @@ import {
20
22
  * @param {function} outputStatus - Function to output status messages
21
23
  * @returns {Promise<{providerID: string, modelID: string}>}
22
24
  */
23
- export async function parseModelConfig(argv, outputError, outputStatus) {
25
+ export async function parseModelConfig(
26
+ argv,
27
+ outputError,
28
+ outputStatus,
29
+ defaultOptions = {}
30
+ ) {
31
+ const defaultModel = getDefaultModel(defaultOptions);
32
+ const { providerID: defaultProviderID, modelID: defaultModelID } =
33
+ getDefaultModelParts(defaultOptions);
34
+
24
35
  // Safeguard: validate argv.model against process.argv to detect yargs/cache mismatch (#192, #196, #239)
25
36
  // This is critical because yargs under Bun may fail to parse --model correctly,
26
37
  // returning the default value instead of the user's CLI argument.
27
38
  const cliModelArg = getModelFromProcessArgv();
28
- let modelArg = argv.model;
39
+ let modelArg = argv.model ?? defaultModel;
29
40
 
30
41
  // Diagnostic logging: always log raw argv sources when debugging model resolution (#239)
31
42
  // Bun global installs may have different process.argv structure (oven-sh/bun#22157)
@@ -54,7 +65,7 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
54
65
  // Always use CLI value when available, even if it matches yargs
55
66
  // This ensures we use the actual CLI argument, not a cached/default yargs value
56
67
  modelArg = cliModelArg;
57
- } else if (modelArg === `${DEFAULT_PROVIDER_ID}/${DEFAULT_MODEL_ID}`) {
68
+ } else if (modelArg === defaultModel) {
58
69
  // cliModelArg is null AND yargs returned the default — check if process.argv
59
70
  // actually contains --model to detect silent yargs/Bun mismatch (#239)
60
71
  const rawArgvStr = process.argv.join(' ');
@@ -74,7 +85,7 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
74
85
  typeof globalThis.Bun !== 'undefined' && globalThis.Bun.argv
75
86
  ? globalThis.Bun.argv
76
87
  : '(not available)',
77
- defaultModel: `${DEFAULT_PROVIDER_ID}/${DEFAULT_MODEL_ID}`,
88
+ defaultModel,
78
89
  }));
79
90
  }
80
91
  }
@@ -111,38 +122,54 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
111
122
  // fail immediately instead of silently falling back to a different model.
112
123
  // However, if the model is the default (no --model CLI flag), warn but proceed (#239).
113
124
  // The models.dev API may lag behind the provider's actual model availability.
114
- const isDefaultModel = !cliModelArg;
125
+ const isDefaultModel = !cliModelArg && modelArg === defaultModel;
115
126
  try {
116
127
  const { Provider } = await import('../provider/provider.ts');
117
128
  const s = await Provider.state();
118
129
  const provider = s.providers[providerID];
119
130
  if (provider && !provider.info.models[modelID]) {
120
- const availableModels = Object.keys(provider.info.models).slice(0, 10);
121
- if (isDefaultModel) {
122
- // Default model not in models.dev catalog — warn but proceed (#239)
123
- // The provider may still accept it; models.dev can lag behind actual availability.
124
- Log.Default.warn(() => ({
131
+ const liveInfo = await Provider.refreshLiveModelInfo(
132
+ providerID,
133
+ modelID
134
+ );
135
+ if (liveInfo) {
136
+ Log.Default.info(() => ({
125
137
  message:
126
- 'default model not found in models.dev catalog — proceeding anyway',
138
+ 'model not found in models.dev catalog but found in provider live endpoint',
127
139
  providerID,
128
140
  modelID,
129
- availableModels,
130
141
  }));
131
142
  } else {
132
- // User explicitly specified provider/model — fail with a clear error (#231)
133
- // Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
134
- Log.Default.error(() => ({
135
- message:
136
- 'model not found in provider — refusing to proceed with explicit provider/model',
137
- providerID,
138
- modelID,
139
- availableModels,
140
- }));
141
- throw new Error(
142
- `Model "${modelID}" not found in provider "${providerID}". ` +
143
- `Available models include: ${availableModels.join(', ')}. ` +
144
- `Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
143
+ const availableModels = Object.keys(provider.info.models).slice(
144
+ 0,
145
+ 10
145
146
  );
147
+ if (isDefaultModel) {
148
+ // Default model not in models.dev catalog — warn but proceed (#239)
149
+ // The provider may still accept it; models.dev can lag behind actual availability.
150
+ Log.Default.warn(() => ({
151
+ message:
152
+ 'default model not found in models.dev catalog — proceeding anyway',
153
+ providerID,
154
+ modelID,
155
+ availableModels,
156
+ }));
157
+ } else {
158
+ // User explicitly specified provider/model — fail with a clear error (#231)
159
+ // Silent fallback caused kimi-k2.5-free to be routed to minimax-m2.5-free
160
+ Log.Default.error(() => ({
161
+ message:
162
+ 'model not found in provider — refusing to proceed with explicit provider/model',
163
+ providerID,
164
+ modelID,
165
+ availableModels,
166
+ }));
167
+ throw new Error(
168
+ `Model "${modelID}" not found in provider "${providerID}". ` +
169
+ `Available models include: ${availableModels.join(', ')}. ` +
170
+ `Use --model ${providerID}/<model-id> with a valid model, or omit the provider prefix for auto-resolution.`
171
+ );
172
+ }
146
173
  }
147
174
  }
148
175
  } catch (validationError) {
@@ -177,7 +204,8 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
177
204
  const compactionModelResult = await parseCompactionModelConfig(
178
205
  argv,
179
206
  providerID,
180
- modelID
207
+ modelID,
208
+ defaultOptions
181
209
  );
182
210
 
183
211
  // Handle --use-existing-claude-oauth option
@@ -206,7 +234,7 @@ export async function parseModelConfig(argv, outputError, outputStatus) {
206
234
 
207
235
  // If user specified the default model (DEFAULT_MODEL), switch to claude-oauth
208
236
  // If user explicitly specified kilo or another provider, warn but respect their choice
209
- if (providerID === DEFAULT_PROVIDER_ID && modelID === DEFAULT_MODEL_ID) {
237
+ if (providerID === defaultProviderID && modelID === defaultModelID) {
210
238
  providerID = 'claude-oauth';
211
239
  modelID = 'claude-sonnet-4-5';
212
240
  } else if (!['claude-oauth', 'anthropic'].includes(providerID)) {
@@ -290,20 +318,28 @@ async function resolveCompactionModelEntry(
290
318
  * @see https://github.com/link-assistant/agent/issues/219
291
319
  * @see https://github.com/link-assistant/agent/issues/232
292
320
  */
293
- async function parseCompactionModelConfig(argv, baseProviderID, baseModelID) {
321
+ async function parseCompactionModelConfig(
322
+ argv,
323
+ baseProviderID,
324
+ baseModelID,
325
+ defaultOptions = {}
326
+ ) {
327
+ const defaultCompactionSafetyMarginPercent =
328
+ getDefaultCompactionSafetyMarginPercent(defaultOptions);
329
+
294
330
  // Get safety margin from CLI
295
331
  const cliSafetyMarginArg = getCompactionSafetyMarginFromProcessArgv();
296
332
  const compactionSafetyMarginPercent = cliSafetyMarginArg
297
333
  ? parseInt(cliSafetyMarginArg, 10)
298
334
  : (argv['compaction-safety-margin'] ??
299
- DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT);
335
+ defaultCompactionSafetyMarginPercent);
300
336
 
301
337
  // Check for --compaction-models (cascade) first — it overrides --compaction-model
302
338
  const cliCompactionModelsArg = getCompactionModelsFromProcessArgv();
303
339
  const compactionModelsArg =
304
340
  cliCompactionModelsArg ??
305
341
  argv['compaction-models'] ??
306
- DEFAULT_COMPACTION_MODELS;
342
+ getDefaultCompactionModels(defaultOptions);
307
343
 
308
344
  // Parse the links notation sequence into an array of model names
309
345
  const modelNames = parseLinksNotationSequence(compactionModelsArg);
@@ -363,7 +399,7 @@ async function parseCompactionModelConfig(argv, baseProviderID, baseModelID) {
363
399
  const compactionModelArg =
364
400
  cliCompactionModelArg ??
365
401
  argv['compaction-model'] ??
366
- DEFAULT_COMPACTION_MODEL;
402
+ getDefaultCompactionModel(defaultOptions);
367
403
 
368
404
  const resolved = await resolveCompactionModelEntry(
369
405
  compactionModelArg,
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
@@ -1,20 +1,26 @@
1
1
  import {
2
- DEFAULT_MODEL,
3
- DEFAULT_COMPACTION_MODEL,
4
- DEFAULT_COMPACTION_MODELS,
5
- DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
2
+ getDefaultModel,
3
+ getDefaultCompactionModel,
4
+ getDefaultCompactionModels,
5
+ getDefaultCompactionSafetyMarginPercent,
6
6
  } from './defaults.ts';
7
7
 
8
8
  /**
9
9
  * Yargs builder for the default `run` command options.
10
10
  * Extracted from index.js to keep file size under 1000 lines.
11
11
  */
12
- export function buildRunOptions(yargs) {
13
- return yargs
12
+ export function buildRunOptions(yargs, defaultOptions = {}) {
13
+ const defaultModel = getDefaultModel(defaultOptions);
14
+ const defaultCompactionModel = getDefaultCompactionModel(defaultOptions);
15
+ const defaultCompactionModels = getDefaultCompactionModels(defaultOptions);
16
+ const defaultCompactionSafetyMarginPercent =
17
+ getDefaultCompactionSafetyMarginPercent(defaultOptions);
18
+
19
+ const parser = yargs
14
20
  .option('model', {
15
21
  type: 'string',
16
22
  description: 'Model to use in format providerID/modelID',
17
- default: DEFAULT_MODEL,
23
+ default: defaultModel,
18
24
  })
19
25
  .option('json-standard', {
20
26
  type: 'string',
@@ -23,6 +29,19 @@ export function buildRunOptions(yargs) {
23
29
  default: 'opencode',
24
30
  choices: ['opencode', 'claude'],
25
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
+ })
26
45
  .option('system-message', {
27
46
  type: 'string',
28
47
  description: 'Full override of the system message',
@@ -120,7 +139,26 @@ export function buildRunOptions(yargs) {
120
139
  description:
121
140
  'When used with --resume or --continue, continue in the same session without forking to a new UUID.',
122
141
  default: false,
123
- })
142
+ });
143
+
144
+ const normalizedParser =
145
+ typeof parser.middleware === 'function'
146
+ ? parser.middleware((argv) => {
147
+ if (argv.fork === false) {
148
+ argv['no-fork'] = true;
149
+ argv.noFork = true;
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
+ }
158
+ }, true)
159
+ : parser;
160
+
161
+ return normalizedParser
124
162
  .option('generate-title', {
125
163
  type: 'boolean',
126
164
  description:
@@ -153,7 +191,7 @@ export function buildRunOptions(yargs) {
153
191
  type: 'string',
154
192
  description:
155
193
  'Model to use for context compaction in format providerID/modelID. Use "same" to use the base model. Default: opencode/gpt-5-nano (free, 400K context). Overridden by --compaction-models if both are specified.',
156
- default: DEFAULT_COMPACTION_MODEL,
194
+ default: defaultCompactionModel,
157
195
  })
158
196
  .option('compaction-models', {
159
197
  type: 'string',
@@ -161,13 +199,13 @@ export function buildRunOptions(yargs) {
161
199
  'Ordered cascade of compaction models in links notation sequence format: "(model1 model2 ... same)". ' +
162
200
  "Models are tried from smallest/cheapest context to largest. If used context exceeds a model's limit or its rate limit is reached, the next model is tried. " +
163
201
  'The special value "same" uses the base model. Overrides --compaction-model when specified.',
164
- default: DEFAULT_COMPACTION_MODELS,
202
+ default: defaultCompactionModels,
165
203
  })
166
204
  .option('compaction-safety-margin', {
167
205
  type: 'number',
168
206
  description:
169
- 'Safety margin (%) of usable context window before triggering compaction. Only applies when the compaction model has equal or smaller context than the base model. Default: 15.',
170
- default: DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT,
207
+ 'Safety margin (%) of usable context window before triggering compaction. Only applies when the compaction model has equal or smaller context than the base model. Default: 25.',
208
+ default: defaultCompactionSafetyMarginPercent,
171
209
  })
172
210
  .option('temperature', {
173
211
  type: 'number',
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Global default model configuration shared by runtime code and tests.
3
+ *
4
+ * Keep the hard-coded defaults here. Runtime helpers allow test runs and
5
+ * local automation to override those defaults without editing source files.
6
+ */
7
+
8
+ type EnvLike = Record<string, string | undefined>;
9
+
10
+ export interface DefaultConfigOptions {
11
+ env?: EnvLike;
12
+ defaultModel?: string | null;
13
+ defaultCompactionModel?: string | null;
14
+ defaultCompactionModels?: string | null;
15
+ defaultCompactionSafetyMarginPercent?: number | string | null;
16
+ }
17
+
18
+ /** Default model used when no `--model` CLI argument is provided. */
19
+ export const DEFAULT_MODEL = 'opencode/minimax-m2.5-free';
20
+
21
+ /** Env var for overriding the default model in test runs and automation. */
22
+ export const DEFAULT_MODEL_ENV = 'LINK_ASSISTANT_AGENT_DEFAULT_MODEL';
23
+
24
+ /** Default compaction model used when no `--compaction-model` CLI argument is provided. */
25
+ export const DEFAULT_COMPACTION_MODEL = 'opencode/gpt-5-nano';
26
+
27
+ /** Env var for overriding the default compaction model in test runs and automation. */
28
+ export const DEFAULT_COMPACTION_MODEL_ENV =
29
+ 'LINK_ASSISTANT_AGENT_DEFAULT_COMPACTION_MODEL';
30
+
31
+ /**
32
+ * Default compaction models cascade, ordered from smallest/cheapest context to largest.
33
+ * During compaction, the system tries each model in order. If the used context exceeds
34
+ * a model's context limit, it skips to the next larger model. If a model's rate limit
35
+ * is reached, it also skips to the next model.
36
+ * The special value "same" means use the same model as `--model`.
37
+ *
38
+ * Parsed as links notation references sequence (single anonymous link):
39
+ * "(big-pickle minimax-m2.5-free nemotron-3-super-free hy3-preview-free ling-2.6-flash-free gpt-5-nano same)"
40
+ *
41
+ * Context limits (approximate):
42
+ * big-pickle: ~200K
43
+ * minimax-m2.5-free: ~204K
44
+ * nemotron-3-super-free: ~204K
45
+ * hy3-preview-free: ~256K
46
+ * ling-2.6-flash-free: ~262K
47
+ * gpt-5-nano: ~400K
48
+ * same: (base model's context)
49
+ *
50
+ * Note: qwen3.6-plus-free was removed because the free promotion ended in April 2026.
51
+ * Note: minimax-m2.5-free is the default model again as of issue #266.
52
+ * @see https://github.com/link-assistant/agent/issues/266
53
+ * @see https://github.com/link-assistant/agent/issues/242
54
+ * @see https://github.com/link-assistant/agent/issues/232
55
+ */
56
+ export const DEFAULT_COMPACTION_MODELS =
57
+ '(big-pickle minimax-m2.5-free nemotron-3-super-free hy3-preview-free ling-2.6-flash-free gpt-5-nano same)';
58
+
59
+ /** Env var for overriding the default compaction cascade in test runs and automation. */
60
+ export const DEFAULT_COMPACTION_MODELS_ENV =
61
+ 'LINK_ASSISTANT_AGENT_DEFAULT_COMPACTION_MODELS';
62
+
63
+ /**
64
+ * Default compaction safety margin as a percentage of usable context window.
65
+ * Applied only when the compaction model has a context window equal to or smaller
66
+ * than the base model. When the compaction model has a larger context, the margin
67
+ * is automatically set to 0 (allowing 100% context usage).
68
+ *
69
+ * Increased from 15% to 25% to reduce probability of context overflow errors,
70
+ * especially when providers return inaccurate or zero token counts.
71
+ * Matches OpenCode upstream's 75% threshold (25% margin).
72
+ * @see https://github.com/link-assistant/agent/issues/219
73
+ * @see https://github.com/link-assistant/agent/issues/249
74
+ */
75
+ export const DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT = 25;
76
+
77
+ /** Env var for overriding the default compaction safety margin in test runs and automation. */
78
+ export const DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT_ENV =
79
+ 'LINK_ASSISTANT_AGENT_DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT';
80
+
81
+ function clean(value: string | null | undefined): string | undefined {
82
+ const trimmed = value?.trim();
83
+ return trimmed ? trimmed : undefined;
84
+ }
85
+
86
+ function optionString(value: string | null | undefined): string | undefined {
87
+ return clean(value);
88
+ }
89
+
90
+ function envString(env: EnvLike, key: string): string | undefined {
91
+ return clean(env[key]);
92
+ }
93
+
94
+ function optionNumber(
95
+ value: number | string | null | undefined
96
+ ): number | undefined {
97
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
98
+ if (typeof value === 'string') {
99
+ const parsed = Number.parseInt(value.trim(), 10);
100
+ if (Number.isFinite(parsed)) return parsed;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /** Resolve the effective default model. CLI `--model` still takes precedence later. */
106
+ export function getDefaultModel(options: DefaultConfigOptions = {}): string {
107
+ const env = options.env ?? process.env;
108
+ return (
109
+ optionString(options.defaultModel) ??
110
+ envString(env, DEFAULT_MODEL_ENV) ??
111
+ DEFAULT_MODEL
112
+ );
113
+ }
114
+
115
+ export function getDefaultCompactionModel(
116
+ options: DefaultConfigOptions = {}
117
+ ): string {
118
+ const env = options.env ?? process.env;
119
+ return (
120
+ optionString(options.defaultCompactionModel) ??
121
+ envString(env, DEFAULT_COMPACTION_MODEL_ENV) ??
122
+ DEFAULT_COMPACTION_MODEL
123
+ );
124
+ }
125
+
126
+ export function getDefaultCompactionModels(
127
+ options: DefaultConfigOptions = {}
128
+ ): string {
129
+ const env = options.env ?? process.env;
130
+ return (
131
+ optionString(options.defaultCompactionModels) ??
132
+ envString(env, DEFAULT_COMPACTION_MODELS_ENV) ??
133
+ DEFAULT_COMPACTION_MODELS
134
+ );
135
+ }
136
+
137
+ export function getDefaultCompactionSafetyMarginPercent(
138
+ options: DefaultConfigOptions = {}
139
+ ): number {
140
+ const env = options.env ?? process.env;
141
+ return (
142
+ optionNumber(options.defaultCompactionSafetyMarginPercent) ??
143
+ optionNumber(env[DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT_ENV]) ??
144
+ DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT
145
+ );
146
+ }
147
+
148
+ export function parseModelParts(model: string): {
149
+ providerID: string;
150
+ modelID: string;
151
+ } {
152
+ const [providerID, ...modelParts] = model.split('/');
153
+ return {
154
+ providerID,
155
+ modelID: modelParts.join('/'),
156
+ };
157
+ }
158
+
159
+ export function getDefaultModelParts(options: DefaultConfigOptions = {}): {
160
+ providerID: string;
161
+ modelID: string;
162
+ } {
163
+ return parseModelParts(getDefaultModel(options));
164
+ }
165
+
166
+ /** Default provider ID extracted from DEFAULT_MODEL. */
167
+ export const DEFAULT_PROVIDER_ID = parseModelParts(DEFAULT_MODEL).providerID;
168
+
169
+ /** Default model ID extracted from DEFAULT_MODEL. */
170
+ export const DEFAULT_MODEL_ID = parseModelParts(DEFAULT_MODEL).modelID;
package/src/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  runContinuousDirectMode,
37
37
  resolveResumeSession,
38
38
  } from './cli/continuous-mode.js';
39
+ import { parseStreamJsonInput } from './cli/input-queue.js';
39
40
  import { createBusEventSubscription } from './cli/event-handler.js';
40
41
  import {
41
42
  outputStatus,
@@ -259,6 +260,19 @@ async function readSystemMessages(argv) {
259
260
  return { systemMessage, appendSystemMessage };
260
261
  }
261
262
 
263
+ function getInputFormat(argv) {
264
+ return argv['input-format'] || argv.inputFormat || 'text';
265
+ }
266
+
267
+ function getAcceptedInputFormats(inputFormat, isInteractive) {
268
+ if (inputFormat === 'stream-json') {
269
+ return ['Claude stream-json user frames'];
270
+ }
271
+ return isInteractive
272
+ ? ['JSON object with "message" field', 'Plain text']
273
+ : ['JSON object with "message" field'];
274
+ }
275
+
262
276
  async function runAgentMode(argv, request) {
263
277
  // Log version and command info in verbose mode using lazy logging
264
278
  Log.Default.info(() => ({
@@ -488,7 +502,7 @@ async function runServerMode(
488
502
  });
489
503
  unsub = eventUnsub;
490
504
 
491
- // Send message to session with specified model (default: opencode/grok-code)
505
+ // Send message to session with specified model
492
506
  const message = request.message || 'hi';
493
507
  const parts = [{ type: 'text', text: message }];
494
508
 
@@ -663,8 +677,15 @@ async function main() {
663
677
  // Check if stdin is a TTY (interactive terminal)
664
678
  if (process.stdin.isTTY) {
665
679
  // Enter interactive terminal mode with continuous listening
666
- const isInteractive = argv.interactive !== false;
667
- const autoMerge = argv['auto-merge-queued-messages'] !== false;
680
+ const inputFormat = getInputFormat(argv);
681
+ const isInteractive =
682
+ inputFormat === 'stream-json'
683
+ ? false
684
+ : argv.interactive !== false;
685
+ const autoMerge =
686
+ inputFormat === 'stream-json'
687
+ ? false
688
+ : argv['auto-merge-queued-messages'] !== false;
668
689
  const alwaysAcceptStdin = argv['always-accept-stdin'] !== false;
669
690
 
670
691
  // Exit if --no-always-accept-stdin is set (single message mode not supported in TTY)
@@ -688,10 +709,12 @@ async function main() {
688
709
  message:
689
710
  'Agent CLI in interactive terminal mode. Type your message and press Enter.',
690
711
  hint: 'Press CTRL+C to exit. Use --help for options.',
691
- acceptedFormats: isInteractive
692
- ? ['JSON object with "message" field', 'Plain text']
693
- : ['JSON object with "message" field'],
712
+ acceptedFormats: getAcceptedInputFormats(
713
+ inputFormat,
714
+ isInteractive
715
+ ),
694
716
  options: {
717
+ inputFormat,
695
718
  interactive: isInteractive,
696
719
  autoMergeQueuedMessages: autoMerge,
697
720
  alwaysAcceptStdin,
@@ -707,8 +730,13 @@ async function main() {
707
730
  }
708
731
 
709
732
  // stdin is piped - enter stdin listening mode
710
- const isInteractive = argv.interactive !== false;
711
- const autoMerge = argv['auto-merge-queued-messages'] !== false;
733
+ const inputFormat = getInputFormat(argv);
734
+ const isInteractive =
735
+ inputFormat === 'stream-json' ? false : argv.interactive !== false;
736
+ const autoMerge =
737
+ inputFormat === 'stream-json'
738
+ ? false
739
+ : argv['auto-merge-queued-messages'] !== false;
712
740
  const alwaysAcceptStdin = argv['always-accept-stdin'] !== false;
713
741
 
714
742
  outputStatus(
@@ -719,10 +747,12 @@ async function main() {
719
747
  ? 'Agent CLI in continuous listening mode. Accepts JSON and plain text input.'
720
748
  : 'Agent CLI in single-message mode. Accepts JSON and plain text input.',
721
749
  hint: 'Press CTRL+C to exit. Use --help for options.',
722
- acceptedFormats: isInteractive
723
- ? ['JSON object with "message" field', 'Plain text']
724
- : ['JSON object with "message" field'],
750
+ acceptedFormats: getAcceptedInputFormats(
751
+ inputFormat,
752
+ isInteractive
753
+ ),
725
754
  options: {
755
+ inputFormat,
726
756
  interactive: isInteractive,
727
757
  autoMergeQueuedMessages: autoMerge,
728
758
  alwaysAcceptStdin,
@@ -757,27 +787,52 @@ async function main() {
757
787
 
758
788
  // Try to parse as JSON, if it fails treat it as plain text message
759
789
  let request;
760
- try {
761
- request = JSON.parse(trimmedInput);
762
- } catch (_e) {
763
- // Not JSON
764
- if (!isInteractive) {
765
- // In non-interactive mode, only accept JSON
790
+ if (inputFormat === 'stream-json') {
791
+ try {
792
+ const firstFrame = trimmedInput
793
+ .split(/\r?\n/)
794
+ .find((line) => line.trim());
795
+ request = parseStreamJsonInput(firstFrame || '');
796
+ if (request.kind !== 'message') {
797
+ throw new Error(
798
+ `Expected stream-json user frame, received ${request.kind}`
799
+ );
800
+ }
801
+ } catch (error) {
766
802
  outputError(
767
803
  {
768
804
  errorType: 'ValidationError',
769
805
  message:
770
- 'Invalid JSON input. In non-interactive mode (--no-interactive), only JSON input is accepted.',
771
- hint: 'Use --interactive to accept plain text, or provide valid JSON: {"message": "your text"}',
806
+ error instanceof Error ? error.message : String(error),
807
+ hint: 'Provide one Claude stream-json user frame per line.',
772
808
  },
773
809
  compactJson
774
810
  );
775
811
  process.exit(1);
776
812
  }
777
- // In interactive mode, treat as plain text message
778
- request = {
779
- message: trimmedInput,
780
- };
813
+ } else {
814
+ try {
815
+ request = JSON.parse(trimmedInput);
816
+ } catch (_e) {
817
+ // Not JSON
818
+ if (!isInteractive) {
819
+ // In non-interactive mode, only accept JSON
820
+ outputError(
821
+ {
822
+ errorType: 'ValidationError',
823
+ message:
824
+ 'Invalid JSON input. In non-interactive mode (--no-interactive), only JSON input is accepted.',
825
+ hint: 'Use --interactive to accept plain text, or provide valid JSON: {"message": "your text"}',
826
+ },
827
+ compactJson
828
+ );
829
+ process.exit(1);
830
+ }
831
+ // In interactive mode, treat as plain text message
832
+ request = {
833
+ message: trimmedInput,
834
+ };
835
+ }
781
836
  }
782
837
 
783
838
  // Output input confirmation in JSON format
@@ -785,7 +840,12 @@ async function main() {
785
840
  {
786
841
  raw: trimmedInput,
787
842
  parsed: request,
788
- format: isInteractive ? 'text' : 'json',
843
+ format:
844
+ inputFormat === 'stream-json'
845
+ ? 'stream-json'
846
+ : isInteractive
847
+ ? 'text'
848
+ : 'json',
789
849
  },
790
850
  compactJson
791
851
  );