@link-assistant/agent 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -181,6 +181,18 @@ See [MODELS.md](MODELS.md) for complete list of available models and pricing.
181
181
  See [docs/groq.md](docs/groq.md) for Groq provider documentation.
182
182
  See [docs/claude-oauth.md](docs/claude-oauth.md) for Claude OAuth provider documentation.
183
183
 
184
+ ### Direct Prompt Mode
185
+
186
+ Use `-p`/`--prompt` to send a prompt directly without reading from stdin:
187
+
188
+ ```bash
189
+ # Direct prompt (bypasses stdin)
190
+ agent -p "What is 2+2?"
191
+
192
+ # Useful in scripts
193
+ result=$(agent -p "Summarize: $(cat file.txt)")
194
+ ```
195
+
184
196
  ### CLI Options
185
197
 
186
198
  ```bash
@@ -197,6 +209,19 @@ Options:
197
209
  --system-message-file Full override of the system message from file
198
210
  --append-system-message Append to the default system message
199
211
  --append-system-message-file Append to the default system message from file
212
+
213
+ Stdin Mode Options:
214
+ -p, --prompt Direct prompt (bypasses stdin reading)
215
+ --disable-stdin Disable stdin streaming (requires --prompt)
216
+ --stdin-stream-timeout Timeout in ms for stdin reading (default: none)
217
+ --interactive Accept plain text input (default: true)
218
+ --no-interactive Only accept JSON input
219
+ --auto-merge-queued-messages Merge rapidly arriving lines (default: true)
220
+ --no-auto-merge-queued-messages Treat each line as separate message
221
+ --always-accept-stdin Keep accepting input after agent finishes (default: true)
222
+ --no-always-accept-stdin Single-message mode - exit after first response
223
+ --compact-json Output compact JSON for program-to-program use
224
+
200
225
  --help Show help
201
226
  --version Show version number
202
227
 
@@ -208,6 +233,8 @@ Commands:
208
233
  mcp MCP server commands
209
234
  ```
210
235
 
236
+ See [docs/stdin-mode.md](docs/stdin-mode.md) for comprehensive stdin mode documentation.
237
+
211
238
  ### JSON Output Standards
212
239
 
213
240
  The agent supports two JSON output format standards via the `--json-standard` option:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -849,6 +849,8 @@ const GOOGLE_OAUTH_SCOPES = [
849
849
  'https://www.googleapis.com/auth/cloud-platform',
850
850
  'https://www.googleapis.com/auth/userinfo.email',
851
851
  'https://www.googleapis.com/auth/userinfo.profile',
852
+ 'https://www.googleapis.com/auth/generative-language.tuning',
853
+ 'https://www.googleapis.com/auth/generative-language.retriever',
852
854
  ];
853
855
 
854
856
  // Google OAuth endpoints
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Continuous stdin mode for the Agent CLI.
3
+ * Keeps the session alive and processes messages as they arrive.
4
+ */
5
+
6
+ import { Server } from '../server/server.ts';
7
+ import { Instance } from '../project/instance.ts';
8
+ import { Bus } from '../bus/index.ts';
9
+ import { Session } from '../session/index.ts';
10
+ import { SessionPrompt } from '../session/prompt.ts';
11
+ import { createEventHandler } from '../json-standard/index.ts';
12
+ import { createContinuousStdinReader } from './input-queue.js';
13
+
14
+ // Shared error tracking
15
+ let hasError = false;
16
+
17
+ /**
18
+ * Set error state
19
+ * @param {boolean} value - Error state value
20
+ */
21
+ export function setHasError(value) {
22
+ hasError = value;
23
+ }
24
+
25
+ /**
26
+ * Get error state
27
+ * @returns {boolean}
28
+ */
29
+ export function getHasError() {
30
+ return hasError;
31
+ }
32
+
33
+ /**
34
+ * Output JSON status message to stderr
35
+ * @param {object} status - Status object to output
36
+ * @param {boolean} compact - If true, output compact JSON (single line)
37
+ */
38
+ function outputStatus(status, compact = false) {
39
+ const json = compact
40
+ ? JSON.stringify(status)
41
+ : JSON.stringify(status, null, 2);
42
+ console.error(json);
43
+ }
44
+
45
+ /**
46
+ * Run server mode with continuous stdin input
47
+ * Keeps the session alive and processes messages as they arrive
48
+ */
49
+ export async function runContinuousServerMode(
50
+ argv,
51
+ providerID,
52
+ modelID,
53
+ systemMessage,
54
+ appendSystemMessage,
55
+ jsonStandard
56
+ ) {
57
+ const compactJson = argv['compact-json'] === true;
58
+ const isInteractive = argv.interactive !== false;
59
+ const autoMerge = argv['auto-merge-queued-messages'] !== false;
60
+
61
+ // Start server like OpenCode does
62
+ const server = Server.listen({ port: 0, hostname: '127.0.0.1' });
63
+ let unsub = null;
64
+ let stdinReader = null;
65
+
66
+ try {
67
+ // Create a session
68
+ const createRes = await fetch(
69
+ `http://${server.hostname}:${server.port}/session`,
70
+ {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({}),
74
+ }
75
+ );
76
+ const session = await createRes.json();
77
+ const sessionID = session.id;
78
+
79
+ if (!sessionID) {
80
+ throw new Error('Failed to create session');
81
+ }
82
+
83
+ // Create event handler for the selected JSON standard
84
+ const eventHandler = createEventHandler(jsonStandard, sessionID);
85
+
86
+ // Track if we're currently processing a message
87
+ let isProcessing = false;
88
+ const pendingMessages = [];
89
+
90
+ // Process messages from the queue
91
+ const processMessage = async (message) => {
92
+ if (isProcessing) {
93
+ pendingMessages.push(message);
94
+ return;
95
+ }
96
+
97
+ isProcessing = true;
98
+ const messageText = message.message || 'hi';
99
+ const parts = [{ type: 'text', text: messageText }];
100
+
101
+ // Create a promise to wait for this message to complete
102
+ const messagePromise = new Promise((resolve) => {
103
+ const checkIdle = Bus.subscribeAll((event) => {
104
+ if (
105
+ event.type === 'session.idle' &&
106
+ event.properties.sessionID === sessionID
107
+ ) {
108
+ checkIdle();
109
+ resolve();
110
+ }
111
+ });
112
+ });
113
+
114
+ // Send message
115
+ fetch(
116
+ `http://${server.hostname}:${server.port}/session/${sessionID}/message`,
117
+ {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({
121
+ parts,
122
+ model: { providerID, modelID },
123
+ system: systemMessage,
124
+ appendSystem: appendSystemMessage,
125
+ }),
126
+ }
127
+ ).catch((error) => {
128
+ hasError = true;
129
+ eventHandler.output({
130
+ type: 'error',
131
+ timestamp: Date.now(),
132
+ sessionID,
133
+ error: error instanceof Error ? error.message : String(error),
134
+ });
135
+ });
136
+
137
+ await messagePromise;
138
+ isProcessing = false;
139
+
140
+ // Process next pending message if any
141
+ if (pendingMessages.length > 0) {
142
+ const nextMessage = pendingMessages.shift();
143
+ processMessage(nextMessage);
144
+ }
145
+ };
146
+
147
+ // Subscribe to all bus events and output in selected format
148
+ unsub = Bus.subscribeAll((event) => {
149
+ if (event.type === 'message.part.updated') {
150
+ const part = event.properties.part;
151
+ if (part.sessionID !== sessionID) {
152
+ return;
153
+ }
154
+
155
+ if (part.type === 'step-start') {
156
+ eventHandler.output({
157
+ type: 'step_start',
158
+ timestamp: Date.now(),
159
+ sessionID,
160
+ part,
161
+ });
162
+ }
163
+
164
+ if (part.type === 'step-finish') {
165
+ eventHandler.output({
166
+ type: 'step_finish',
167
+ timestamp: Date.now(),
168
+ sessionID,
169
+ part,
170
+ });
171
+ }
172
+
173
+ if (part.type === 'text' && part.time?.end) {
174
+ eventHandler.output({
175
+ type: 'text',
176
+ timestamp: Date.now(),
177
+ sessionID,
178
+ part,
179
+ });
180
+ }
181
+
182
+ if (part.type === 'tool' && part.state.status === 'completed') {
183
+ eventHandler.output({
184
+ type: 'tool_use',
185
+ timestamp: Date.now(),
186
+ sessionID,
187
+ part,
188
+ });
189
+ }
190
+ }
191
+
192
+ if (event.type === 'session.error') {
193
+ const props = event.properties;
194
+ if (props.sessionID !== sessionID || !props.error) {
195
+ return;
196
+ }
197
+ hasError = true;
198
+ eventHandler.output({
199
+ type: 'error',
200
+ timestamp: Date.now(),
201
+ sessionID,
202
+ error: props.error,
203
+ });
204
+ }
205
+ });
206
+
207
+ // Create continuous stdin reader
208
+ stdinReader = createContinuousStdinReader({
209
+ interactive: isInteractive,
210
+ autoMerge,
211
+ onMessage: (message) => {
212
+ processMessage(message);
213
+ },
214
+ });
215
+
216
+ // Wait for stdin to end (EOF or close)
217
+ await new Promise((resolve) => {
218
+ const checkRunning = setInterval(() => {
219
+ if (!stdinReader.isRunning()) {
220
+ clearInterval(checkRunning);
221
+ // Wait for any pending messages to complete
222
+ const waitForPending = () => {
223
+ if (!isProcessing && pendingMessages.length === 0) {
224
+ resolve();
225
+ } else {
226
+ setTimeout(waitForPending, 100);
227
+ }
228
+ };
229
+ waitForPending();
230
+ }
231
+ }, 100);
232
+
233
+ // Also handle SIGINT
234
+ process.on('SIGINT', () => {
235
+ outputStatus(
236
+ {
237
+ type: 'status',
238
+ message: 'Received SIGINT. Shutting down...',
239
+ },
240
+ compactJson
241
+ );
242
+ clearInterval(checkRunning);
243
+ resolve();
244
+ });
245
+ });
246
+ } finally {
247
+ if (stdinReader) {
248
+ stdinReader.stop();
249
+ }
250
+ if (unsub) {
251
+ unsub();
252
+ }
253
+ server.stop();
254
+ await Instance.dispose();
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Run direct mode with continuous stdin input
260
+ * Keeps the session alive and processes messages as they arrive
261
+ */
262
+ export async function runContinuousDirectMode(
263
+ argv,
264
+ providerID,
265
+ modelID,
266
+ systemMessage,
267
+ appendSystemMessage,
268
+ jsonStandard
269
+ ) {
270
+ const compactJson = argv['compact-json'] === true;
271
+ const isInteractive = argv.interactive !== false;
272
+ const autoMerge = argv['auto-merge-queued-messages'] !== false;
273
+
274
+ let unsub = null;
275
+ let stdinReader = null;
276
+
277
+ try {
278
+ // Create a session directly
279
+ const session = await Session.createNext({
280
+ directory: process.cwd(),
281
+ });
282
+ const sessionID = session.id;
283
+
284
+ // Create event handler for the selected JSON standard
285
+ const eventHandler = createEventHandler(jsonStandard, sessionID);
286
+
287
+ // Track if we're currently processing a message
288
+ let isProcessing = false;
289
+ const pendingMessages = [];
290
+
291
+ // Process messages from the queue
292
+ const processMessage = async (message) => {
293
+ if (isProcessing) {
294
+ pendingMessages.push(message);
295
+ return;
296
+ }
297
+
298
+ isProcessing = true;
299
+ const messageText = message.message || 'hi';
300
+ const parts = [{ type: 'text', text: messageText }];
301
+
302
+ // Create a promise to wait for this message to complete
303
+ const messagePromise = new Promise((resolve) => {
304
+ const checkIdle = Bus.subscribeAll((event) => {
305
+ if (
306
+ event.type === 'session.idle' &&
307
+ event.properties.sessionID === sessionID
308
+ ) {
309
+ checkIdle();
310
+ resolve();
311
+ }
312
+ });
313
+ });
314
+
315
+ // Send message directly
316
+ SessionPrompt.prompt({
317
+ sessionID,
318
+ parts,
319
+ model: { providerID, modelID },
320
+ system: systemMessage,
321
+ appendSystem: appendSystemMessage,
322
+ }).catch((error) => {
323
+ hasError = true;
324
+ eventHandler.output({
325
+ type: 'error',
326
+ timestamp: Date.now(),
327
+ sessionID,
328
+ error: error instanceof Error ? error.message : String(error),
329
+ });
330
+ });
331
+
332
+ await messagePromise;
333
+ isProcessing = false;
334
+
335
+ // Process next pending message if any
336
+ if (pendingMessages.length > 0) {
337
+ const nextMessage = pendingMessages.shift();
338
+ processMessage(nextMessage);
339
+ }
340
+ };
341
+
342
+ // Subscribe to all bus events and output in selected format
343
+ unsub = Bus.subscribeAll((event) => {
344
+ if (event.type === 'message.part.updated') {
345
+ const part = event.properties.part;
346
+ if (part.sessionID !== sessionID) {
347
+ return;
348
+ }
349
+
350
+ if (part.type === 'step-start') {
351
+ eventHandler.output({
352
+ type: 'step_start',
353
+ timestamp: Date.now(),
354
+ sessionID,
355
+ part,
356
+ });
357
+ }
358
+
359
+ if (part.type === 'step-finish') {
360
+ eventHandler.output({
361
+ type: 'step_finish',
362
+ timestamp: Date.now(),
363
+ sessionID,
364
+ part,
365
+ });
366
+ }
367
+
368
+ if (part.type === 'text' && part.time?.end) {
369
+ eventHandler.output({
370
+ type: 'text',
371
+ timestamp: Date.now(),
372
+ sessionID,
373
+ part,
374
+ });
375
+ }
376
+
377
+ if (part.type === 'tool' && part.state.status === 'completed') {
378
+ eventHandler.output({
379
+ type: 'tool_use',
380
+ timestamp: Date.now(),
381
+ sessionID,
382
+ part,
383
+ });
384
+ }
385
+ }
386
+
387
+ if (event.type === 'session.error') {
388
+ const props = event.properties;
389
+ if (props.sessionID !== sessionID || !props.error) {
390
+ return;
391
+ }
392
+ hasError = true;
393
+ eventHandler.output({
394
+ type: 'error',
395
+ timestamp: Date.now(),
396
+ sessionID,
397
+ error: props.error,
398
+ });
399
+ }
400
+ });
401
+
402
+ // Create continuous stdin reader
403
+ stdinReader = createContinuousStdinReader({
404
+ interactive: isInteractive,
405
+ autoMerge,
406
+ onMessage: (message) => {
407
+ processMessage(message);
408
+ },
409
+ });
410
+
411
+ // Wait for stdin to end (EOF or close)
412
+ await new Promise((resolve) => {
413
+ const checkRunning = setInterval(() => {
414
+ if (!stdinReader.isRunning()) {
415
+ clearInterval(checkRunning);
416
+ // Wait for any pending messages to complete
417
+ const waitForPending = () => {
418
+ if (!isProcessing && pendingMessages.length === 0) {
419
+ resolve();
420
+ } else {
421
+ setTimeout(waitForPending, 100);
422
+ }
423
+ };
424
+ waitForPending();
425
+ }
426
+ }, 100);
427
+
428
+ // Also handle SIGINT
429
+ process.on('SIGINT', () => {
430
+ outputStatus(
431
+ {
432
+ type: 'status',
433
+ message: 'Received SIGINT. Shutting down...',
434
+ },
435
+ compactJson
436
+ );
437
+ clearInterval(checkRunning);
438
+ resolve();
439
+ });
440
+ });
441
+ } finally {
442
+ if (stdinReader) {
443
+ stdinReader.stop();
444
+ }
445
+ if (unsub) {
446
+ unsub();
447
+ }
448
+ await Instance.dispose();
449
+ }
450
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Input queue for managing continuous stdin input.
3
+ * Supports queuing of multiple messages and auto-merging of rapidly arriving lines.
4
+ */
5
+ export class InputQueue {
6
+ constructor(options = {}) {
7
+ this.queue = [];
8
+ this.autoMerge = options.autoMerge !== false; // enabled by default
9
+ this.mergeDelayMs = options.mergeDelayMs || 50; // delay to wait for more lines
10
+ this.pendingLines = [];
11
+ this.mergeTimer = null;
12
+ this.onMessage = options.onMessage || (() => {});
13
+ this.interactive = options.interactive !== false; // enabled by default
14
+ }
15
+
16
+ /**
17
+ * Parse input and determine if it's JSON or plain text
18
+ * @param {string} input - Raw input string
19
+ * @returns {object} - Parsed message object
20
+ */
21
+ parseInput(input) {
22
+ const trimmed = input.trim();
23
+ if (!trimmed) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const parsed = JSON.parse(trimmed);
29
+ // If it has a message field, use it directly
30
+ if (typeof parsed === 'object' && parsed !== null) {
31
+ return parsed;
32
+ }
33
+ // Otherwise wrap it
34
+ return { message: JSON.stringify(parsed) };
35
+ } catch (_e) {
36
+ // Not JSON, treat as plain text message
37
+ return { message: trimmed };
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Add a line to the queue, potentially merging with pending lines
43
+ * @param {string} line - Input line
44
+ */
45
+ addLine(line) {
46
+ if (!this.interactive && !line.trim().startsWith('{')) {
47
+ // In non-interactive mode, only accept JSON
48
+ return;
49
+ }
50
+
51
+ if (this.autoMerge) {
52
+ // Add to pending lines and schedule merge
53
+ this.pendingLines.push(line);
54
+ this.scheduleMerge();
55
+ } else {
56
+ // No merging, queue immediately
57
+ const parsed = this.parseInput(line);
58
+ if (parsed) {
59
+ this.queue.push(parsed);
60
+ this.notifyMessage(parsed);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Schedule a merge of pending lines after a short delay
67
+ */
68
+ scheduleMerge() {
69
+ if (this.mergeTimer) {
70
+ clearTimeout(this.mergeTimer);
71
+ }
72
+ this.mergeTimer = setTimeout(() => {
73
+ this.flushPendingLines();
74
+ }, this.mergeDelayMs);
75
+ }
76
+
77
+ /**
78
+ * Flush pending lines into a single merged message
79
+ */
80
+ flushPendingLines() {
81
+ if (this.pendingLines.length === 0) {
82
+ return;
83
+ }
84
+
85
+ const mergedText = this.pendingLines.join('\n');
86
+ this.pendingLines = [];
87
+ this.mergeTimer = null;
88
+
89
+ const parsed = this.parseInput(mergedText);
90
+ if (parsed) {
91
+ this.queue.push(parsed);
92
+ this.notifyMessage(parsed);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Immediately flush any pending lines (for shutdown)
98
+ */
99
+ flush() {
100
+ if (this.mergeTimer) {
101
+ clearTimeout(this.mergeTimer);
102
+ this.mergeTimer = null;
103
+ }
104
+ this.flushPendingLines();
105
+ }
106
+
107
+ /**
108
+ * Get next message from the queue
109
+ * @returns {object|null} - Next message or null if queue is empty
110
+ */
111
+ dequeue() {
112
+ return this.queue.shift() || null;
113
+ }
114
+
115
+ /**
116
+ * Check if queue has messages
117
+ * @returns {boolean}
118
+ */
119
+ hasMessages() {
120
+ return this.queue.length > 0;
121
+ }
122
+
123
+ /**
124
+ * Notify listener of new message
125
+ * @param {object} message - The message object
126
+ */
127
+ notifyMessage(message) {
128
+ if (this.onMessage) {
129
+ this.onMessage(message);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Get queue size
135
+ * @returns {number}
136
+ */
137
+ size() {
138
+ return this.queue.length;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Create a continuous stdin reader that queues input lines
144
+ * @param {object} options - Options for the reader
145
+ * @returns {object} - Reader with queue and control methods
146
+ */
147
+ // Note: This function is prepared for future continuous input mode
148
+ export function createContinuousStdinReader(options = {}) {
149
+ const inputQueue = new InputQueue(options);
150
+ let isRunning = true;
151
+ let lineBuffer = '';
152
+
153
+ const handleData = (chunk) => {
154
+ if (!isRunning) {
155
+ return;
156
+ }
157
+
158
+ lineBuffer += chunk.toString();
159
+ const lines = lineBuffer.split('\n');
160
+
161
+ // Keep the last incomplete line in buffer
162
+ lineBuffer = lines.pop() || '';
163
+
164
+ // Process complete lines
165
+ for (const line of lines) {
166
+ if (line.trim()) {
167
+ inputQueue.addLine(line);
168
+ }
169
+ }
170
+ };
171
+
172
+ const handleEnd = () => {
173
+ // Process any remaining data in buffer
174
+ if (lineBuffer.trim()) {
175
+ inputQueue.addLine(lineBuffer);
176
+ }
177
+ inputQueue.flush();
178
+ isRunning = false;
179
+ };
180
+
181
+ process.stdin.on('data', handleData);
182
+ process.stdin.on('end', handleEnd);
183
+ process.stdin.on('error', () => {
184
+ isRunning = false;
185
+ });
186
+
187
+ return {
188
+ queue: inputQueue,
189
+ stop: () => {
190
+ isRunning = false;
191
+ inputQueue.flush();
192
+ process.stdin.removeListener('data', handleData);
193
+ process.stdin.removeListener('end', handleEnd);
194
+ },
195
+ isRunning: () => isRunning,
196
+ };
197
+ }
package/src/index.js CHANGED
@@ -18,6 +18,10 @@ import { AuthCommand } from './cli/cmd/auth.ts';
18
18
  import { Flag } from './flag/flag.ts';
19
19
  import { FormatError } from './cli/error.ts';
20
20
  import { UI } from './cli/ui.ts';
21
+ import {
22
+ runContinuousServerMode,
23
+ runContinuousDirectMode,
24
+ } from './cli/continuous-mode.js';
21
25
  import { createRequire } from 'module';
22
26
  import { readFileSync } from 'fs';
23
27
  import { dirname, join } from 'path';
@@ -73,50 +77,80 @@ process.on('unhandledRejection', (reason, _promise) => {
73
77
  process.exit(1);
74
78
  });
75
79
 
76
- function readStdin() {
77
- return new Promise((resolve, reject) => {
80
+ /**
81
+ * Read stdin with optional timeout.
82
+ * @param {number|null} timeout - Timeout in milliseconds. If null, wait indefinitely until EOF.
83
+ * @returns {Promise<string>} - The stdin content
84
+ */
85
+ function readStdinWithTimeout(timeout = null) {
86
+ return new Promise((resolve) => {
78
87
  let data = '';
88
+ let hasData = false;
89
+ let timer = null;
90
+
91
+ const cleanup = () => {
92
+ if (timer) {
93
+ clearTimeout(timer);
94
+ }
95
+ process.stdin.removeListener('data', onData);
96
+ process.stdin.removeListener('end', onEnd);
97
+ process.stdin.removeListener('error', onError);
98
+ };
99
+
79
100
  const onData = (chunk) => {
101
+ hasData = true;
102
+ if (timer) {
103
+ clearTimeout(timer);
104
+ }
80
105
  data += chunk;
81
106
  };
107
+
82
108
  const onEnd = () => {
83
109
  cleanup();
84
110
  resolve(data);
85
111
  };
86
- const onError = (err) => {
112
+
113
+ const onError = () => {
87
114
  cleanup();
88
- reject(err);
89
- };
90
- const cleanup = () => {
91
- process.stdin.removeListener('data', onData);
92
- process.stdin.removeListener('end', onEnd);
93
- process.stdin.removeListener('error', onError);
115
+ resolve('');
94
116
  };
117
+
118
+ // Only set timeout if specified (not null)
119
+ if (timeout !== null) {
120
+ timer = setTimeout(() => {
121
+ if (!hasData) {
122
+ process.stdin.pause();
123
+ cleanup();
124
+ resolve('');
125
+ }
126
+ }, timeout);
127
+ }
128
+
95
129
  process.stdin.on('data', onData);
96
130
  process.stdin.on('end', onEnd);
97
131
  process.stdin.on('error', onError);
98
132
  });
99
133
  }
100
134
 
101
- async function runAgentMode(argv) {
102
- // Note: verbose flag and logging are now initialized in middleware
103
- // See main() function for the middleware that sets up Flag and Log.init()
104
-
105
- // Log version and command info in verbose mode
106
- if (Flag.OPENCODE_VERBOSE) {
107
- console.error(`Agent version: ${pkg.version}`);
108
- console.error(`Command: ${process.argv.join(' ')}`);
109
- console.error(`Working directory: ${process.cwd()}`);
110
- console.error(`Script path: ${import.meta.path}`);
111
- }
112
-
113
- // Log dry-run mode if enabled
114
- if (Flag.OPENCODE_DRY_RUN) {
115
- console.error(
116
- `[DRY RUN MODE] No actual API calls or package installations will be made`
117
- );
118
- }
135
+ /**
136
+ * Output JSON status message to stderr
137
+ * This prevents the status message from interfering with JSON output parsing
138
+ * @param {object} status - Status object to output
139
+ * @param {boolean} compact - If true, output compact JSON (single line)
140
+ */
141
+ function outputStatus(status, compact = false) {
142
+ const json = compact
143
+ ? JSON.stringify(status)
144
+ : JSON.stringify(status, null, 2);
145
+ console.error(json);
146
+ }
119
147
 
148
+ /**
149
+ * Parse model configuration from argv
150
+ * @param {object} argv - Command line arguments
151
+ * @returns {object} - { providerID, modelID }
152
+ */
153
+ async function parseModelConfig(argv) {
120
154
  // Parse model argument (handle model IDs with slashes like groq/qwen/qwen3-32b)
121
155
  const modelParts = argv.model.split('/');
122
156
  let providerID = modelParts[0] || 'opencode';
@@ -131,13 +165,15 @@ async function runAgentMode(argv) {
131
165
  const creds = await ClaudeOAuth.getCredentials();
132
166
 
133
167
  if (!creds?.accessToken) {
134
- console.error(
135
- JSON.stringify({
168
+ const compactJson = argv['compact-json'] === true;
169
+ outputStatus(
170
+ {
136
171
  type: 'error',
137
172
  errorType: 'AuthenticationError',
138
173
  message:
139
174
  '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)',
140
- })
175
+ },
176
+ compactJson
141
177
  );
142
178
  process.exit(1);
143
179
  }
@@ -152,26 +188,27 @@ async function runAgentMode(argv) {
152
188
  modelID = 'claude-sonnet-4-5';
153
189
  } else if (!['claude-oauth', 'anthropic'].includes(providerID)) {
154
190
  // If user specified a different provider, warn them
155
- console.error(
156
- JSON.stringify({
191
+ const compactJson = argv['compact-json'] === true;
192
+ outputStatus(
193
+ {
157
194
  type: 'warning',
158
195
  message: `--use-existing-claude-oauth is set but model uses provider "${providerID}". Using OAuth credentials anyway.`,
159
- })
196
+ },
197
+ compactJson
160
198
  );
161
199
  providerID = 'claude-oauth';
162
200
  }
163
201
  }
164
202
 
165
- // Validate and get JSON standard
166
- const jsonStandard = argv['json-standard'];
167
- if (!isValidJsonStandard(jsonStandard)) {
168
- console.error(
169
- `Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`
170
- );
171
- process.exit(1);
172
- }
203
+ return { providerID, modelID };
204
+ }
173
205
 
174
- // Read system message files
206
+ /**
207
+ * Read system message from files if specified
208
+ * @param {object} argv - Command line arguments
209
+ * @returns {object} - { systemMessage, appendSystemMessage }
210
+ */
211
+ async function readSystemMessages(argv) {
175
212
  let systemMessage = argv['system-message'];
176
213
  let appendSystemMessage = argv['append-system-message'];
177
214
 
@@ -205,23 +242,43 @@ async function runAgentMode(argv) {
205
242
  appendSystemMessage = await file.text();
206
243
  }
207
244
 
208
- // Logging is already initialized in middleware, no need to call Log.init() again
245
+ return { systemMessage, appendSystemMessage };
246
+ }
209
247
 
210
- // Read input from stdin
211
- const input = await readStdin();
212
- const trimmedInput = input.trim();
248
+ async function runAgentMode(argv, request) {
249
+ // Note: verbose flag and logging are now initialized in middleware
250
+ // See main() function for the middleware that sets up Flag and Log.init()
213
251
 
214
- // Try to parse as JSON, if it fails treat it as plain text message
215
- let request;
216
- try {
217
- request = JSON.parse(trimmedInput);
218
- } catch (_e) {
219
- // Not JSON, treat as plain text message
220
- request = {
221
- message: trimmedInput,
222
- };
252
+ // Log version and command info in verbose mode
253
+ if (Flag.OPENCODE_VERBOSE) {
254
+ console.error(`Agent version: ${pkg.version}`);
255
+ console.error(`Command: ${process.argv.join(' ')}`);
256
+ console.error(`Working directory: ${process.cwd()}`);
257
+ console.error(`Script path: ${import.meta.path}`);
258
+ }
259
+
260
+ // Log dry-run mode if enabled
261
+ if (Flag.OPENCODE_DRY_RUN) {
262
+ console.error(
263
+ `[DRY RUN MODE] No actual API calls or package installations will be made`
264
+ );
223
265
  }
224
266
 
267
+ const { providerID, modelID } = await parseModelConfig(argv);
268
+
269
+ // Validate and get JSON standard
270
+ const jsonStandard = argv['json-standard'];
271
+ if (!isValidJsonStandard(jsonStandard)) {
272
+ console.error(
273
+ `Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`
274
+ );
275
+ process.exit(1);
276
+ }
277
+
278
+ const { systemMessage, appendSystemMessage } = await readSystemMessages(argv);
279
+
280
+ // Logging is already initialized in middleware, no need to call Log.init() again
281
+
225
282
  // Wrap in Instance.provide for OpenCode infrastructure
226
283
  await Instance.provide({
227
284
  directory: process.cwd(),
@@ -254,6 +311,81 @@ async function runAgentMode(argv) {
254
311
  process.exit(hasError ? 1 : 0);
255
312
  }
256
313
 
314
+ /**
315
+ * Run agent in continuous stdin mode
316
+ * Keeps accepting input until EOF or SIGINT
317
+ * @param {object} argv - Command line arguments
318
+ */
319
+ async function runContinuousAgentMode(argv) {
320
+ // Note: verbose flag and logging are now initialized in middleware
321
+ // See main() function for the middleware that sets up Flag and Log.init()
322
+
323
+ const compactJson = argv['compact-json'] === true;
324
+
325
+ // Log version and command info in verbose mode
326
+ if (Flag.OPENCODE_VERBOSE) {
327
+ console.error(`Agent version: ${pkg.version}`);
328
+ console.error(`Command: ${process.argv.join(' ')}`);
329
+ console.error(`Working directory: ${process.cwd()}`);
330
+ console.error(`Script path: ${import.meta.path}`);
331
+ }
332
+
333
+ // Log dry-run mode if enabled
334
+ if (Flag.OPENCODE_DRY_RUN) {
335
+ console.error(
336
+ `[DRY RUN MODE] No actual API calls or package installations will be made`
337
+ );
338
+ }
339
+
340
+ const { providerID, modelID } = await parseModelConfig(argv);
341
+
342
+ // Validate and get JSON standard
343
+ const jsonStandard = argv['json-standard'];
344
+ if (!isValidJsonStandard(jsonStandard)) {
345
+ outputStatus(
346
+ {
347
+ type: 'error',
348
+ message: `Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`,
349
+ },
350
+ compactJson
351
+ );
352
+ process.exit(1);
353
+ }
354
+
355
+ const { systemMessage, appendSystemMessage } = await readSystemMessages(argv);
356
+
357
+ // Wrap in Instance.provide for OpenCode infrastructure
358
+ await Instance.provide({
359
+ directory: process.cwd(),
360
+ fn: async () => {
361
+ if (argv.server) {
362
+ // SERVER MODE: Start server and communicate via HTTP
363
+ await runContinuousServerMode(
364
+ argv,
365
+ providerID,
366
+ modelID,
367
+ systemMessage,
368
+ appendSystemMessage,
369
+ jsonStandard
370
+ );
371
+ } else {
372
+ // DIRECT MODE: Run everything in single process
373
+ await runContinuousDirectMode(
374
+ argv,
375
+ providerID,
376
+ modelID,
377
+ systemMessage,
378
+ appendSystemMessage,
379
+ jsonStandard
380
+ );
381
+ }
382
+ },
383
+ });
384
+
385
+ // Explicitly exit to ensure process terminates
386
+ process.exit(hasError ? 1 : 0);
387
+ }
388
+
257
389
  async function runServerMode(
258
390
  request,
259
391
  providerID,
@@ -533,7 +665,7 @@ async function runDirectMode(
533
665
  async function main() {
534
666
  try {
535
667
  // Parse command line arguments with subcommands
536
- const argv = await yargs(hideBin(process.argv))
668
+ const yargsInstance = yargs(hideBin(process.argv))
537
669
  .scriptName('agent')
538
670
  .usage('$0 [command] [options]')
539
671
  .version(pkg.version)
@@ -593,6 +725,46 @@ async function main() {
593
725
  'Use existing Claude OAuth credentials from ~/.claude/.credentials.json (from Claude Code CLI)',
594
726
  default: false,
595
727
  })
728
+ .option('prompt', {
729
+ alias: 'p',
730
+ type: 'string',
731
+ description: 'Prompt message to send directly (bypasses stdin reading)',
732
+ })
733
+ .option('disable-stdin', {
734
+ type: 'boolean',
735
+ description:
736
+ 'Disable stdin streaming mode (requires --prompt or shows help)',
737
+ default: false,
738
+ })
739
+ .option('stdin-stream-timeout', {
740
+ type: 'number',
741
+ description:
742
+ 'Optional timeout in milliseconds for stdin reading (default: no timeout)',
743
+ })
744
+ .option('auto-merge-queued-messages', {
745
+ type: 'boolean',
746
+ description:
747
+ 'Enable auto-merging of rapidly arriving input lines into single messages (default: true)',
748
+ default: true,
749
+ })
750
+ .option('interactive', {
751
+ type: 'boolean',
752
+ description:
753
+ 'Enable interactive mode to accept manual input as plain text strings (default: true). Use --no-interactive to only accept JSON input.',
754
+ default: true,
755
+ })
756
+ .option('always-accept-stdin', {
757
+ type: 'boolean',
758
+ description:
759
+ 'Keep accepting stdin input even after the agent finishes work (default: true). Use --no-always-accept-stdin for single-message mode.',
760
+ default: true,
761
+ })
762
+ .option('compact-json', {
763
+ type: 'boolean',
764
+ description:
765
+ 'Output compact JSON (single line) instead of pretty-printed JSON (default: false). Useful for program-to-program communication.',
766
+ default: false,
767
+ })
596
768
  // Initialize logging early for all CLI commands
597
769
  // This prevents debug output from appearing in CLI unless --verbose is used
598
770
  .middleware(async (argv) => {
@@ -641,15 +813,125 @@ async function main() {
641
813
  process.exit(1);
642
814
  }
643
815
  })
644
- .help().argv;
816
+ .help();
817
+
818
+ const argv = await yargsInstance.argv;
645
819
 
646
820
  // If a command was executed (like mcp), yargs handles it
647
821
  // Otherwise, check if we should run in agent mode (stdin piped)
648
822
  const commandExecuted = argv._ && argv._.length > 0;
649
823
 
650
824
  if (!commandExecuted) {
651
- // No command specified, run in default agent mode (stdin processing)
652
- await runAgentMode(argv);
825
+ const compactJson = argv['compact-json'] === true;
826
+
827
+ // Check if --prompt flag was provided
828
+ if (argv.prompt) {
829
+ // Direct prompt mode - bypass stdin entirely
830
+ const request = { message: argv.prompt };
831
+ await runAgentMode(argv, request);
832
+ return;
833
+ }
834
+
835
+ // Check if --disable-stdin was set without --prompt
836
+ if (argv['disable-stdin']) {
837
+ // Output a helpful message suggesting to use --prompt
838
+ outputStatus(
839
+ {
840
+ type: 'error',
841
+ message:
842
+ 'No prompt provided. Use -p/--prompt to specify a message, or remove --disable-stdin to read from stdin.',
843
+ hint: 'Example: agent -p "Hello, how are you?"',
844
+ },
845
+ compactJson
846
+ );
847
+ process.exit(1);
848
+ }
849
+
850
+ // Check if stdin is a TTY (interactive terminal)
851
+ // If it is, show help instead of waiting for input
852
+ if (process.stdin.isTTY) {
853
+ yargsInstance.showHelp();
854
+ process.exit(0);
855
+ }
856
+
857
+ // stdin is piped - enter stdin listening mode
858
+ // Output status message to inform user what's happening
859
+ const isInteractive = argv.interactive !== false;
860
+ const autoMerge = argv['auto-merge-queued-messages'] !== false;
861
+ const alwaysAcceptStdin = argv['always-accept-stdin'] !== false;
862
+
863
+ outputStatus(
864
+ {
865
+ type: 'status',
866
+ mode: 'stdin-stream',
867
+ message: alwaysAcceptStdin
868
+ ? 'Agent CLI in continuous listening mode. Accepts JSON and plain text input.'
869
+ : 'Agent CLI in single-message mode. Accepts JSON and plain text input.',
870
+ hint: 'Press CTRL+C to exit. Use --help for options.',
871
+ acceptedFormats: isInteractive
872
+ ? ['JSON object with "message" field', 'Plain text']
873
+ : ['JSON object with "message" field'],
874
+ options: {
875
+ interactive: isInteractive,
876
+ autoMergeQueuedMessages: autoMerge,
877
+ alwaysAcceptStdin,
878
+ compactJson,
879
+ },
880
+ },
881
+ compactJson
882
+ );
883
+
884
+ // Use continuous mode if --always-accept-stdin is enabled (default)
885
+ if (alwaysAcceptStdin) {
886
+ await runContinuousAgentMode(argv);
887
+ return;
888
+ }
889
+
890
+ // Single-message mode (--no-always-accept-stdin)
891
+ // Read stdin with optional timeout
892
+ const timeout = argv['stdin-stream-timeout'] ?? null;
893
+ const input = await readStdinWithTimeout(timeout);
894
+ const trimmedInput = input.trim();
895
+
896
+ if (trimmedInput === '') {
897
+ outputStatus(
898
+ {
899
+ type: 'status',
900
+ message: 'No input received. Exiting.',
901
+ },
902
+ compactJson
903
+ );
904
+ yargsInstance.showHelp();
905
+ process.exit(0);
906
+ }
907
+
908
+ // Try to parse as JSON, if it fails treat it as plain text message
909
+ let request;
910
+ try {
911
+ request = JSON.parse(trimmedInput);
912
+ } catch (_e) {
913
+ // Not JSON
914
+ if (!isInteractive) {
915
+ // In non-interactive mode, only accept JSON
916
+ outputStatus(
917
+ {
918
+ type: 'error',
919
+ message:
920
+ 'Invalid JSON input. In non-interactive mode (--no-interactive), only JSON input is accepted.',
921
+ hint: 'Use --interactive to accept plain text, or provide valid JSON: {"message": "your text"}',
922
+ },
923
+ compactJson
924
+ );
925
+ process.exit(1);
926
+ }
927
+ // In interactive mode, treat as plain text message
928
+ request = {
929
+ message: trimmedInput,
930
+ };
931
+ }
932
+
933
+ // Run agent mode
934
+ await runAgentMode(argv, request);
653
935
  }
654
936
  } catch (error) {
655
937
  hasError = true;