@link-assistant/agent 0.0.8 → 0.0.9
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/EXAMPLES.md +44 -1
- package/README.md +36 -0
- package/package.json +2 -1
- package/src/cli/cmd/mcp.ts +144 -12
- package/src/cli/ui.ts +63 -0
- package/src/index.js +366 -291
- package/src/json-standard/index.ts +157 -0
package/EXAMPLES.md
CHANGED
|
@@ -279,7 +279,28 @@ echo '{"message":"fetch url","tools":[{"name":"webfetch","params":{"url":"https:
|
|
|
279
279
|
|
|
280
280
|
## Output Format
|
|
281
281
|
|
|
282
|
-
### JSON
|
|
282
|
+
### JSON Standards
|
|
283
|
+
|
|
284
|
+
@link-assistant/agent supports two JSON output format standards via the `--json-standard` option:
|
|
285
|
+
|
|
286
|
+
#### OpenCode Standard (default)
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
# Default - same as --json-standard opencode
|
|
290
|
+
echo "hi" | agent
|
|
291
|
+
|
|
292
|
+
# Explicit opencode standard
|
|
293
|
+
echo "hi" | agent --json-standard opencode
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
#### Claude Standard (experimental)
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
# Claude CLI compatible format (NDJSON)
|
|
300
|
+
echo "hi" | agent --json-standard claude
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### JSON Event Streaming (Pretty-Printed) - OpenCode Standard
|
|
283
304
|
|
|
284
305
|
@link-assistant/agent outputs JSON events in pretty-printed streaming format for easy readability, 100% compatible with OpenCode's event structure:
|
|
285
306
|
|
|
@@ -327,6 +348,28 @@ This format is designed for:
|
|
|
327
348
|
- **Compatibility**: 100% compatible with OpenCode's event structure
|
|
328
349
|
- **Automation**: Can be parsed using standard JSON tools (see filtering examples below)
|
|
329
350
|
|
|
351
|
+
### Claude Stream-JSON Output (NDJSON)
|
|
352
|
+
|
|
353
|
+
When using `--json-standard claude`, output is in NDJSON (Newline-Delimited JSON) format, compatible with Claude CLI:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
echo "hi" | agent --json-standard claude
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Output (compact NDJSON):
|
|
360
|
+
```json
|
|
361
|
+
{"type":"init","timestamp":"2025-01-01T00:00:00.000Z","session_id":"ses_560236487ffe3ROK1ThWvPwTEF"}
|
|
362
|
+
{"type":"message","timestamp":"2025-01-01T00:00:01.000Z","session_id":"ses_560236487ffe3ROK1ThWvPwTEF","role":"assistant","content":[{"type":"text","text":"Hi! How can I help with your coding tasks today?"}]}
|
|
363
|
+
{"type":"result","timestamp":"2025-01-01T00:00:01.100Z","session_id":"ses_560236487ffe3ROK1ThWvPwTEF","status":"success","duration_ms":1100}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Key differences from OpenCode format:
|
|
367
|
+
- **Compact**: One JSON per line (no pretty-printing)
|
|
368
|
+
- **Event Types**: `init`, `message`, `tool_use`, `tool_result`, `result`
|
|
369
|
+
- **Timestamps**: ISO 8601 strings instead of Unix milliseconds
|
|
370
|
+
- **Session ID**: `session_id` (snake_case) instead of `sessionID` (camelCase)
|
|
371
|
+
- **Content**: Message content in array format with `{type, text}` objects
|
|
372
|
+
|
|
330
373
|
### Filtering Output
|
|
331
374
|
|
|
332
375
|
Extract specific event types using `jq`:
|
package/README.md
CHANGED
|
@@ -156,6 +156,8 @@ agent [options]
|
|
|
156
156
|
Options:
|
|
157
157
|
--model Model to use in format providerID/modelID
|
|
158
158
|
Default: opencode/grok-code
|
|
159
|
+
--json-standard JSON output format standard
|
|
160
|
+
Choices: "opencode" (default), "claude" (experimental)
|
|
159
161
|
--system-message Full override of the system message
|
|
160
162
|
--system-message-file Full override of the system message from file
|
|
161
163
|
--append-system-message Append to the default system message
|
|
@@ -164,6 +166,36 @@ Options:
|
|
|
164
166
|
--version Show version number
|
|
165
167
|
```
|
|
166
168
|
|
|
169
|
+
### JSON Output Standards
|
|
170
|
+
|
|
171
|
+
The agent supports two JSON output format standards via the `--json-standard` option:
|
|
172
|
+
|
|
173
|
+
#### OpenCode Standard (default)
|
|
174
|
+
|
|
175
|
+
The OpenCode format is the default JSON output format, compatible with `opencode run --format json`:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
echo "hi" | agent --json-standard opencode
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- **Format**: Pretty-printed JSON (human-readable with indentation)
|
|
182
|
+
- **Event Types**: `step_start`, `step_finish`, `text`, `tool_use`, `error`
|
|
183
|
+
- **Timestamps**: Unix milliseconds (number)
|
|
184
|
+
- **Session ID**: `sessionID` (camelCase)
|
|
185
|
+
|
|
186
|
+
#### Claude Standard (experimental)
|
|
187
|
+
|
|
188
|
+
The Claude format provides compatibility with Anthropic's Claude CLI `--output-format stream-json`:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
echo "hi" | agent --json-standard claude
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- **Format**: NDJSON (Newline-Delimited JSON - compact, one JSON per line)
|
|
195
|
+
- **Event Types**: `init`, `message`, `tool_use`, `tool_result`, `result`
|
|
196
|
+
- **Timestamps**: ISO 8601 strings
|
|
197
|
+
- **Session ID**: `session_id` (snake_case)
|
|
198
|
+
|
|
167
199
|
### Input Formats
|
|
168
200
|
|
|
169
201
|
**Plain Text (auto-converted):**
|
|
@@ -222,11 +254,14 @@ See [EXAMPLES.md](EXAMPLES.md) for detailed usage examples of each tool with bot
|
|
|
222
254
|
bun test
|
|
223
255
|
|
|
224
256
|
# Run specific test file
|
|
257
|
+
bun test tests/mcp.test.js
|
|
225
258
|
bun test tests/websearch.tools.test.js
|
|
226
259
|
bun test tests/batch.tools.test.js
|
|
227
260
|
bun test tests/plaintext.input.test.js
|
|
228
261
|
```
|
|
229
262
|
|
|
263
|
+
For detailed testing information including how to run tests manually and trigger CI tests, see [TESTING.md](TESTING.md).
|
|
264
|
+
|
|
230
265
|
## Maintenance
|
|
231
266
|
|
|
232
267
|
### Development
|
|
@@ -255,6 +290,7 @@ Bun automatically discovers and runs all `*.test.js` files in the project.
|
|
|
255
290
|
- ✅ 13 tool implementation tests
|
|
256
291
|
- ✅ Plain text input support test
|
|
257
292
|
- ✅ OpenCode compatibility tests for websearch/codesearch
|
|
293
|
+
- ✅ JSON standard unit tests (opencode and claude formats)
|
|
258
294
|
- ✅ All tests pass with 100% OpenCode JSON format compatibility
|
|
259
295
|
|
|
260
296
|
### Publishing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/agent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"@agentclientprotocol/sdk": "^0.5.1",
|
|
44
44
|
"@ai-sdk/mcp": "^0.0.8",
|
|
45
45
|
"@ai-sdk/xai": "^2.0.33",
|
|
46
|
+
"@clack/prompts": "^0.11.0",
|
|
46
47
|
"@hono/standard-validator": "^0.2.0",
|
|
47
48
|
"@hono/zod-validator": "^0.7.5",
|
|
48
49
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
package/src/cli/cmd/mcp.ts
CHANGED
|
@@ -1,19 +1,94 @@
|
|
|
1
|
+
import type { Argv } from "yargs"
|
|
1
2
|
import { cmd } from "./cmd"
|
|
2
3
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
3
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
4
5
|
import * as prompts from "@clack/prompts"
|
|
5
6
|
import { UI } from "../ui"
|
|
7
|
+
import { Global } from "../../global"
|
|
8
|
+
import { Config } from "../../config/config"
|
|
9
|
+
import path from "path"
|
|
10
|
+
import fs from "fs/promises"
|
|
6
11
|
|
|
7
12
|
export const McpCommand = cmd({
|
|
8
13
|
command: "mcp",
|
|
9
|
-
builder: (yargs) => yargs.command(McpAddCommand).demandCommand(),
|
|
14
|
+
builder: (yargs) => yargs.command(McpAddCommand).command(McpListCommand).demandCommand(),
|
|
10
15
|
async handler() {},
|
|
11
16
|
})
|
|
12
17
|
|
|
18
|
+
async function loadGlobalConfig(): Promise<Config.Info> {
|
|
19
|
+
const configPath = path.join(Global.Path.config, "opencode.json")
|
|
20
|
+
try {
|
|
21
|
+
const content = await Bun.file(configPath).text()
|
|
22
|
+
return JSON.parse(content)
|
|
23
|
+
} catch {
|
|
24
|
+
return {
|
|
25
|
+
$schema: "https://opencode.ai/config.json",
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function saveGlobalConfig(config: Config.Info): Promise<void> {
|
|
31
|
+
const configPath = path.join(Global.Path.config, "opencode.json")
|
|
32
|
+
await fs.mkdir(Global.Path.config, { recursive: true })
|
|
33
|
+
await Bun.write(configPath, JSON.stringify(config, null, 2))
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
export const McpAddCommand = cmd({
|
|
14
|
-
command: "add",
|
|
37
|
+
command: "add [name] [command..]",
|
|
15
38
|
describe: "add an MCP server",
|
|
16
|
-
|
|
39
|
+
builder: (yargs: Argv) => {
|
|
40
|
+
return yargs
|
|
41
|
+
.positional("name", {
|
|
42
|
+
describe: "name of the MCP server",
|
|
43
|
+
type: "string",
|
|
44
|
+
})
|
|
45
|
+
.positional("command", {
|
|
46
|
+
describe: "command and arguments to run the MCP server (e.g., npx @playwright/mcp@latest)",
|
|
47
|
+
type: "string",
|
|
48
|
+
array: true,
|
|
49
|
+
})
|
|
50
|
+
.option("url", {
|
|
51
|
+
describe: "URL for remote MCP server",
|
|
52
|
+
type: "string",
|
|
53
|
+
})
|
|
54
|
+
.option("enabled", {
|
|
55
|
+
describe: "enable the MCP server",
|
|
56
|
+
type: "boolean",
|
|
57
|
+
default: true,
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
async handler(args) {
|
|
61
|
+
// If name and command are provided as CLI arguments, use non-interactive mode
|
|
62
|
+
if (args.name && ((args.command && args.command.length > 0) || args.url)) {
|
|
63
|
+
// Non-interactive mode: CLI arguments provided
|
|
64
|
+
const config = await loadGlobalConfig()
|
|
65
|
+
config.mcp = config.mcp || {}
|
|
66
|
+
|
|
67
|
+
if (args.url) {
|
|
68
|
+
// Remote MCP server
|
|
69
|
+
config.mcp[args.name] = {
|
|
70
|
+
type: "remote",
|
|
71
|
+
url: args.url,
|
|
72
|
+
enabled: args.enabled,
|
|
73
|
+
}
|
|
74
|
+
UI.success(`Remote MCP server "${args.name}" added with URL: ${args.url}`)
|
|
75
|
+
} else if (args.command && args.command.length > 0) {
|
|
76
|
+
// Local MCP server
|
|
77
|
+
config.mcp[args.name] = {
|
|
78
|
+
type: "local",
|
|
79
|
+
command: args.command,
|
|
80
|
+
enabled: args.enabled,
|
|
81
|
+
}
|
|
82
|
+
UI.success(`Local MCP server "${args.name}" added with command: ${args.command.join(" ")}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await saveGlobalConfig(config)
|
|
86
|
+
const configPath = path.join(Global.Path.config, "opencode.json")
|
|
87
|
+
UI.info(`Configuration saved to: ${configPath}`)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Interactive mode: prompt for input
|
|
17
92
|
UI.empty()
|
|
18
93
|
prompts.intro("Add MCP server")
|
|
19
94
|
|
|
@@ -40,17 +115,27 @@ export const McpAddCommand = cmd({
|
|
|
40
115
|
})
|
|
41
116
|
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
|
42
117
|
|
|
118
|
+
const config = await loadGlobalConfig()
|
|
119
|
+
config.mcp = config.mcp || {}
|
|
120
|
+
|
|
43
121
|
if (type === "local") {
|
|
44
122
|
const command = await prompts.text({
|
|
45
123
|
message: "Enter command to run",
|
|
46
|
-
placeholder: "e.g.,
|
|
124
|
+
placeholder: "e.g., npx @playwright/mcp@latest",
|
|
47
125
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
|
48
126
|
})
|
|
49
127
|
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
|
50
128
|
|
|
129
|
+
// Parse command into array
|
|
130
|
+
const commandParts = command.split(/\s+/)
|
|
131
|
+
config.mcp[name] = {
|
|
132
|
+
type: "local",
|
|
133
|
+
command: commandParts,
|
|
134
|
+
enabled: true,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await saveGlobalConfig(config)
|
|
51
138
|
prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
|
|
52
|
-
prompts.outro("MCP server added successfully")
|
|
53
|
-
return
|
|
54
139
|
}
|
|
55
140
|
|
|
56
141
|
if (type === "remote") {
|
|
@@ -66,15 +151,62 @@ export const McpAddCommand = cmd({
|
|
|
66
151
|
})
|
|
67
152
|
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
|
68
153
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
154
|
+
// Test connection
|
|
155
|
+
try {
|
|
156
|
+
const client = new Client({
|
|
157
|
+
name: "opencode",
|
|
158
|
+
version: "1.0.0",
|
|
159
|
+
})
|
|
160
|
+
const transport = new StreamableHTTPClientTransport(new URL(url))
|
|
161
|
+
await client.connect(transport)
|
|
162
|
+
await client.close()
|
|
163
|
+
} catch (error) {
|
|
164
|
+
prompts.log.warn(`Could not verify connection to ${url}, but saving configuration anyway`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
config.mcp[name] = {
|
|
168
|
+
type: "remote",
|
|
169
|
+
url: url,
|
|
170
|
+
enabled: true,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await saveGlobalConfig(config)
|
|
75
174
|
prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
|
|
76
175
|
}
|
|
77
176
|
|
|
177
|
+
const configPath = path.join(Global.Path.config, "opencode.json")
|
|
178
|
+
prompts.log.info(`Configuration saved to: ${configPath}`)
|
|
78
179
|
prompts.outro("MCP server added successfully")
|
|
79
180
|
},
|
|
80
181
|
})
|
|
182
|
+
|
|
183
|
+
export const McpListCommand = cmd({
|
|
184
|
+
command: "list",
|
|
185
|
+
describe: "list configured MCP servers",
|
|
186
|
+
async handler() {
|
|
187
|
+
const config = await loadGlobalConfig()
|
|
188
|
+
const mcpServers = config.mcp || {}
|
|
189
|
+
|
|
190
|
+
if (Object.keys(mcpServers).length === 0) {
|
|
191
|
+
UI.info("No MCP servers configured")
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
UI.println(UI.Style.TEXT_BOLD + "Configured MCP servers:" + UI.Style.TEXT_NORMAL)
|
|
196
|
+
UI.empty()
|
|
197
|
+
|
|
198
|
+
for (const [name, server] of Object.entries(mcpServers)) {
|
|
199
|
+
const enabledStatus = server.enabled !== false ? UI.Style.TEXT_SUCCESS_BOLD + "[enabled]" : UI.Style.TEXT_DIM + "[disabled]"
|
|
200
|
+
UI.println(UI.Style.TEXT_INFO_BOLD + ` ${name}` + UI.Style.TEXT_NORMAL + ` ${enabledStatus}` + UI.Style.TEXT_NORMAL)
|
|
201
|
+
|
|
202
|
+
if (server.type === "local") {
|
|
203
|
+
UI.println(UI.Style.TEXT_DIM + ` Type: local`)
|
|
204
|
+
UI.println(UI.Style.TEXT_DIM + ` Command: ${server.command.join(" ")}`)
|
|
205
|
+
} else if (server.type === "remote") {
|
|
206
|
+
UI.println(UI.Style.TEXT_DIM + ` Type: remote`)
|
|
207
|
+
UI.println(UI.Style.TEXT_DIM + ` URL: ${server.url}`)
|
|
208
|
+
}
|
|
209
|
+
UI.empty()
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
})
|
package/src/cli/ui.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { NamedError } from "../util/error"
|
|
2
|
+
import z from "zod"
|
|
3
|
+
|
|
4
|
+
export namespace UI {
|
|
5
|
+
// ANSI color codes for terminal output
|
|
6
|
+
export const Style = {
|
|
7
|
+
TEXT_NORMAL: "\x1b[0m",
|
|
8
|
+
TEXT_BOLD: "\x1b[1m",
|
|
9
|
+
TEXT_DIM: "\x1b[2m",
|
|
10
|
+
TEXT_DANGER_BOLD: "\x1b[1;31m",
|
|
11
|
+
TEXT_SUCCESS_BOLD: "\x1b[1;32m",
|
|
12
|
+
TEXT_WARNING_BOLD: "\x1b[1;33m",
|
|
13
|
+
TEXT_INFO_BOLD: "\x1b[1;34m",
|
|
14
|
+
TEXT_HIGHLIGHT_BOLD: "\x1b[1;35m",
|
|
15
|
+
TEXT_DIM_BOLD: "\x1b[1;90m",
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
// Error for cancelled operations (e.g., Ctrl+C in prompts)
|
|
19
|
+
export const CancelledError = NamedError.create(
|
|
20
|
+
"CancelledError",
|
|
21
|
+
z.object({})
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// Print an empty line
|
|
25
|
+
export function empty() {
|
|
26
|
+
process.stderr.write("\n")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Print a line with optional formatting
|
|
30
|
+
export function println(...args: string[]) {
|
|
31
|
+
process.stderr.write(args.join("") + Style.TEXT_NORMAL + "\n")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Print an error message
|
|
35
|
+
export function error(message: string) {
|
|
36
|
+
process.stderr.write(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message + "\n")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Print a success message
|
|
40
|
+
export function success(message: string) {
|
|
41
|
+
process.stderr.write(Style.TEXT_SUCCESS_BOLD + "Success: " + Style.TEXT_NORMAL + message + "\n")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Print an info message
|
|
45
|
+
export function info(message: string) {
|
|
46
|
+
process.stderr.write(Style.TEXT_INFO_BOLD + "Info: " + Style.TEXT_NORMAL + message + "\n")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Basic markdown rendering for terminal
|
|
50
|
+
export function markdown(text: string): string {
|
|
51
|
+
// Simple markdown to ANSI conversion
|
|
52
|
+
let result = text
|
|
53
|
+
|
|
54
|
+
// Bold text: **text** or __text__
|
|
55
|
+
result = result.replace(/\*\*(.+?)\*\*/g, Style.TEXT_BOLD + "$1" + Style.TEXT_NORMAL)
|
|
56
|
+
result = result.replace(/__(.+?)__/g, Style.TEXT_BOLD + "$1" + Style.TEXT_NORMAL)
|
|
57
|
+
|
|
58
|
+
// Code blocks: `code`
|
|
59
|
+
result = result.replace(/`([^`]+)`/g, Style.TEXT_DIM + "$1" + Style.TEXT_NORMAL)
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,34 @@ import { SessionPrompt } from './session/prompt.ts'
|
|
|
9
9
|
import { EOL } from 'os'
|
|
10
10
|
import yargs from 'yargs'
|
|
11
11
|
import { hideBin } from 'yargs/helpers'
|
|
12
|
+
import { createEventHandler, isValidJsonStandard } from './json-standard/index.ts'
|
|
13
|
+
import { McpCommand } from './cli/cmd/mcp.ts'
|
|
14
|
+
|
|
15
|
+
// Track if any errors occurred during execution
|
|
16
|
+
let hasError = false
|
|
17
|
+
|
|
18
|
+
// Install global error handlers to ensure non-zero exit codes
|
|
19
|
+
process.on('uncaughtException', (error) => {
|
|
20
|
+
hasError = true
|
|
21
|
+
console.error(JSON.stringify({
|
|
22
|
+
type: 'error',
|
|
23
|
+
errorType: error.name || 'UncaughtException',
|
|
24
|
+
message: error.message,
|
|
25
|
+
stack: error.stack
|
|
26
|
+
}, null, 2))
|
|
27
|
+
process.exit(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
31
|
+
hasError = true
|
|
32
|
+
console.error(JSON.stringify({
|
|
33
|
+
type: 'error',
|
|
34
|
+
errorType: 'UnhandledRejection',
|
|
35
|
+
message: reason?.message || String(reason),
|
|
36
|
+
stack: reason?.stack
|
|
37
|
+
}, null, 2))
|
|
38
|
+
process.exit(1)
|
|
39
|
+
})
|
|
12
40
|
|
|
13
41
|
async function readStdin() {
|
|
14
42
|
return new Promise((resolve, reject) => {
|
|
@@ -35,15 +63,341 @@ async function readStdin() {
|
|
|
35
63
|
})
|
|
36
64
|
}
|
|
37
65
|
|
|
66
|
+
async function runAgentMode(argv) {
|
|
67
|
+
// Parse model argument
|
|
68
|
+
const modelParts = argv.model.split('/')
|
|
69
|
+
const providerID = modelParts[0] || 'opencode'
|
|
70
|
+
const modelID = modelParts[1] || 'grok-code'
|
|
71
|
+
|
|
72
|
+
// Validate and get JSON standard
|
|
73
|
+
const jsonStandard = argv['json-standard']
|
|
74
|
+
if (!isValidJsonStandard(jsonStandard)) {
|
|
75
|
+
console.error(`Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`)
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read system message files
|
|
80
|
+
let systemMessage = argv['system-message']
|
|
81
|
+
let appendSystemMessage = argv['append-system-message']
|
|
82
|
+
|
|
83
|
+
if (argv['system-message-file']) {
|
|
84
|
+
const resolvedPath = require('path').resolve(process.cwd(), argv['system-message-file'])
|
|
85
|
+
const file = Bun.file(resolvedPath)
|
|
86
|
+
if (!(await file.exists())) {
|
|
87
|
+
console.error(`System message file not found: ${argv['system-message-file']}`)
|
|
88
|
+
process.exit(1)
|
|
89
|
+
}
|
|
90
|
+
systemMessage = await file.text()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (argv['append-system-message-file']) {
|
|
94
|
+
const resolvedPath = require('path').resolve(process.cwd(), argv['append-system-message-file'])
|
|
95
|
+
const file = Bun.file(resolvedPath)
|
|
96
|
+
if (!(await file.exists())) {
|
|
97
|
+
console.error(`Append system message file not found: ${argv['append-system-message-file']}`)
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
appendSystemMessage = await file.text()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Initialize logging to redirect to log file instead of stderr
|
|
104
|
+
// This prevents log messages from mixing with JSON output
|
|
105
|
+
await Log.init({
|
|
106
|
+
print: false, // Don't print to stderr
|
|
107
|
+
level: 'INFO'
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Read input from stdin
|
|
111
|
+
const input = await readStdin()
|
|
112
|
+
const trimmedInput = input.trim()
|
|
113
|
+
|
|
114
|
+
// Try to parse as JSON, if it fails treat it as plain text message
|
|
115
|
+
let request
|
|
116
|
+
try {
|
|
117
|
+
request = JSON.parse(trimmedInput)
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// Not JSON, treat as plain text message
|
|
120
|
+
request = {
|
|
121
|
+
message: trimmedInput
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Wrap in Instance.provide for OpenCode infrastructure
|
|
126
|
+
await Instance.provide({
|
|
127
|
+
directory: process.cwd(),
|
|
128
|
+
fn: async () => {
|
|
129
|
+
if (argv.server) {
|
|
130
|
+
// SERVER MODE: Start server and communicate via HTTP
|
|
131
|
+
await runServerMode(request, providerID, modelID, systemMessage, appendSystemMessage, jsonStandard)
|
|
132
|
+
} else {
|
|
133
|
+
// DIRECT MODE: Run everything in single process
|
|
134
|
+
await runDirectMode(request, providerID, modelID, systemMessage, appendSystemMessage, jsonStandard)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Explicitly exit to ensure process terminates
|
|
140
|
+
process.exit(hasError ? 1 : 0)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function runServerMode(request, providerID, modelID, systemMessage, appendSystemMessage, jsonStandard) {
|
|
144
|
+
// Start server like OpenCode does
|
|
145
|
+
const server = Server.listen({ port: 0, hostname: "127.0.0.1" })
|
|
146
|
+
let unsub = null
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
// Create a session
|
|
150
|
+
const createRes = await fetch(`http://${server.hostname}:${server.port}/session`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({})
|
|
154
|
+
})
|
|
155
|
+
const session = await createRes.json()
|
|
156
|
+
const sessionID = session.id
|
|
157
|
+
|
|
158
|
+
if (!sessionID) {
|
|
159
|
+
throw new Error("Failed to create session")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Create event handler for the selected JSON standard
|
|
163
|
+
const eventHandler = createEventHandler(jsonStandard, sessionID)
|
|
164
|
+
|
|
165
|
+
// Subscribe to all bus events and output in selected format
|
|
166
|
+
const eventPromise = new Promise((resolve) => {
|
|
167
|
+
unsub = Bus.subscribeAll((event) => {
|
|
168
|
+
// Output events in selected JSON format
|
|
169
|
+
if (event.type === 'message.part.updated') {
|
|
170
|
+
const part = event.properties.part
|
|
171
|
+
if (part.sessionID !== sessionID) return
|
|
172
|
+
|
|
173
|
+
// Output different event types
|
|
174
|
+
if (part.type === 'step-start') {
|
|
175
|
+
eventHandler.output({
|
|
176
|
+
type: 'step_start',
|
|
177
|
+
timestamp: Date.now(),
|
|
178
|
+
sessionID,
|
|
179
|
+
part
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (part.type === 'step-finish') {
|
|
184
|
+
eventHandler.output({
|
|
185
|
+
type: 'step_finish',
|
|
186
|
+
timestamp: Date.now(),
|
|
187
|
+
sessionID,
|
|
188
|
+
part
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (part.type === 'text' && part.time?.end) {
|
|
193
|
+
eventHandler.output({
|
|
194
|
+
type: 'text',
|
|
195
|
+
timestamp: Date.now(),
|
|
196
|
+
sessionID,
|
|
197
|
+
part
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
202
|
+
eventHandler.output({
|
|
203
|
+
type: 'tool_use',
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
sessionID,
|
|
206
|
+
part
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Handle session idle to know when to stop
|
|
212
|
+
if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
|
|
213
|
+
resolve()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Handle errors
|
|
217
|
+
if (event.type === 'session.error') {
|
|
218
|
+
const props = event.properties
|
|
219
|
+
if (props.sessionID !== sessionID || !props.error) return
|
|
220
|
+
hasError = true
|
|
221
|
+
eventHandler.output({
|
|
222
|
+
type: 'error',
|
|
223
|
+
timestamp: Date.now(),
|
|
224
|
+
sessionID,
|
|
225
|
+
error: props.error
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Send message to session with specified model (default: opencode/grok-code)
|
|
232
|
+
const message = request.message || "hi"
|
|
233
|
+
const parts = [{ type: "text", text: message }]
|
|
234
|
+
|
|
235
|
+
// Start the prompt (don't wait for response, events come via Bus)
|
|
236
|
+
fetch(`http://${server.hostname}:${server.port}/session/${sessionID}/message`, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
parts,
|
|
241
|
+
model: {
|
|
242
|
+
providerID,
|
|
243
|
+
modelID
|
|
244
|
+
},
|
|
245
|
+
system: systemMessage,
|
|
246
|
+
appendSystem: appendSystemMessage
|
|
247
|
+
})
|
|
248
|
+
}).catch((error) => {
|
|
249
|
+
hasError = true
|
|
250
|
+
eventHandler.output({
|
|
251
|
+
type: 'error',
|
|
252
|
+
timestamp: Date.now(),
|
|
253
|
+
sessionID,
|
|
254
|
+
error: error instanceof Error ? error.message : String(error)
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// Wait for session to become idle
|
|
259
|
+
await eventPromise
|
|
260
|
+
} finally {
|
|
261
|
+
// Always clean up resources
|
|
262
|
+
if (unsub) unsub()
|
|
263
|
+
server.stop()
|
|
264
|
+
await Instance.dispose()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function runDirectMode(request, providerID, modelID, systemMessage, appendSystemMessage, jsonStandard) {
|
|
269
|
+
// DIRECT MODE: Run in single process without server
|
|
270
|
+
let unsub = null
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// Create a session directly
|
|
274
|
+
const session = await Session.createNext({
|
|
275
|
+
directory: process.cwd()
|
|
276
|
+
})
|
|
277
|
+
const sessionID = session.id
|
|
278
|
+
|
|
279
|
+
// Create event handler for the selected JSON standard
|
|
280
|
+
const eventHandler = createEventHandler(jsonStandard, sessionID)
|
|
281
|
+
|
|
282
|
+
// Subscribe to all bus events and output in selected format
|
|
283
|
+
const eventPromise = new Promise((resolve) => {
|
|
284
|
+
unsub = Bus.subscribeAll((event) => {
|
|
285
|
+
// Output events in selected JSON format
|
|
286
|
+
if (event.type === 'message.part.updated') {
|
|
287
|
+
const part = event.properties.part
|
|
288
|
+
if (part.sessionID !== sessionID) return
|
|
289
|
+
|
|
290
|
+
// Output different event types
|
|
291
|
+
if (part.type === 'step-start') {
|
|
292
|
+
eventHandler.output({
|
|
293
|
+
type: 'step_start',
|
|
294
|
+
timestamp: Date.now(),
|
|
295
|
+
sessionID,
|
|
296
|
+
part
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (part.type === 'step-finish') {
|
|
301
|
+
eventHandler.output({
|
|
302
|
+
type: 'step_finish',
|
|
303
|
+
timestamp: Date.now(),
|
|
304
|
+
sessionID,
|
|
305
|
+
part
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (part.type === 'text' && part.time?.end) {
|
|
310
|
+
eventHandler.output({
|
|
311
|
+
type: 'text',
|
|
312
|
+
timestamp: Date.now(),
|
|
313
|
+
sessionID,
|
|
314
|
+
part
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
319
|
+
eventHandler.output({
|
|
320
|
+
type: 'tool_use',
|
|
321
|
+
timestamp: Date.now(),
|
|
322
|
+
sessionID,
|
|
323
|
+
part
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Handle session idle to know when to stop
|
|
329
|
+
if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
|
|
330
|
+
resolve()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Handle errors
|
|
334
|
+
if (event.type === 'session.error') {
|
|
335
|
+
const props = event.properties
|
|
336
|
+
if (props.sessionID !== sessionID || !props.error) return
|
|
337
|
+
hasError = true
|
|
338
|
+
eventHandler.output({
|
|
339
|
+
type: 'error',
|
|
340
|
+
timestamp: Date.now(),
|
|
341
|
+
sessionID,
|
|
342
|
+
error: props.error
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// Send message to session directly
|
|
349
|
+
const message = request.message || "hi"
|
|
350
|
+
const parts = [{ type: "text", text: message }]
|
|
351
|
+
|
|
352
|
+
// Start the prompt directly without HTTP
|
|
353
|
+
SessionPrompt.prompt({
|
|
354
|
+
sessionID,
|
|
355
|
+
parts,
|
|
356
|
+
model: {
|
|
357
|
+
providerID,
|
|
358
|
+
modelID
|
|
359
|
+
},
|
|
360
|
+
system: systemMessage,
|
|
361
|
+
appendSystem: appendSystemMessage
|
|
362
|
+
}).catch((error) => {
|
|
363
|
+
hasError = true
|
|
364
|
+
eventHandler.output({
|
|
365
|
+
type: 'error',
|
|
366
|
+
timestamp: Date.now(),
|
|
367
|
+
sessionID,
|
|
368
|
+
error: error instanceof Error ? error.message : String(error)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// Wait for session to become idle
|
|
373
|
+
await eventPromise
|
|
374
|
+
} finally {
|
|
375
|
+
// Always clean up resources
|
|
376
|
+
if (unsub) unsub()
|
|
377
|
+
await Instance.dispose()
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
38
381
|
async function main() {
|
|
39
382
|
try {
|
|
40
|
-
// Parse command line arguments
|
|
383
|
+
// Parse command line arguments with subcommands
|
|
41
384
|
const argv = await yargs(hideBin(process.argv))
|
|
385
|
+
.scriptName('agent')
|
|
386
|
+
.usage('$0 [command] [options]')
|
|
387
|
+
// MCP subcommand
|
|
388
|
+
.command(McpCommand)
|
|
389
|
+
// Default run mode (when piping stdin)
|
|
42
390
|
.option('model', {
|
|
43
391
|
type: 'string',
|
|
44
392
|
description: 'Model to use in format providerID/modelID',
|
|
45
393
|
default: 'opencode/grok-code'
|
|
46
394
|
})
|
|
395
|
+
.option('json-standard', {
|
|
396
|
+
type: 'string',
|
|
397
|
+
description: 'JSON output format standard: "opencode" (default) or "claude" (experimental)',
|
|
398
|
+
default: 'opencode',
|
|
399
|
+
choices: ['opencode', 'claude']
|
|
400
|
+
})
|
|
47
401
|
.option('system-message', {
|
|
48
402
|
type: 'string',
|
|
49
403
|
description: 'Full override of the system message'
|
|
@@ -68,302 +422,23 @@ async function main() {
|
|
|
68
422
|
.help()
|
|
69
423
|
.argv
|
|
70
424
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const modelID = modelParts[1] || 'grok-code'
|
|
75
|
-
|
|
76
|
-
// Read system message files
|
|
77
|
-
let systemMessage = argv['system-message']
|
|
78
|
-
let appendSystemMessage = argv['append-system-message']
|
|
79
|
-
|
|
80
|
-
if (argv['system-message-file']) {
|
|
81
|
-
const resolvedPath = require('path').resolve(process.cwd(), argv['system-message-file'])
|
|
82
|
-
const file = Bun.file(resolvedPath)
|
|
83
|
-
if (!(await file.exists())) {
|
|
84
|
-
console.error(`System message file not found: ${argv['system-message-file']}`)
|
|
85
|
-
process.exit(1)
|
|
86
|
-
}
|
|
87
|
-
systemMessage = await file.text()
|
|
88
|
-
}
|
|
425
|
+
// If a command was executed (like mcp), yargs handles it
|
|
426
|
+
// Otherwise, check if we should run in agent mode (stdin piped)
|
|
427
|
+
const commandExecuted = argv._ && argv._.length > 0
|
|
89
428
|
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (!(await file.exists())) {
|
|
94
|
-
console.error(`Append system message file not found: ${argv['append-system-message-file']}`)
|
|
95
|
-
process.exit(1)
|
|
96
|
-
}
|
|
97
|
-
appendSystemMessage = await file.text()
|
|
429
|
+
if (!commandExecuted) {
|
|
430
|
+
// No command specified, run in default agent mode (stdin processing)
|
|
431
|
+
await runAgentMode(argv)
|
|
98
432
|
}
|
|
99
|
-
|
|
100
|
-
// Initialize logging to redirect to log file instead of stderr
|
|
101
|
-
// This prevents log messages from mixing with JSON output
|
|
102
|
-
await Log.init({
|
|
103
|
-
print: false, // Don't print to stderr
|
|
104
|
-
level: 'INFO'
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
// Read input from stdin
|
|
108
|
-
const input = await readStdin()
|
|
109
|
-
const trimmedInput = input.trim()
|
|
110
|
-
|
|
111
|
-
// Try to parse as JSON, if it fails treat it as plain text message
|
|
112
|
-
let request
|
|
113
|
-
try {
|
|
114
|
-
request = JSON.parse(trimmedInput)
|
|
115
|
-
} catch (e) {
|
|
116
|
-
// Not JSON, treat as plain text message
|
|
117
|
-
request = {
|
|
118
|
-
message: trimmedInput
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Wrap in Instance.provide for OpenCode infrastructure
|
|
123
|
-
await Instance.provide({
|
|
124
|
-
directory: process.cwd(),
|
|
125
|
-
fn: async () => {
|
|
126
|
-
if (argv.server) {
|
|
127
|
-
// SERVER MODE: Start server and communicate via HTTP
|
|
128
|
-
await runServerMode()
|
|
129
|
-
} else {
|
|
130
|
-
// DIRECT MODE: Run everything in single process
|
|
131
|
-
await runDirectMode()
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
async function runServerMode() {
|
|
137
|
-
// Start server like OpenCode does
|
|
138
|
-
const server = Server.listen({ port: 0, hostname: "127.0.0.1" })
|
|
139
|
-
let unsub = null
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
// Create a session
|
|
143
|
-
const createRes = await fetch(`http://${server.hostname}:${server.port}/session`, {
|
|
144
|
-
method: 'POST',
|
|
145
|
-
headers: { 'Content-Type': 'application/json' },
|
|
146
|
-
body: JSON.stringify({})
|
|
147
|
-
})
|
|
148
|
-
const session = await createRes.json()
|
|
149
|
-
const sessionID = session.id
|
|
150
|
-
|
|
151
|
-
if (!sessionID) {
|
|
152
|
-
throw new Error("Failed to create session")
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Subscribe to all bus events to output them in OpenCode format
|
|
156
|
-
const eventPromise = new Promise((resolve) => {
|
|
157
|
-
unsub = Bus.subscribeAll((event) => {
|
|
158
|
-
// Output events in OpenCode JSON format
|
|
159
|
-
if (event.type === 'message.part.updated') {
|
|
160
|
-
const part = event.properties.part
|
|
161
|
-
if (part.sessionID !== sessionID) return
|
|
162
|
-
|
|
163
|
-
// Output different event types (pretty-printed for readability)
|
|
164
|
-
if (part.type === 'step-start') {
|
|
165
|
-
process.stdout.write(JSON.stringify({
|
|
166
|
-
type: 'step_start',
|
|
167
|
-
timestamp: Date.now(),
|
|
168
|
-
sessionID,
|
|
169
|
-
part
|
|
170
|
-
}, null, 2) + EOL)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (part.type === 'step-finish') {
|
|
174
|
-
process.stdout.write(JSON.stringify({
|
|
175
|
-
type: 'step_finish',
|
|
176
|
-
timestamp: Date.now(),
|
|
177
|
-
sessionID,
|
|
178
|
-
part
|
|
179
|
-
}, null, 2) + EOL)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (part.type === 'text' && part.time?.end) {
|
|
183
|
-
process.stdout.write(JSON.stringify({
|
|
184
|
-
type: 'text',
|
|
185
|
-
timestamp: Date.now(),
|
|
186
|
-
sessionID,
|
|
187
|
-
part
|
|
188
|
-
}, null, 2) + EOL)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
192
|
-
process.stdout.write(JSON.stringify({
|
|
193
|
-
type: 'tool_use',
|
|
194
|
-
timestamp: Date.now(),
|
|
195
|
-
sessionID,
|
|
196
|
-
part
|
|
197
|
-
}, null, 2) + EOL)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Handle session idle to know when to stop
|
|
202
|
-
if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
|
|
203
|
-
resolve()
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Handle errors
|
|
207
|
-
if (event.type === 'session.error') {
|
|
208
|
-
const props = event.properties
|
|
209
|
-
if (props.sessionID !== sessionID || !props.error) return
|
|
210
|
-
process.stdout.write(JSON.stringify({
|
|
211
|
-
type: 'error',
|
|
212
|
-
timestamp: Date.now(),
|
|
213
|
-
sessionID,
|
|
214
|
-
error: props.error
|
|
215
|
-
}, null, 2) + EOL)
|
|
216
|
-
}
|
|
217
|
-
})
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
// Send message to session with specified model (default: opencode/grok-code)
|
|
221
|
-
const message = request.message || "hi"
|
|
222
|
-
const parts = [{ type: "text", text: message }]
|
|
223
|
-
|
|
224
|
-
// Start the prompt (don't wait for response, events come via Bus)
|
|
225
|
-
fetch(`http://${server.hostname}:${server.port}/session/${sessionID}/message`, {
|
|
226
|
-
method: 'POST',
|
|
227
|
-
headers: { 'Content-Type': 'application/json' },
|
|
228
|
-
body: JSON.stringify({
|
|
229
|
-
parts,
|
|
230
|
-
model: {
|
|
231
|
-
providerID,
|
|
232
|
-
modelID
|
|
233
|
-
},
|
|
234
|
-
system: systemMessage,
|
|
235
|
-
appendSystem: appendSystemMessage
|
|
236
|
-
})
|
|
237
|
-
}).catch(() => {
|
|
238
|
-
// Ignore errors, we're listening to events
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
// Wait for session to become idle
|
|
242
|
-
await eventPromise
|
|
243
|
-
} finally {
|
|
244
|
-
// Always clean up resources
|
|
245
|
-
if (unsub) unsub()
|
|
246
|
-
server.stop()
|
|
247
|
-
await Instance.dispose()
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function runDirectMode() {
|
|
252
|
-
// DIRECT MODE: Run in single process without server
|
|
253
|
-
let unsub = null
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
// Create a session directly
|
|
257
|
-
const session = await Session.createNext({
|
|
258
|
-
directory: process.cwd()
|
|
259
|
-
})
|
|
260
|
-
const sessionID = session.id
|
|
261
|
-
|
|
262
|
-
// Subscribe to all bus events to output them in OpenCode format
|
|
263
|
-
const eventPromise = new Promise((resolve) => {
|
|
264
|
-
unsub = Bus.subscribeAll((event) => {
|
|
265
|
-
// Output events in OpenCode JSON format
|
|
266
|
-
if (event.type === 'message.part.updated') {
|
|
267
|
-
const part = event.properties.part
|
|
268
|
-
if (part.sessionID !== sessionID) return
|
|
269
|
-
|
|
270
|
-
// Output different event types (pretty-printed for readability)
|
|
271
|
-
if (part.type === 'step-start') {
|
|
272
|
-
process.stdout.write(JSON.stringify({
|
|
273
|
-
type: 'step_start',
|
|
274
|
-
timestamp: Date.now(),
|
|
275
|
-
sessionID,
|
|
276
|
-
part
|
|
277
|
-
}, null, 2) + EOL)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (part.type === 'step-finish') {
|
|
281
|
-
process.stdout.write(JSON.stringify({
|
|
282
|
-
type: 'step_finish',
|
|
283
|
-
timestamp: Date.now(),
|
|
284
|
-
sessionID,
|
|
285
|
-
part
|
|
286
|
-
}, null, 2) + EOL)
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (part.type === 'text' && part.time?.end) {
|
|
290
|
-
process.stdout.write(JSON.stringify({
|
|
291
|
-
type: 'text',
|
|
292
|
-
timestamp: Date.now(),
|
|
293
|
-
sessionID,
|
|
294
|
-
part
|
|
295
|
-
}, null, 2) + EOL)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (part.type === 'tool' && part.state.status === 'completed') {
|
|
299
|
-
process.stdout.write(JSON.stringify({
|
|
300
|
-
type: 'tool_use',
|
|
301
|
-
timestamp: Date.now(),
|
|
302
|
-
sessionID,
|
|
303
|
-
part
|
|
304
|
-
}, null, 2) + EOL)
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Handle session idle to know when to stop
|
|
309
|
-
if (event.type === 'session.idle' && event.properties.sessionID === sessionID) {
|
|
310
|
-
resolve()
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Handle errors
|
|
314
|
-
if (event.type === 'session.error') {
|
|
315
|
-
const props = event.properties
|
|
316
|
-
if (props.sessionID !== sessionID || !props.error) return
|
|
317
|
-
process.stdout.write(JSON.stringify({
|
|
318
|
-
type: 'error',
|
|
319
|
-
timestamp: Date.now(),
|
|
320
|
-
sessionID,
|
|
321
|
-
error: props.error
|
|
322
|
-
}, null, 2) + EOL)
|
|
323
|
-
}
|
|
324
|
-
})
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
// Send message to session directly
|
|
328
|
-
const message = request.message || "hi"
|
|
329
|
-
const parts = [{ type: "text", text: message }]
|
|
330
|
-
|
|
331
|
-
// Start the prompt directly without HTTP
|
|
332
|
-
SessionPrompt.prompt({
|
|
333
|
-
sessionID,
|
|
334
|
-
parts,
|
|
335
|
-
model: {
|
|
336
|
-
providerID,
|
|
337
|
-
modelID
|
|
338
|
-
},
|
|
339
|
-
system: systemMessage,
|
|
340
|
-
appendSystem: appendSystemMessage
|
|
341
|
-
}).catch((error) => {
|
|
342
|
-
process.stdout.write(JSON.stringify({
|
|
343
|
-
type: 'error',
|
|
344
|
-
timestamp: Date.now(),
|
|
345
|
-
sessionID,
|
|
346
|
-
error: error instanceof Error ? error.message : String(error)
|
|
347
|
-
}, null, 2) + EOL)
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
// Wait for session to become idle
|
|
351
|
-
await eventPromise
|
|
352
|
-
} finally {
|
|
353
|
-
// Always clean up resources
|
|
354
|
-
if (unsub) unsub()
|
|
355
|
-
await Instance.dispose()
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Explicitly exit to ensure process terminates
|
|
360
|
-
process.exit(0)
|
|
361
433
|
} catch (error) {
|
|
434
|
+
hasError = true
|
|
362
435
|
console.error(JSON.stringify({
|
|
363
436
|
type: 'error',
|
|
364
437
|
timestamp: Date.now(),
|
|
365
|
-
|
|
366
|
-
|
|
438
|
+
errorType: error instanceof Error ? error.name : 'Error',
|
|
439
|
+
message: error instanceof Error ? error.message : String(error),
|
|
440
|
+
stack: error instanceof Error ? error.stack : undefined
|
|
441
|
+
}, null, 2))
|
|
367
442
|
process.exit(1)
|
|
368
443
|
}
|
|
369
444
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Standard Format Handlers
|
|
3
|
+
*
|
|
4
|
+
* Provides adapters for different JSON output formats:
|
|
5
|
+
* - opencode: OpenCode format (default) - pretty-printed JSON events
|
|
6
|
+
* - claude: Claude CLI stream-json format - NDJSON (newline-delimited JSON)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EOL } from 'os'
|
|
10
|
+
|
|
11
|
+
export type JsonStandard = 'opencode' | 'claude'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OpenCode JSON event types
|
|
15
|
+
*/
|
|
16
|
+
export interface OpenCodeEvent {
|
|
17
|
+
type: 'step_start' | 'step_finish' | 'text' | 'tool_use' | 'error'
|
|
18
|
+
timestamp: number
|
|
19
|
+
sessionID: string
|
|
20
|
+
part?: Record<string, unknown>
|
|
21
|
+
error?: string | Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Claude JSON event types (stream-json format)
|
|
26
|
+
*/
|
|
27
|
+
export interface ClaudeEvent {
|
|
28
|
+
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result'
|
|
29
|
+
timestamp?: string
|
|
30
|
+
session_id?: string
|
|
31
|
+
role?: 'assistant' | 'user'
|
|
32
|
+
content?: Array<{type: 'text' | 'tool_use', text?: string, name?: string, input?: unknown}>
|
|
33
|
+
output?: string
|
|
34
|
+
name?: string
|
|
35
|
+
input?: unknown
|
|
36
|
+
tool_use_id?: string
|
|
37
|
+
status?: 'success' | 'error'
|
|
38
|
+
duration_ms?: number
|
|
39
|
+
model?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Serialize JSON output based on the selected standard
|
|
44
|
+
*/
|
|
45
|
+
export function serializeOutput(event: OpenCodeEvent | ClaudeEvent, standard: JsonStandard): string {
|
|
46
|
+
if (standard === 'claude') {
|
|
47
|
+
// NDJSON format - compact, one line
|
|
48
|
+
return JSON.stringify(event) + EOL
|
|
49
|
+
}
|
|
50
|
+
// OpenCode format - pretty-printed
|
|
51
|
+
return JSON.stringify(event, null, 2) + EOL
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Convert OpenCode event to Claude event format
|
|
56
|
+
*/
|
|
57
|
+
export function convertOpenCodeToClaude(event: OpenCodeEvent, startTime: number): ClaudeEvent | null {
|
|
58
|
+
const timestamp = new Date(event.timestamp).toISOString()
|
|
59
|
+
const session_id = event.sessionID
|
|
60
|
+
|
|
61
|
+
switch (event.type) {
|
|
62
|
+
case 'step_start':
|
|
63
|
+
return {
|
|
64
|
+
type: 'init',
|
|
65
|
+
timestamp,
|
|
66
|
+
session_id
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case 'text':
|
|
70
|
+
if (event.part && typeof event.part.text === 'string') {
|
|
71
|
+
return {
|
|
72
|
+
type: 'message',
|
|
73
|
+
timestamp,
|
|
74
|
+
session_id,
|
|
75
|
+
role: 'assistant',
|
|
76
|
+
content: [{
|
|
77
|
+
type: 'text',
|
|
78
|
+
text: event.part.text
|
|
79
|
+
}]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
|
|
84
|
+
case 'tool_use':
|
|
85
|
+
if (event.part && event.part.state) {
|
|
86
|
+
const state = event.part.state as Record<string, unknown>
|
|
87
|
+
const tool = state.tool as Record<string, unknown> | undefined
|
|
88
|
+
return {
|
|
89
|
+
type: 'tool_use',
|
|
90
|
+
timestamp,
|
|
91
|
+
session_id,
|
|
92
|
+
name: (tool?.name as string) || 'unknown',
|
|
93
|
+
input: tool?.parameters || {},
|
|
94
|
+
tool_use_id: event.part.id as string
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
|
|
99
|
+
case 'step_finish':
|
|
100
|
+
return {
|
|
101
|
+
type: 'result',
|
|
102
|
+
timestamp,
|
|
103
|
+
session_id,
|
|
104
|
+
status: 'success',
|
|
105
|
+
duration_ms: event.timestamp - startTime
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case 'error':
|
|
109
|
+
return {
|
|
110
|
+
type: 'result',
|
|
111
|
+
timestamp,
|
|
112
|
+
session_id,
|
|
113
|
+
status: 'error',
|
|
114
|
+
output: typeof event.error === 'string' ? event.error : JSON.stringify(event.error)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
default:
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create an event output handler based on the selected standard
|
|
124
|
+
*/
|
|
125
|
+
export function createEventHandler(standard: JsonStandard, sessionID: string) {
|
|
126
|
+
const startTime = Date.now()
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
/**
|
|
130
|
+
* Format and output an event
|
|
131
|
+
*/
|
|
132
|
+
output(event: OpenCodeEvent): void {
|
|
133
|
+
if (standard === 'claude') {
|
|
134
|
+
const claudeEvent = convertOpenCodeToClaude(event, startTime)
|
|
135
|
+
if (claudeEvent) {
|
|
136
|
+
process.stdout.write(serializeOutput(claudeEvent, standard))
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
process.stdout.write(serializeOutput(event, standard))
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get the start time for duration calculations
|
|
145
|
+
*/
|
|
146
|
+
getStartTime(): number {
|
|
147
|
+
return startTime
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate JSON standard option
|
|
154
|
+
*/
|
|
155
|
+
export function isValidJsonStandard(value: string): value is JsonStandard {
|
|
156
|
+
return value === 'opencode' || value === 'claude'
|
|
157
|
+
}
|