From 4ad2b4a2f3ef70cccbc944d91d9e955dabe294f4 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 3 Jul 2026 15:37:57 +0000 Subject: [PATCH 1/2] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-assistant/agent/issues/273 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..56b5950 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-07-03T15:37:57.309Z for PR creation at branch issue-273-d5676a1f4939 for issue https://github.com/link-assistant/agent/issues/273 \ No newline at end of file From f5c3a3c3e69c44eceed0ddf44dbb83e2a9c85031 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 3 Jul 2026 15:52:05 +0000 Subject: [PATCH 2/2] feat: add live stream-json idle events --- .gitkeep | 1 - docs/stdin-mode.md | 88 ++++++++++++ js/.changeset/live-stream-json-idle.md | 5 + js/README.md | 19 +++ js/src/cli/continuous-mode.js | 129 +++-------------- js/src/cli/event-handler.js | 185 ++++++++++++++----------- js/src/json-standard/index.ts | 17 ++- js/tests/event-handler.js | 41 ++++++ js/tests/json-standard-unit.js | 15 ++ 9 files changed, 303 insertions(+), 197 deletions(-) delete mode 100644 .gitkeep create mode 100644 js/.changeset/live-stream-json-idle.md create mode 100644 js/tests/event-handler.js diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 56b5950..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-07-03T15:37:57.309Z for PR creation at branch issue-273-d5676a1f4939 for issue https://github.com/link-assistant/agent/issues/273 \ No newline at end of file diff --git a/docs/stdin-mode.md b/docs/stdin-mode.md index 741bc42..57f6321 100644 --- a/docs/stdin-mode.md +++ b/docs/stdin-mode.md @@ -352,6 +352,94 @@ printf 'First line\nSecond line\nThird line' | agent Becomes a single message: "First line\nSecond line\nThird line" +## Live `stream-json` Contract + +For bidirectional drivers such as hive-mind, use continuous stdin with +Claude-compatible JSONL input and output: + +```bash +agent --input-format stream-json --output-format stream-json +``` + +Each stdin line must be one complete JSON object. Supported frame types: + +| Frame | Direction | Behavior | +| ---------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `{"type":"user_prompt","content":"..."}` | driver -> agent | Queues a user turn. `{"type":"user", ...}` frames with text content are accepted too. | +| `{"type":"system","content":"..."}` | driver -> agent | Replaces the system message used for subsequent turns. | +| `{"type":"interrupt"}` | driver -> agent | Requests cancellation of the active prompt and moves the session back to idle. It does not start a new user turn by itself. | +| `{"type":"permission_response", ...}` | driver -> agent | Resolves a pending `permission_request` without waiting for the turn to finish. See [permissions](permissions.md). | + +### Session Resume + +`--resume ` is the stable resume entry point. By default it forks +the requested session to preserve the original history and continues in the +forked session. Add `--no-fork` to continue in the exact same session: + +```bash +agent --resume ses_abc123 --no-fork --input-format stream-json --output-format stream-json +``` + +Output events identify the active session. OpenCode output uses `sessionID`; +Claude `stream-json` output uses `session_id`. When `--resume` forks, the first +`init`, `message`, `result`, or `idle` event contains the forked session id. +When `--resume --no-fork` is used, those events contain the requested session id. + +### User-Frame Replay + +When a `user_prompt` or `user` frame is actually consumed, the agent replays it +on stdout as an acknowledgement. In Claude `stream-json` output this is a user +message: + +```json +{ + "type": "message", + "timestamp": "2026-07-03T00:00:00.000Z", + "session_id": "ses_abc123", + "role": "user", + "content": [{ "type": "text", "text": "new issue comment" }] +} +``` + +If the agent is busy, user frames are queued and replayed only when they become +the active turn. This lets a driver distinguish "accepted on stdin" from +"delivered to the conversation". + +### Interrupt And Queuing Semantics + +`{"type":"interrupt"}` requests an immediate abort of the active prompt. The +agent then emits the idle signal below when the session is safe for another +turn. User frames that arrive while the active prompt is stopping remain queued +and are processed after that idle boundary. + +### Idle / Turn Boundary Signal + +At the end of each turn, and after an interrupt cancellation settles, the agent +emits an explicit idle event. + +OpenCode output: + +```json +{ + "type": "session_idle", + "timestamp": 1718630400000, + "sessionID": "ses_abc123" +} +``` + +Claude `stream-json` output: + +```json +{ + "type": "idle", + "timestamp": "2026-07-03T00:00:00.000Z", + "session_id": "ses_abc123" +} +``` + +Drivers can flush queued `user_prompt` frames after this event instead of +interrupting an in-progress turn. + ## Status Messages When entering listening mode, the CLI outputs a JSON status message (pretty-printed by default). diff --git a/js/.changeset/live-stream-json-idle.md b/js/.changeset/live-stream-json-idle.md new file mode 100644 index 0000000..f4db3ca --- /dev/null +++ b/js/.changeset/live-stream-json-idle.md @@ -0,0 +1,5 @@ +--- +'@link-assistant/agent': patch +--- + +Add explicit live stream-json idle events and document resume, replay, and interrupt semantics. diff --git a/js/README.md b/js/README.md index e7dbaa4..ea0ba7a 100644 --- a/js/README.md +++ b/js/README.md @@ -286,6 +286,10 @@ Options: Default: opencode/minimax-m2.5-free --json-standard JSON output format standard Choices: "opencode" (default), "claude" (experimental) + --output-format Alias for JSON output format + Choices: "json" (OpenCode), "stream-json" (Claude NDJSON) + --input-format Input format + Choices: "text" (default), "stream-json" (Claude JSONL) --use-existing-claude-oauth Use existing Claude OAuth credentials from ~/.claude/.credentials.json --system-message Full override of the system message @@ -390,6 +394,21 @@ echo "your message here" | agent } ``` +**Live `stream-json`:** + +For bidirectional drivers, use: + +```bash +agent --input-format stream-json --output-format stream-json +``` + +The live contract supports `user_prompt`, `system`, `interrupt`, and +`permission_response` frames on stdin. It emits replay acknowledgements for +consumed user frames and an explicit idle/turn-boundary event +(`session_idle` in OpenCode output, `idle` in Claude output). See +[docs/stdin-mode.md](../docs/stdin-mode.md#live-stream-json-contract) for the +session resume and steering semantics. + ## Supported Tools All 13 tools are **enabled by default** with **no configuration required**. See [TOOLS.md](../TOOLS.md) for complete documentation. diff --git a/js/src/cli/continuous-mode.js b/js/src/cli/continuous-mode.js index 72c4452..acc9475 100644 --- a/js/src/cli/continuous-mode.js +++ b/js/src/cli/continuous-mode.js @@ -10,6 +10,7 @@ import { Session } from '../session/index.ts'; import { SessionPrompt } from '../session/prompt.ts'; import { createEventHandler, serializeOutput } from '../json-standard/index.ts'; import { createContinuousStdinReader } from './input-queue.js'; +import { outputBusEvent } from './event-handler.js'; import { Permission } from '../permission/index.ts'; import { Log } from '../util/log.ts'; import { config } from '../config/config.ts'; @@ -400,62 +401,14 @@ export async function runContinuousServerMode( // Subscribe to all bus events and output in selected format unsub = Bus.subscribeAll((event) => { - if (event.type === 'message.part.updated') { - const part = event.properties.part; - if (part.sessionID !== sessionID) { - return; - } - - if (part.type === 'step-start') { - eventHandler.output({ - type: 'step_start', - timestamp: Date.now(), - sessionID, - part, - }); - } - - if (part.type === 'step-finish') { - eventHandler.output({ - type: 'step_finish', - timestamp: Date.now(), - sessionID, - part, - }); - } - - if (part.type === 'text' && part.time?.end) { - eventHandler.output({ - type: 'text', - timestamp: Date.now(), - sessionID, - part, - }); - } - - if (part.type === 'tool') { - eventHandler.output({ - type: 'tool_use', - timestamp: Date.now(), - sessionID, - part, - }); - } - } - - if (event.type === 'session.error') { - const props = event.properties; - if (props.sessionID !== sessionID || !props.error) { - return; - } - hasError = true; - eventHandler.output({ - type: 'error', - timestamp: Date.now(), - sessionID, - error: props.error, - }); - } + outputBusEvent({ + event, + sessionID, + eventHandler, + onError: () => { + hasError = true; + }, + }); }); // Create continuous stdin reader @@ -661,62 +614,14 @@ export async function runContinuousDirectMode( // Subscribe to all bus events and output in selected format unsub = Bus.subscribeAll((event) => { - if (event.type === 'message.part.updated') { - const part = event.properties.part; - if (part.sessionID !== sessionID) { - return; - } - - if (part.type === 'step-start') { - eventHandler.output({ - type: 'step_start', - timestamp: Date.now(), - sessionID, - part, - }); - } - - if (part.type === 'step-finish') { - eventHandler.output({ - type: 'step_finish', - timestamp: Date.now(), - sessionID, - part, - }); - } - - if (part.type === 'text' && part.time?.end) { - eventHandler.output({ - type: 'text', - timestamp: Date.now(), - sessionID, - part, - }); - } - - if (part.type === 'tool') { - eventHandler.output({ - type: 'tool_use', - timestamp: Date.now(), - sessionID, - part, - }); - } - } - - if (event.type === 'session.error') { - const props = event.properties; - if (props.sessionID !== sessionID || !props.error) { - return; - } - hasError = true; - eventHandler.output({ - type: 'error', - timestamp: Date.now(), - sessionID, - error: props.error, - }); - } + outputBusEvent({ + event, + sessionID, + eventHandler, + onError: () => { + hasError = true; + }, + }); }); // Create continuous stdin reader diff --git a/js/src/cli/event-handler.js b/js/src/cli/event-handler.js index 04e56ef..54549e5 100644 --- a/js/src/cli/event-handler.js +++ b/js/src/cli/event-handler.js @@ -26,105 +26,126 @@ export function createBusEventSubscription({ }); const unsub = Bus.subscribeAll((event) => { - // Output events in selected JSON format - if (event.type === 'message.part.updated') { - const part = event.properties.part; - if (part.sessionID !== sessionID) { - return; - } - - // Output different event types - if (part.type === 'step-start') { - eventHandler.output({ - type: 'step_start', - timestamp: Date.now(), - sessionID, - part, - }); - } - - if (part.type === 'step-finish') { - eventHandler.output({ - type: 'step_finish', - timestamp: Date.now(), - sessionID, - part, - }); - } + outputBusEvent({ + event, + sessionID, + eventHandler, + onError, + onIdle: idleResolve, + }); + }); - if (part.type === 'text' && part.time?.end) { - eventHandler.output({ - type: 'text', - timestamp: Date.now(), - sessionID, - part, - }); - } + return { unsub, idlePromise }; +} - if (part.type === 'tool') { - eventHandler.output({ - type: 'tool_use', - timestamp: Date.now(), - sessionID, - part, - }); +export function outputBusEvent({ + event, + sessionID, + eventHandler, + onError, + onIdle, +}) { + // Output events in selected JSON format + if (event.type === 'message.part.updated') { + const part = event.properties.part; + if (part.sessionID !== sessionID) { + return; + } - // If tool failed, also output an error event - if (part.state?.status === 'error') { - eventHandler.output({ - type: 'error', - timestamp: Date.now(), - sessionID, - error: part.state.error || 'Tool execution failed', - }); - } - } + // Output different event types + if (part.type === 'step-start') { + eventHandler.output({ + type: 'step_start', + timestamp: Date.now(), + sessionID, + part, + }); } - // Emit a JSON permission request when a tool needs approval (issue #271). - // The consumer replies with a `permission_response` frame over stdin which - // is routed to Permission.respond. No TUI is involved. - if (event.type === 'permission.updated') { - const permission = event.properties; - if (permission.sessionID !== sessionID) { - return; - } + if (part.type === 'step-finish') { eventHandler.output({ - type: 'permission_request', + type: 'step_finish', timestamp: Date.now(), sessionID, - permissionID: permission.id, - callID: permission.callID, - tool: permission.type, - pattern: permission.pattern, - title: permission.title, - metadata: permission.metadata, + part, }); } - // Handle session idle to know when to stop - if ( - event.type === 'session.idle' && - event.properties.sessionID === sessionID - ) { - idleResolve(); + if (part.type === 'text' && part.time?.end) { + eventHandler.output({ + type: 'text', + timestamp: Date.now(), + sessionID, + part, + }); } - // Handle errors - if (event.type === 'session.error') { - const props = event.properties; - if (props.sessionID !== sessionID || !props.error) { - return; - } - onError(); + if (part.type === 'tool') { eventHandler.output({ - type: 'error', + type: 'tool_use', timestamp: Date.now(), sessionID, - error: props.error, + part, }); + + // If tool failed, also output an error event + if (part.state?.status === 'error') { + eventHandler.output({ + type: 'error', + timestamp: Date.now(), + sessionID, + error: part.state.error || 'Tool execution failed', + }); + } } - }); + } - return { unsub, idlePromise }; + // Emit a JSON permission request when a tool needs approval (issue #271). + // The consumer replies with a `permission_response` frame over stdin which + // is routed to Permission.respond. No TUI is involved. + if (event.type === 'permission.updated') { + const permission = event.properties; + if (permission.sessionID !== sessionID) { + return; + } + eventHandler.output({ + type: 'permission_request', + timestamp: Date.now(), + sessionID, + permissionID: permission.id, + callID: permission.callID, + tool: permission.type, + pattern: permission.pattern, + title: permission.title, + metadata: permission.metadata, + }); + } + + // Handle session idle to know when to stop + if ( + event.type === 'session.idle' && + event.properties.sessionID === sessionID + ) { + eventHandler.output({ + type: 'session_idle', + timestamp: Date.now(), + sessionID, + }); + onIdle?.(); + } + + // Handle errors + if (event.type === 'session.error') { + const props = event.properties; + if (props.sessionID !== sessionID || !props.error) { + return; + } + onError(); + eventHandler.output({ + type: 'error', + timestamp: Date.now(), + sessionID, + error: props.error, + }); + } } diff --git a/js/src/json-standard/index.ts b/js/src/json-standard/index.ts index 8763123..a1e6190 100644 --- a/js/src/json-standard/index.ts +++ b/js/src/json-standard/index.ts @@ -18,7 +18,13 @@ export type JsonStandard = 'opencode' | 'claude'; * OpenCode JSON event types */ export interface OpenCodeEvent { - type: 'step_start' | 'step_finish' | 'text' | 'tool_use' | 'error'; + type: + | 'step_start' + | 'step_finish' + | 'text' + | 'tool_use' + | 'error' + | 'session_idle'; timestamp: number; sessionID: string; part?: Record; @@ -29,7 +35,7 @@ export interface OpenCodeEvent { * Claude JSON event types (stream-json format) */ export interface ClaudeEvent { - type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result'; + type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'idle'; timestamp?: string; session_id?: string; role?: 'assistant' | 'user'; @@ -126,6 +132,13 @@ export function convertOpenCodeToClaude( duration_ms: event.timestamp - startTime, }; + case 'session_idle': + return { + type: 'idle', + timestamp, + session_id, + }; + case 'error': return { type: 'result', diff --git a/js/tests/event-handler.js b/js/tests/event-handler.js new file mode 100644 index 0000000..7ed8460 --- /dev/null +++ b/js/tests/event-handler.js @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; +import { createBusEventSubscription } from '../src/cli/event-handler.js'; +import { Bus } from '../src/bus/index.ts'; +import { Instance } from '../src/project/instance.ts'; +import { SessionStatus } from '../src/session/status.ts'; + +describe('createBusEventSubscription', () => { + test('outputs a public idle event when the session reaches a turn boundary', async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + const outputs = []; + const { unsub, idlePromise } = createBusEventSubscription({ + sessionID: 'ses_idle', + eventHandler: { + output(event) { + outputs.push(event); + }, + }, + onError() {}, + }); + + try { + await Bus.publish(SessionStatus.Event.Idle, { + sessionID: 'ses_idle', + }); + await idlePromise; + } finally { + unsub(); + } + + expect(outputs).toContainEqual( + expect.objectContaining({ + type: 'session_idle', + sessionID: 'ses_idle', + }) + ); + }, + }); + }); +}); diff --git a/js/tests/json-standard-unit.js b/js/tests/json-standard-unit.js index 49aa6d7..ac4b517 100644 --- a/js/tests/json-standard-unit.js +++ b/js/tests/json-standard-unit.js @@ -156,6 +156,21 @@ describe('JSON Standard Module - Unit Tests', () => { expect(claudeEvent.duration_ms).toBe(1000); }); + test('converts session_idle to idle', () => { + const opencodeEvent = { + type: 'session_idle', + timestamp: startTime + 1100, + sessionID: 'ses_test123', + }; + + const claudeEvent = convertOpenCodeToClaude(opencodeEvent, startTime); + + expect(claudeEvent).not.toBeNull(); + expect(claudeEvent.type).toBe('idle'); + expect(claudeEvent.session_id).toBe('ses_test123'); + expect(claudeEvent.timestamp).toBeDefined(); + }); + test('converts error to result with error status', () => { const opencodeEvent = { type: 'error',