linkshell-cli 0.2.93 → 0.2.95

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,390 @@
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, 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
+
52
+ constructor(
53
+ private readonly input: {
54
+ command: string;
55
+ protocol: AgentProtocol;
56
+ framing: AgentFraming;
57
+ cwd: string;
58
+ onNotification: (method: string, params: unknown) => void;
59
+ onRequest: (method: string, params: unknown) => Promise<unknown> | unknown;
60
+ onExit: (message: string) => void;
61
+ },
62
+ ) {}
63
+
64
+ async initialize(): Promise<unknown> {
65
+ // No persistent process to start — we'll capture session on first prompt.
66
+ // But we can verify the binary exists by running a quick --help.
67
+ try {
68
+ const { execSync } = await import("node:child_process");
69
+ execSync("claude --version", { stdio: "ignore", timeout: 5000 });
70
+ } catch {
71
+ throw new Error("Claude Code CLI not found or not executable");
72
+ }
73
+ return { status: "ok" };
74
+ }
75
+
76
+ async newSession(input: { cwd: string; mcpServers?: unknown }): Promise<unknown> {
77
+ // Start a dry-run prompt to get a session_id, then cancel.
78
+ // Actually, we just store that there's no session yet — the real session
79
+ // will be created on the first prompt() call.
80
+ this.claudeSessionId = undefined;
81
+ return { sessionId: undefined, status: "ready" };
82
+ }
83
+
84
+ async loadSession(input: { sessionId: string; cwd: string; mcpServers?: unknown }): Promise<unknown> {
85
+ this.claudeSessionId = input.sessionId;
86
+ return { sessionId: input.sessionId, status: "loaded" };
87
+ }
88
+
89
+ async prompt(input: {
90
+ sessionId?: string;
91
+ content: unknown[];
92
+ clientMessageId: string;
93
+ model?: string;
94
+ reasoningEffort?: string;
95
+ permissionMode?: AgentPermissionMode;
96
+ cwd: string;
97
+ }): Promise<unknown> {
98
+ if (this.child) {
99
+ this.stop();
100
+ }
101
+
102
+ this.pendingCancel = false;
103
+
104
+ // Build Claude args
105
+ const args: string[] = [
106
+ "--print",
107
+ "--output-format", "stream-json",
108
+ "--input-format", "stream-json",
109
+ "--verbose",
110
+ "--permission-mode", "bypassPermissions",
111
+ ];
112
+
113
+ // Use stored session for --resume (only when we have a real session ID from system.init)
114
+ if (this.claudeSessionId) {
115
+ args.push("--resume", this.claudeSessionId);
116
+ }
117
+ // Prevent autonomous multi-turn tool loops in headless mode
118
+ args.push("--max-turns", "1");
119
+
120
+ if (input.model) {
121
+ args.push("--model", input.model);
122
+ }
123
+
124
+ // Build the user message
125
+ const contentBlocks = (input.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>).map(
126
+ (block) => {
127
+ if (block.type === "image" && block.data) {
128
+ return { type: "image", source: { type: "base64", media_type: block.mimeType ?? "image/png", data: block.data } };
129
+ }
130
+ return { type: "text", text: block.text ?? "" };
131
+ },
132
+ );
133
+
134
+ const userMessage = {
135
+ type: "user",
136
+ message: {
137
+ role: "user",
138
+ content: contentBlocks,
139
+ },
140
+ };
141
+
142
+ return new Promise((resolve, reject) => {
143
+ const cwd = input.cwd ?? this.input.cwd;
144
+ const child = spawn("claude", args, {
145
+ cwd,
146
+ env: process.env,
147
+ stdio: ["pipe", "pipe", "pipe"],
148
+ });
149
+
150
+ this.child = child;
151
+ let settled = false;
152
+
153
+ const finish = (err: Error | null, result: unknown) => {
154
+ if (settled) return;
155
+ settled = true;
156
+ // Only clear reference if this child is still the active one
157
+ if (this.child === child) {
158
+ this.child = undefined;
159
+ }
160
+ if (err) {
161
+ // Don't call onExit for per-prompt failures — the client can still accept new prompts.
162
+ // onExit is reserved for fatal errors (e.g. binary not found in initialize()).
163
+ reject(err);
164
+ } else {
165
+ resolve(result);
166
+ }
167
+ };
168
+
169
+ // Send the prompt
170
+ try {
171
+ child.stdin.write(JSON.stringify(userMessage) + "\n");
172
+ child.stdin.end();
173
+ } catch (err) {
174
+ child.kill("SIGTERM");
175
+ finish(err instanceof Error ? err : new Error(String(err)), undefined);
176
+ return child;
177
+ }
178
+
179
+ // Read stdout line by line (stream-json is newline-delimited)
180
+ const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
181
+ let currentToolId: string | undefined;
182
+ let currentToolName: string | undefined;
183
+
184
+ rl.on("line", (line: string) => {
185
+ if (this.pendingCancel) {
186
+ child.kill("SIGTERM");
187
+ return;
188
+ }
189
+
190
+ let event: ClaudeStreamEvent;
191
+ try {
192
+ event = JSON.parse(line);
193
+ } catch {
194
+ return; // skip unparseable lines
195
+ }
196
+
197
+ switch (event.type) {
198
+ case "system": {
199
+ if (event.subtype === "init") {
200
+ // Capture session ID
201
+ if (event.session_id) {
202
+ this.claudeSessionId = event.session_id;
203
+ }
204
+ // Emit thread/started so workspace/session proxies update their agentSessionId
205
+ this.input.onNotification("thread/started", {
206
+ sessionId: event.session_id,
207
+ threadId: event.session_id,
208
+ });
209
+ // Also send initialized with full metadata
210
+ const initParams: Record<string, unknown> = {
211
+ sessionId: event.session_id,
212
+ threadId: event.session_id,
213
+ cwd: event.cwd ?? cwd,
214
+ model: event.model,
215
+ };
216
+ if (event.tools) initParams.tools = event.tools;
217
+ if (event.mcp_servers) initParams.mcpServers = event.mcp_servers;
218
+ this.input.onNotification("initialized", initParams);
219
+ }
220
+ // Hook events and other system messages are informational, skip
221
+ break;
222
+ }
223
+
224
+ case "assistant": {
225
+ const message = event.message;
226
+ if (!message) break;
227
+ const content = (message.content ?? []) as ClaudeContentBlock[];
228
+
229
+ for (const block of content) {
230
+ switch (block.type) {
231
+ case "thinking":
232
+ this.input.onNotification("item/started", {
233
+ sessionId: this.claudeSessionId,
234
+ item: {
235
+ id: event.uuid ?? id("thinking"),
236
+ type: "thinking",
237
+ text: block.thinking,
238
+ status: "completed",
239
+ },
240
+ });
241
+ break;
242
+
243
+ case "text":
244
+ this.input.onNotification("item/agentMessage/delta", {
245
+ sessionId: this.claudeSessionId,
246
+ itemId: message.id ?? event.uuid ?? id("msg"),
247
+ delta: block.text,
248
+ });
249
+ break;
250
+
251
+ case "tool_use": {
252
+ currentToolId = block.id;
253
+ currentToolName = block.name ?? "tool";
254
+ const toolName = block.name ?? "tool";
255
+ this.input.onNotification("item/started", {
256
+ sessionId: this.claudeSessionId,
257
+ item: {
258
+ id: block.id ?? id("tool"),
259
+ type: toolName === "Bash" ? "commandExecution" : toolName === "Write" || toolName === "Edit" ? "fileChange" : "toolCall",
260
+ toolName: block.name,
261
+ tool: block.name,
262
+ input: block.input,
263
+ command: block.input?.command as string | undefined,
264
+ cwd: block.input?.cwd as string | undefined ?? cwd,
265
+ status: "running",
266
+ },
267
+ });
268
+ break;
269
+ }
270
+
271
+ case "tool_result":
272
+ // tool_result comes in user messages, not assistant
273
+ break;
274
+ }
275
+ }
276
+ break;
277
+ }
278
+
279
+ case "user": {
280
+ // User messages in stream-json contain tool results
281
+ const message = event.message;
282
+ if (!message) break;
283
+ const content = (message.content ?? []) as ClaudeContentBlock[];
284
+
285
+ for (const block of content) {
286
+ if (block.type === "tool_result") {
287
+ const toolId = block.tool_use_id ?? currentToolId;
288
+ const isError = block.is_error === true;
289
+ this.input.onNotification("item/completed", {
290
+ sessionId: this.claudeSessionId,
291
+ item: {
292
+ id: toolId ?? id("tool"),
293
+ type: "toolCall",
294
+ toolName: currentToolName,
295
+ tool: currentToolName,
296
+ status: isError ? "failed" : "completed",
297
+ output: typeof block.content === "string" ? block.content : JSON.stringify(block.content),
298
+ aggregatedOutput: typeof block.content === "string" ? block.content : undefined,
299
+ isError,
300
+ },
301
+ });
302
+ }
303
+ }
304
+ break;
305
+ }
306
+
307
+ case "result": {
308
+ // Turn complete
309
+ const isError = event.subtype === "error" || event.is_error === true;
310
+ this.input.onNotification("turn/completed", {
311
+ sessionId: this.claudeSessionId,
312
+ stopReason: event.stop_reason ?? (isError ? "error" : "end_turn"),
313
+ durationMs: event.duration_ms,
314
+ totalCostUsd: event.total_cost_usd,
315
+ usage: event.usage,
316
+ isError,
317
+ });
318
+ finish(null, { sessionId: this.claudeSessionId, status: isError ? "error" : "completed" });
319
+ break;
320
+ }
321
+ }
322
+ });
323
+
324
+ child.stderr.on("data", (chunk: Buffer) => {
325
+ const trimmed = chunk.toString().trim();
326
+ if (trimmed) process.stderr.write(`[claude:stderr] ${trimmed}\n`);
327
+ });
328
+
329
+ child.on("error", (err) => {
330
+ finish(err, undefined);
331
+ });
332
+
333
+ child.on("exit", (code, signal) => {
334
+ if (!settled) {
335
+ finish(
336
+ new Error(`Claude exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"})`),
337
+ undefined,
338
+ );
339
+ }
340
+ });
341
+ });
342
+ }
343
+
344
+ cancel(input: { sessionId?: string; turnId?: string }): void {
345
+ this.pendingCancel = true;
346
+ if (this.child && !this.child.killed) {
347
+ this.child.kill("SIGTERM");
348
+ }
349
+ this.child = undefined;
350
+ }
351
+
352
+ respondPermission(input: { sessionId?: string; requestId: string; outcome: "allow" | "deny"; optionId?: string }): void {
353
+ // In bypassPermissions mode, permission requests don't occur.
354
+ // If we later switch to hook-based permissions, we'd send the response via a hook.
355
+ }
356
+
357
+ async listSessions(): Promise<unknown> {
358
+ const home = homedir();
359
+ const projectDir = join(home, ".claude", "projects", projectHash(this.input.cwd));
360
+
361
+ if (!existsSync(projectDir)) {
362
+ return { sessions: [] };
363
+ }
364
+
365
+ const sessions: Array<{ id: string; cwd: string; lastModified: number }> = [];
366
+ try {
367
+ for (const entry of readdirSync(projectDir)) {
368
+ if (entry.endsWith(".jsonl")) {
369
+ const sessionId = entry.replace(".jsonl", "");
370
+ sessions.push({
371
+ id: sessionId,
372
+ cwd: this.input.cwd,
373
+ lastModified: 0, // would need fs.statSync for accurate time
374
+ });
375
+ }
376
+ }
377
+ } catch {
378
+ // directory read failed
379
+ }
380
+
381
+ return { sessions };
382
+ }
383
+
384
+ stop(): void {
385
+ if (this.child && !this.child.killed) {
386
+ this.child.kill("SIGTERM");
387
+ }
388
+ this.child = undefined;
389
+ }
390
+ }
@@ -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
  }