linkshell-cli 0.2.93 → 0.2.94

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.
@@ -5,6 +5,7 @@ import {
5
5
  type Envelope,
6
6
  } from "@linkshell/protocol";
7
7
  import { AcpClient } from "./acp-client.js";
8
+ import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
8
9
  import type { AgentProtocol, AgentProvider } from "./provider-resolver.js";
9
10
  import { resolveAgentCommand } from "./provider-resolver.js";
10
11
 
@@ -657,7 +658,7 @@ function providerLabel(provider: AgentProvider): string {
657
658
  }
658
659
 
659
660
  export class AgentWorkspaceProxy {
660
- private clients = new Map<AgentProvider, AcpClient>();
661
+ private clients = new Map<AgentProvider, AcpClient | ClaudeStreamJsonClient>();
661
662
  private agentProtocols = new Map<AgentProvider, AgentProtocol>();
662
663
  private initialized = false;
663
664
  private status: AgentStatus = "unavailable";
@@ -753,7 +754,7 @@ export class AgentWorkspaceProxy {
753
754
  this.clients.clear();
754
755
  }
755
756
 
756
- private clientForProvider(provider: AgentProvider): AcpClient | undefined {
757
+ private clientForProvider(provider: AgentProvider): AcpClient | ClaudeStreamJsonClient | undefined {
757
758
  return this.clients.get(provider);
758
759
  }
759
760
 
@@ -772,7 +773,7 @@ export class AgentWorkspaceProxy {
772
773
  this.sendCapabilities();
773
774
  }
774
775
 
775
- private async ensureProviderClient(provider: AgentProvider): Promise<AcpClient | undefined> {
776
+ private async ensureProviderClient(provider: AgentProvider): Promise<AcpClient | ClaudeStreamJsonClient | undefined> {
776
777
  const existing = this.clients.get(provider);
777
778
  if (existing) return existing;
778
779
 
@@ -789,15 +790,26 @@ export class AgentWorkspaceProxy {
789
790
 
790
791
  try {
791
792
  this.agentProtocols.set(provider, resolved.protocol);
792
- const client = new AcpClient({
793
- command: resolved.command,
794
- protocol: resolved.protocol,
795
- framing: resolved.framing,
796
- cwd: this.input.cwd,
797
- onNotification: (method, params) => this.handleNotification(method, params),
798
- onRequest: (method, params) => this.handleRequest(method, params),
799
- onExit: (message) => this.handleProviderExit(provider, message),
800
- });
793
+ const isClaudeStreamJson = resolved.protocol === "claude-stream-json";
794
+ const client = isClaudeStreamJson
795
+ ? new ClaudeStreamJsonClient({
796
+ command: resolved.command,
797
+ protocol: resolved.protocol,
798
+ framing: resolved.framing,
799
+ cwd: this.input.cwd,
800
+ onNotification: (method, params) => this.handleNotification(method, params),
801
+ onRequest: (method, params) => this.handleRequest(method, params),
802
+ onExit: (message) => this.handleProviderExit(provider, message),
803
+ })
804
+ : new AcpClient({
805
+ command: resolved.command,
806
+ protocol: resolved.protocol,
807
+ framing: resolved.framing,
808
+ cwd: this.input.cwd,
809
+ onNotification: (method, params) => this.handleNotification(method, params),
810
+ onRequest: (method, params) => this.handleRequest(method, params),
811
+ onExit: (message) => this.handleProviderExit(provider, message),
812
+ });
801
813
  await client.initialize();
802
814
  this.clients.set(provider, client);
803
815
  this.status = "idle";
@@ -0,0 +1,372 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { homedir } from "node:os";
4
+ import { readdirSync, existsSync } from "node:fs";
5
+ import { join, basename, relative, resolve } from "node:path";
6
+ import type { AgentFraming, AgentProtocol } from "./provider-resolver.js";
7
+
8
+ type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
9
+
10
+ interface ClaudeStreamEvent {
11
+ type: string;
12
+ subtype?: string;
13
+ message?: Record<string, unknown>;
14
+ session_id?: string;
15
+ parent_tool_use_id?: string | null;
16
+ uuid?: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ interface ClaudeContentBlock {
21
+ type: string;
22
+ text?: string;
23
+ thinking?: string;
24
+ id?: string;
25
+ name?: string;
26
+ input?: Record<string, unknown>;
27
+ tool_use_id?: string;
28
+ content?: string;
29
+ is_error?: boolean;
30
+ signature?: string;
31
+ }
32
+
33
+ // Hash a directory path the same way Claude Code does for project storage
34
+ function projectHash(cwd: string): string {
35
+ return (
36
+ "-" +
37
+ resolve(cwd)
38
+ .replace(/\/$/, "")
39
+ .replace(/\//g, "-")
40
+ );
41
+ }
42
+
43
+ function id(prefix: string): string {
44
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
45
+ }
46
+
47
+ export class ClaudeStreamJsonClient {
48
+ private child: ChildProcessWithoutNullStreams | undefined;
49
+ private claudeSessionId: string | undefined;
50
+ private pendingCancel = false;
51
+ private messageBuffer = "";
52
+
53
+ constructor(
54
+ private readonly input: {
55
+ command: string;
56
+ protocol: AgentProtocol;
57
+ framing: AgentFraming;
58
+ cwd: string;
59
+ onNotification: (method: string, params: unknown) => void;
60
+ onRequest: (method: string, params: unknown) => Promise<unknown> | unknown;
61
+ onExit: (message: string) => void;
62
+ },
63
+ ) {}
64
+
65
+ async initialize(): Promise<unknown> {
66
+ // No persistent process to start — we'll capture session on first prompt.
67
+ // But we can verify the binary exists by running a quick --help.
68
+ try {
69
+ const { execSync } = await import("node:child_process");
70
+ execSync("claude --version", { stdio: "ignore", timeout: 5000 });
71
+ } catch {
72
+ throw new Error("Claude Code CLI not found or not executable");
73
+ }
74
+ return { status: "ok" };
75
+ }
76
+
77
+ async newSession(input: { cwd: string; mcpServers?: unknown }): Promise<unknown> {
78
+ // Start a dry-run prompt to get a session_id, then cancel.
79
+ // Actually, we just store that there's no session yet — the real session
80
+ // will be created on the first prompt() call.
81
+ this.claudeSessionId = undefined;
82
+ return { sessionId: undefined, status: "ready" };
83
+ }
84
+
85
+ async loadSession(input: { sessionId: string; cwd: string; mcpServers?: unknown }): Promise<unknown> {
86
+ this.claudeSessionId = input.sessionId;
87
+ return { sessionId: input.sessionId, status: "loaded" };
88
+ }
89
+
90
+ async prompt(input: {
91
+ sessionId?: string;
92
+ content: unknown[];
93
+ clientMessageId: string;
94
+ model?: string;
95
+ reasoningEffort?: string;
96
+ permissionMode?: AgentPermissionMode;
97
+ cwd: string;
98
+ }): Promise<unknown> {
99
+ if (this.child) {
100
+ this.stop();
101
+ }
102
+
103
+ this.pendingCancel = false;
104
+
105
+ // Build Claude args
106
+ const args: string[] = [
107
+ "--print",
108
+ "--output-format", "stream-json",
109
+ "--input-format", "stream-json",
110
+ "--verbose",
111
+ "--permission-mode", "bypassPermissions",
112
+ ];
113
+
114
+ // Use stored session for --continue or --resume
115
+ if (input.sessionId || this.claudeSessionId) {
116
+ const sid = input.sessionId ?? this.claudeSessionId;
117
+ if (sid) {
118
+ args.push("--resume", sid);
119
+ }
120
+ }
121
+
122
+ if (input.model) {
123
+ args.push("--model", input.model);
124
+ }
125
+
126
+ // Build the user message
127
+ const contentBlocks = (input.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>).map(
128
+ (block) => {
129
+ if (block.type === "image" && block.data) {
130
+ return { type: "image", source: { type: "base64", media_type: block.mimeType ?? "image/png", data: block.data } };
131
+ }
132
+ return { type: "text", text: block.text ?? "" };
133
+ },
134
+ );
135
+
136
+ const userMessage = {
137
+ type: "user",
138
+ message: {
139
+ role: "user",
140
+ content: contentBlocks,
141
+ },
142
+ };
143
+
144
+ return new Promise((resolve, reject) => {
145
+ const cwd = input.cwd ?? this.input.cwd;
146
+ const child = spawn("claude", args, {
147
+ cwd,
148
+ env: process.env,
149
+ stdio: ["pipe", "pipe", "pipe"],
150
+ });
151
+
152
+ this.child = child;
153
+ let settled = false;
154
+
155
+ const finish = (err: Error | null, result: unknown) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ this.child = undefined;
159
+ if (err) {
160
+ this.input.onExit(err.message);
161
+ reject(err);
162
+ } else {
163
+ resolve(result);
164
+ }
165
+ };
166
+
167
+ // Send the prompt
168
+ child.stdin.write(JSON.stringify(userMessage) + "\n");
169
+ child.stdin.end();
170
+
171
+ // Read stdout line by line (stream-json is newline-delimited)
172
+ const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
173
+ let currentToolId: string | undefined;
174
+
175
+ rl.on("line", (line: string) => {
176
+ if (this.pendingCancel) {
177
+ child.kill("SIGTERM");
178
+ return;
179
+ }
180
+
181
+ let event: ClaudeStreamEvent;
182
+ try {
183
+ event = JSON.parse(line);
184
+ } catch {
185
+ return; // skip unparseable lines
186
+ }
187
+
188
+ switch (event.type) {
189
+ case "system": {
190
+ if (event.subtype === "init") {
191
+ // Capture session ID
192
+ if (event.session_id) {
193
+ this.claudeSessionId = event.session_id;
194
+ }
195
+ // Send as initialized notification — workspace needs this
196
+ const initParams: Record<string, unknown> = {
197
+ sessionId: event.session_id,
198
+ cwd: event.cwd ?? cwd,
199
+ model: event.model,
200
+ };
201
+ if (event.tools) initParams.tools = event.tools;
202
+ if (event.mcp_servers) initParams.mcpServers = event.mcp_servers;
203
+ this.input.onNotification("initialized", initParams);
204
+ }
205
+ // Hook events and other system messages are informational, skip
206
+ break;
207
+ }
208
+
209
+ case "assistant": {
210
+ const message = event.message;
211
+ if (!message) break;
212
+ const content = (message.content ?? []) as ClaudeContentBlock[];
213
+
214
+ for (const block of content) {
215
+ switch (block.type) {
216
+ case "thinking":
217
+ this.input.onNotification("item/started", {
218
+ sessionId: this.claudeSessionId,
219
+ item: {
220
+ id: event.uuid ?? id("thinking"),
221
+ type: "thinking",
222
+ text: block.thinking,
223
+ status: "completed",
224
+ },
225
+ });
226
+ break;
227
+
228
+ case "text":
229
+ this.input.onNotification("item/agentMessage/delta", {
230
+ sessionId: this.claudeSessionId,
231
+ itemId: message.id ?? event.uuid ?? id("msg"),
232
+ delta: block.text,
233
+ });
234
+ break;
235
+
236
+ case "tool_use": {
237
+ currentToolId = block.id;
238
+ const toolName = block.name ?? "tool";
239
+ this.input.onNotification("item/started", {
240
+ sessionId: this.claudeSessionId,
241
+ item: {
242
+ id: block.id ?? id("tool"),
243
+ type: toolName === "Bash" ? "commandExecution" : toolName === "Write" || toolName === "Edit" ? "fileChange" : "toolCall",
244
+ toolName: block.name,
245
+ tool: block.name,
246
+ input: block.input,
247
+ command: block.input?.command as string | undefined,
248
+ cwd: block.input?.cwd as string | undefined ?? cwd,
249
+ status: "running",
250
+ },
251
+ });
252
+ break;
253
+ }
254
+
255
+ case "tool_result":
256
+ // tool_result comes in user messages, not assistant
257
+ break;
258
+ }
259
+ }
260
+ break;
261
+ }
262
+
263
+ case "user": {
264
+ // User messages in stream-json contain tool results
265
+ const message = event.message;
266
+ if (!message) break;
267
+ const content = (message.content ?? []) as ClaudeContentBlock[];
268
+
269
+ for (const block of content) {
270
+ if (block.type === "tool_result") {
271
+ const toolId = block.tool_use_id ?? currentToolId;
272
+ const isError = block.is_error === true;
273
+ this.input.onNotification("item/completed", {
274
+ sessionId: this.claudeSessionId,
275
+ item: {
276
+ id: toolId ?? id("tool"),
277
+ type: "toolCall",
278
+ status: isError ? "failed" : "completed",
279
+ output: typeof block.content === "string" ? block.content : JSON.stringify(block.content),
280
+ aggregatedOutput: typeof block.content === "string" ? block.content : undefined,
281
+ isError,
282
+ },
283
+ });
284
+ }
285
+ }
286
+ break;
287
+ }
288
+
289
+ case "result": {
290
+ // Turn complete
291
+ const isError = event.subtype === "error" || event.is_error === true;
292
+ this.input.onNotification("turn/completed", {
293
+ sessionId: this.claudeSessionId,
294
+ stopReason: event.stop_reason ?? (isError ? "error" : "end_turn"),
295
+ durationMs: event.duration_ms,
296
+ totalCostUsd: event.total_cost_usd,
297
+ usage: event.usage,
298
+ isError,
299
+ });
300
+ finish(null, { sessionId: this.claudeSessionId, status: isError ? "error" : "completed" });
301
+ break;
302
+ }
303
+ }
304
+ });
305
+
306
+ child.stderr.on("data", (chunk: Buffer) => {
307
+ const trimmed = chunk.toString().trim();
308
+ if (trimmed) process.stderr.write(`[claude:stderr] ${trimmed}\n`);
309
+ });
310
+
311
+ child.on("error", (err) => {
312
+ finish(err, undefined);
313
+ });
314
+
315
+ child.on("exit", (code, signal) => {
316
+ if (!settled) {
317
+ finish(
318
+ new Error(`Claude exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"})`),
319
+ undefined,
320
+ );
321
+ }
322
+ });
323
+ });
324
+ }
325
+
326
+ cancel(input: { sessionId?: string; turnId?: string }): void {
327
+ this.pendingCancel = true;
328
+ if (this.child && !this.child.killed) {
329
+ this.child.kill("SIGTERM");
330
+ }
331
+ this.child = undefined;
332
+ }
333
+
334
+ respondPermission(input: { sessionId?: string; requestId: string; outcome: "allow" | "deny"; optionId?: string }): void {
335
+ // In bypassPermissions mode, permission requests don't occur.
336
+ // If we later switch to hook-based permissions, we'd send the response via a hook.
337
+ }
338
+
339
+ async listSessions(): Promise<unknown> {
340
+ const home = homedir();
341
+ const projectDir = join(home, ".claude", "projects", projectHash(this.input.cwd));
342
+
343
+ if (!existsSync(projectDir)) {
344
+ return { sessions: [] };
345
+ }
346
+
347
+ const sessions: Array<{ id: string; cwd: string; lastModified: number }> = [];
348
+ try {
349
+ for (const entry of readdirSync(projectDir)) {
350
+ if (entry.endsWith(".jsonl")) {
351
+ const sessionId = entry.replace(".jsonl", "");
352
+ sessions.push({
353
+ id: sessionId,
354
+ cwd: this.input.cwd,
355
+ lastModified: 0, // would need fs.statSync for accurate time
356
+ });
357
+ }
358
+ }
359
+ } catch {
360
+ // directory read failed
361
+ }
362
+
363
+ return { sessions };
364
+ }
365
+
366
+ stop(): void {
367
+ if (this.child && !this.child.killed) {
368
+ this.child.kill("SIGTERM");
369
+ }
370
+ this.child = undefined;
371
+ }
372
+ }
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { existsSync } from "node:fs";
4
4
 
5
5
  export type AgentProvider = "codex" | "claude" | "custom";
6
- export type AgentProtocol = "acp" | "codex-app-server";
6
+ export type AgentProtocol = "acp" | "codex-app-server" | "claude-stream-json";
7
7
  export type AgentFraming = "content-length" | "newline";
8
8
 
9
9
  export interface AgentCommandConfig {
@@ -40,9 +40,9 @@ export function resolveAgentCommand(input: {
40
40
  if (input.provider === "claude") {
41
41
  return {
42
42
  provider: "claude",
43
- command: "claude --acp",
44
- protocol: "acp",
45
- framing: "content-length",
43
+ command: "claude --print --output-format stream-json --input-format stream-json --verbose --permission-mode bypassPermissions",
44
+ protocol: "claude-stream-json",
45
+ framing: "newline",
46
46
  };
47
47
  }
48
48
 
@@ -81,34 +81,9 @@ function resolveBinary(bin: string): string | null {
81
81
  return null;
82
82
  }
83
83
 
84
- function supportsAcpFlag(bin: string): boolean {
85
- try {
86
- const result = execSync(`${bin} --acp 2>&1 || true`, {
87
- encoding: "utf8",
88
- stdio: ["ignore", "pipe", "pipe"],
89
- timeout: 5000,
90
- });
91
- // If stderr contains "unknown option", this version doesn't support --acp
92
- return !result.includes("unknown option");
93
- } catch {
94
- // Timeout or other error — assume not supported
95
- return false;
96
- }
97
- }
98
-
99
84
  export function detectAvailableProviders(): AgentProvider[] {
100
85
  const available: AgentProvider[] = [];
101
- const claudeBin = resolveBinary("claude");
102
- if (claudeBin) {
103
- if (supportsAcpFlag(claudeBin)) {
104
- available.push("claude");
105
- } else {
106
- process.stderr.write(`[provider] Claude Code detected but --acp flag not supported (version too old?)\n`);
107
- }
108
- }
86
+ if (resolveBinary("claude")) available.push("claude");
109
87
  if (resolveBinary("codex")) available.push("codex");
110
- if (available.length === 0) {
111
- process.stderr.write(`[provider] no agent providers detected\n`);
112
- }
113
88
  return available;
114
89
  }