Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/stdin-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sessionID>` 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).
Expand Down
5 changes: 5 additions & 0 deletions js/.changeset/live-stream-json-idle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@link-assistant/agent': patch
---

Add explicit live stream-json idle events and document resume, replay, and interrupt semantics.
19 changes: 19 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
129 changes: 17 additions & 112 deletions js/src/cli/continuous-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading