@hienlh/ppm 0.1.0

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.
Files changed (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. package/vite.config.ts +62 -0
@@ -0,0 +1,256 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Represents a discovered Claude CLI installation.
6
+ * Mirrors opcode's ClaudeInstallation struct.
7
+ */
8
+ export interface ClaudeInstallation {
9
+ path: string;
10
+ version: string | null;
11
+ source: string;
12
+ }
13
+
14
+ /**
15
+ * Source preference score (lower = better).
16
+ * Mirrors opcode's source_preference() function.
17
+ */
18
+ function sourcePreference(source: string): number {
19
+ const order: Record<string, number> = {
20
+ "which": 1,
21
+ "homebrew": 2,
22
+ "system": 3,
23
+ "nvm-active": 4,
24
+ "local-bin": 6,
25
+ "claude-local": 7,
26
+ "npm-global": 8,
27
+ "yarn": 9,
28
+ "bun": 10,
29
+ "node-modules": 11,
30
+ "home-bin": 12,
31
+ "PATH": 13,
32
+ };
33
+ if (source.startsWith("nvm")) return 5;
34
+ return order[source] ?? 14;
35
+ }
36
+
37
+ /**
38
+ * Try `which claude` (Unix) to find the binary.
39
+ * Handles aliased output: "claude: aliased to /path/to/claude".
40
+ */
41
+ function tryWhichCommand(): ClaudeInstallation | null {
42
+ try {
43
+ const result = Bun.spawnSync(["which", "claude"], { stdout: "pipe", stderr: "pipe" });
44
+ if (result.exitCode !== 0) return null;
45
+
46
+ let output = result.stdout.toString().trim();
47
+ if (!output) return null;
48
+
49
+ // Parse aliased output
50
+ if (output.startsWith("claude:") && output.includes("aliased to")) {
51
+ output = output.split("aliased to")[1]?.trim() ?? "";
52
+ }
53
+ if (!output || !existsSync(output)) return null;
54
+
55
+ return { path: output, version: getClaudeVersion(output), source: "which" };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Find Claude installations in NVM directories.
63
+ * Checks NVM_BIN env var and all NVM node version bin directories.
64
+ */
65
+ function findNvmInstallations(): ClaudeInstallation[] {
66
+ const installations: ClaudeInstallation[] = [];
67
+ const home = process.env.HOME;
68
+ if (!home) return installations;
69
+
70
+ // Check NVM_BIN (current active NVM)
71
+ const nvmBin = process.env.NVM_BIN;
72
+ if (nvmBin) {
73
+ const claudePath = join(nvmBin, "claude");
74
+ if (existsSync(claudePath)) {
75
+ installations.push({
76
+ path: claudePath,
77
+ version: getClaudeVersion(claudePath),
78
+ source: "nvm-active",
79
+ });
80
+ }
81
+ }
82
+
83
+ // Scan all NVM node versions
84
+ const nvmDir = join(home, ".nvm", "versions", "node");
85
+ try {
86
+ for (const entry of readdirSync(nvmDir) as string[]) {
87
+ const claudePath = join(nvmDir, entry, "bin", "claude");
88
+ if (existsSync(claudePath) && statSync(claudePath).isFile()) {
89
+ installations.push({
90
+ path: claudePath,
91
+ version: getClaudeVersion(claudePath),
92
+ source: `nvm (${entry})`,
93
+ });
94
+ }
95
+ }
96
+ } catch {
97
+ // NVM dir doesn't exist — skip
98
+ }
99
+
100
+ return installations;
101
+ }
102
+
103
+ /**
104
+ * Check standard installation paths.
105
+ * Mirrors opcode's find_standard_installations().
106
+ */
107
+ function findStandardInstallations(): ClaudeInstallation[] {
108
+ const installations: ClaudeInstallation[] = [];
109
+ const home = process.env.HOME ?? "";
110
+
111
+ const pathsToCheck: [string, string][] = [
112
+ ["/usr/local/bin/claude", "system"],
113
+ ["/opt/homebrew/bin/claude", "homebrew"],
114
+ ["/usr/bin/claude", "system"],
115
+ [join(home, ".claude/local/claude"), "claude-local"],
116
+ [join(home, ".local/bin/claude"), "local-bin"],
117
+ [join(home, ".npm-global/bin/claude"), "npm-global"],
118
+ [join(home, ".yarn/bin/claude"), "yarn"],
119
+ [join(home, ".bun/bin/claude"), "bun"],
120
+ [join(home, "bin/claude"), "home-bin"],
121
+ [join(home, "node_modules/.bin/claude"), "node-modules"],
122
+ [join(home, ".config/yarn/global/node_modules/.bin/claude"), "yarn-global"],
123
+ ];
124
+
125
+ for (const [path, source] of pathsToCheck) {
126
+ if (!path || !existsSync(path)) continue;
127
+ installations.push({
128
+ path,
129
+ version: getClaudeVersion(path),
130
+ source,
131
+ });
132
+ }
133
+
134
+ return installations;
135
+ }
136
+
137
+ /**
138
+ * Get Claude version by running `<path> --version`.
139
+ * Extracts semver pattern from output.
140
+ */
141
+ function getClaudeVersion(binaryPath: string): string | null {
142
+ try {
143
+ const result = Bun.spawnSync([binaryPath, "--version"], {
144
+ stdout: "pipe",
145
+ stderr: "pipe",
146
+ timeout: 5000,
147
+ });
148
+ if (result.exitCode !== 0) return null;
149
+ const output = result.stdout.toString();
150
+ const match = output.match(/(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)/);
151
+ return match?.[1] ?? null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Compare two semver strings. Returns -1, 0, or 1.
159
+ */
160
+ function compareVersions(a: string, b: string): number {
161
+ const pa = a.split(".").map((s) => parseInt(s) || 0);
162
+ const pb = b.split(".").map((s) => parseInt(s) || 0);
163
+ const len = Math.max(pa.length, pb.length);
164
+ for (let i = 0; i < len; i++) {
165
+ const va = pa[i] ?? 0;
166
+ const vb = pb[i] ?? 0;
167
+ if (va > vb) return 1;
168
+ if (va < vb) return -1;
169
+ }
170
+ return 0;
171
+ }
172
+
173
+ /** Cached binary path — only discovered once per process */
174
+ let cachedBinaryPath: string | null = null;
175
+
176
+ /**
177
+ * Find the best Claude CLI binary on the system.
178
+ * Discovery chain (mirrors opcode):
179
+ * 1. which claude
180
+ * 2. NVM paths (active + all versions)
181
+ * 3. Standard paths (homebrew, system, npm, yarn, bun, etc.)
182
+ * Selects the installation with the highest version.
183
+ */
184
+ export function findClaudeBinary(): string {
185
+ if (cachedBinaryPath) return cachedBinaryPath;
186
+
187
+ const installations: ClaudeInstallation[] = [];
188
+
189
+ // 1. Try `which` command
190
+ const whichResult = tryWhichCommand();
191
+ if (whichResult) installations.push(whichResult);
192
+
193
+ // 2. Check NVM paths
194
+ installations.push(...findNvmInstallations());
195
+
196
+ // 3. Check standard paths
197
+ installations.push(...findStandardInstallations());
198
+
199
+ // Deduplicate by path
200
+ const seen = new Set<string>();
201
+ const unique = installations.filter((i) => {
202
+ if (seen.has(i.path)) return false;
203
+ seen.add(i.path);
204
+ return true;
205
+ });
206
+
207
+ if (unique.length === 0) {
208
+ throw new Error(
209
+ "Claude Code CLI not found. Install via: npm install -g @anthropic-ai/claude-code",
210
+ );
211
+ }
212
+
213
+ // Select best: highest version, then source preference
214
+ const best = unique.reduce((a, b) => {
215
+ if (a.version && b.version) {
216
+ const cmp = compareVersions(b.version, a.version);
217
+ if (cmp !== 0) return cmp > 0 ? b : a;
218
+ } else if (a.version && !b.version) return a;
219
+ else if (!a.version && b.version) return b;
220
+ return sourcePreference(a.source) <= sourcePreference(b.source) ? a : b;
221
+ });
222
+
223
+ cachedBinaryPath = best.path;
224
+ return best.path;
225
+ }
226
+
227
+ /** Reset cached binary path (for testing) */
228
+ export function resetBinaryCache(): void {
229
+ cachedBinaryPath = null;
230
+ }
231
+
232
+ /** Discover all installations (for UI display) */
233
+ export function discoverAllInstallations(): ClaudeInstallation[] {
234
+ const installations: ClaudeInstallation[] = [];
235
+ const whichResult = tryWhichCommand();
236
+ if (whichResult) installations.push(whichResult);
237
+ installations.push(...findNvmInstallations());
238
+ installations.push(...findStandardInstallations());
239
+
240
+ // Deduplicate and sort by version desc, then source preference
241
+ const seen = new Set<string>();
242
+ return installations
243
+ .filter((i) => {
244
+ if (seen.has(i.path)) return false;
245
+ seen.add(i.path);
246
+ return true;
247
+ })
248
+ .sort((a, b) => {
249
+ if (a.version && b.version) {
250
+ const cmp = compareVersions(b.version, a.version);
251
+ if (cmp !== 0) return cmp;
252
+ } else if (a.version && !b.version) return -1;
253
+ else if (!a.version && b.version) return 1;
254
+ return sourcePreference(a.source) - sourcePreference(b.source);
255
+ });
256
+ }
@@ -0,0 +1,413 @@
1
+ import type {
2
+ AIProvider,
3
+ Session,
4
+ SessionConfig,
5
+ SessionInfo,
6
+ ChatEvent,
7
+ ChatMessage,
8
+ } from "./provider.interface.ts";
9
+ import { findClaudeBinary } from "./claude-binary-finder.ts";
10
+ import { processRegistry } from "./claude-process-registry.ts";
11
+
12
+ /**
13
+ * Stream-JSON event types from Claude CLI.
14
+ * Each line of stdout is one complete JSON object.
15
+ * Mirrors opcode's line-by-line parsing approach.
16
+ */
17
+ interface CliSystemEvent {
18
+ type: "system";
19
+ subtype: "init" | string;
20
+ session_id?: string;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ interface CliAssistantEvent {
25
+ type: "assistant";
26
+ message: {
27
+ content: Array<
28
+ | { type: "text"; text: string }
29
+ | { type: "tool_use"; name: string; input: unknown; id?: string }
30
+ >;
31
+ };
32
+ }
33
+
34
+ interface CliResultEvent {
35
+ type: "result";
36
+ result?: string;
37
+ session_id?: string;
38
+ is_error?: boolean;
39
+ }
40
+
41
+ interface CliToolResultEvent {
42
+ type: "tool_result";
43
+ output?: unknown;
44
+ content?: unknown;
45
+ is_error?: boolean;
46
+ }
47
+
48
+ interface CliErrorEvent {
49
+ type: "error";
50
+ error?: string;
51
+ message?: string;
52
+ }
53
+
54
+ type CliEvent =
55
+ | CliSystemEvent
56
+ | CliAssistantEvent
57
+ | CliResultEvent
58
+ | CliToolResultEvent
59
+ | CliErrorEvent
60
+ | { type: string; [key: string]: unknown };
61
+
62
+ /**
63
+ * AI provider that spawns the `claude` CLI as a subprocess.
64
+ * Architecture mirrors opcode's Rust implementation:
65
+ * - Binary discovery chain (claude-binary-finder.ts)
66
+ * - Process registry for cancel/kill (claude-process-registry.ts)
67
+ * - Line-by-line stream-json parsing (no chunk accumulation)
68
+ * - Session ID extraction from init message
69
+ * - Continue/resume support via -c and --resume flags
70
+ */
71
+ export class ClaudeCodeCliProvider implements AIProvider {
72
+ id = "claude";
73
+ name = "Claude Code";
74
+
75
+ private sessions = new Map<string, Session>();
76
+ private messageHistory = new Map<string, ChatMessage[]>();
77
+ /** Maps our session ID → Claude CLI's real session ID (from init message) */
78
+ private cliSessionIds = new Map<string, string>();
79
+
80
+ async createSession(config: SessionConfig): Promise<Session> {
81
+ const id = crypto.randomUUID();
82
+ const session: Session = {
83
+ id,
84
+ providerId: this.id,
85
+ title: config.title ?? "New Chat",
86
+ projectName: config.projectName,
87
+ createdAt: new Date().toISOString(),
88
+ };
89
+ this.sessions.set(id, session);
90
+ this.messageHistory.set(id, []);
91
+ return session;
92
+ }
93
+
94
+ async resumeSession(sessionId: string): Promise<Session> {
95
+ const session = this.sessions.get(sessionId);
96
+ if (!session) throw new Error(`Session ${sessionId} not found`);
97
+ return session;
98
+ }
99
+
100
+ async listSessions(): Promise<SessionInfo[]> {
101
+ return Array.from(this.sessions.values()).map((s) => ({
102
+ id: s.id,
103
+ providerId: s.providerId,
104
+ title: s.title,
105
+ projectName: s.projectName,
106
+ createdAt: s.createdAt,
107
+ }));
108
+ }
109
+
110
+ async deleteSession(sessionId: string): Promise<void> {
111
+ // Kill running process if any
112
+ if (processRegistry.isRunning(sessionId)) {
113
+ await processRegistry.kill(sessionId);
114
+ }
115
+ this.sessions.delete(sessionId);
116
+ this.messageHistory.delete(sessionId);
117
+ this.cliSessionIds.delete(sessionId);
118
+ }
119
+
120
+ async *sendMessage(
121
+ sessionId: string,
122
+ message: string,
123
+ ): AsyncIterable<ChatEvent> {
124
+ const session = this.sessions.get(sessionId);
125
+ if (!session) {
126
+ yield { type: "error", message: "Session not found" };
127
+ return;
128
+ }
129
+
130
+ // Update title from first message
131
+ if (session.title === "New Chat") {
132
+ session.title = message.slice(0, 50) + (message.length > 50 ? "..." : "");
133
+ }
134
+
135
+ // Store user message
136
+ const history = this.messageHistory.get(sessionId) ?? [];
137
+ history.push({
138
+ id: crypto.randomUUID(),
139
+ role: "user",
140
+ content: message,
141
+ timestamp: new Date().toISOString(),
142
+ });
143
+
144
+ // Build CLI arguments (mirrors opcode's execute/continue/resume pattern)
145
+ const args = this.buildCliArgs(sessionId, message);
146
+
147
+ // Clean env — remove CLAUDECODE to avoid "nested session" error
148
+ const env = { ...process.env };
149
+ delete env.CLAUDECODE;
150
+
151
+ // Find binary via discovery chain
152
+ let binaryPath: string;
153
+ try {
154
+ binaryPath = findClaudeBinary();
155
+ } catch (e) {
156
+ yield { type: "error", message: (e as Error).message };
157
+ return;
158
+ }
159
+
160
+ // Spawn process
161
+ let proc: ReturnType<typeof Bun.spawn>;
162
+ try {
163
+ proc = Bun.spawn([binaryPath, ...args], {
164
+ stdout: "pipe",
165
+ stderr: "pipe",
166
+ env,
167
+ });
168
+ } catch (e) {
169
+ yield { type: "error", message: `Failed to spawn claude CLI: ${(e as Error).message}` };
170
+ return;
171
+ }
172
+
173
+ // Register in process registry (for cancel support)
174
+ processRegistry.register(sessionId, proc, { prompt: message });
175
+
176
+ let assistantContent = "";
177
+
178
+ try {
179
+ // Read stdout line-by-line (mirrors opcode's BufReader::lines())
180
+ yield* this.readStreamJsonLines(proc, sessionId, (text) => {
181
+ assistantContent += text;
182
+ });
183
+
184
+ // Check stderr for errors after stdout is done
185
+ yield* this.readStderrErrors(proc);
186
+ } catch (e) {
187
+ yield { type: "error", message: `Stream error: ${(e as Error).message}` };
188
+ } finally {
189
+ // Unregister from process registry
190
+ processRegistry.unregister(sessionId);
191
+ }
192
+
193
+ // Store assistant message
194
+ history.push({
195
+ id: crypto.randomUUID(),
196
+ role: "assistant",
197
+ content: assistantContent,
198
+ timestamp: new Date().toISOString(),
199
+ });
200
+ this.messageHistory.set(sessionId, history);
201
+
202
+ yield { type: "done", sessionId };
203
+ }
204
+
205
+ /** Cancel a running session */
206
+ async cancelSession(sessionId: string): Promise<boolean> {
207
+ return processRegistry.kill(sessionId);
208
+ }
209
+
210
+ getMessages(sessionId: string): ChatMessage[] {
211
+ return this.messageHistory.get(sessionId) ?? [];
212
+ }
213
+
214
+ /**
215
+ * Build CLI arguments based on session state.
216
+ * Mirrors opcode's execute_claude_code / continue_claude_code / resume_claude_code.
217
+ */
218
+ private buildCliArgs(sessionId: string, message: string): string[] {
219
+ const cliSessionId = this.cliSessionIds.get(sessionId);
220
+ const history = this.messageHistory.get(sessionId) ?? [];
221
+ const userMessageCount = history.filter((m) => m.role === "user").length;
222
+ const isFirstMessage = userMessageCount <= 1; // Current message already pushed
223
+
224
+ const args: string[] = [];
225
+
226
+ if (!isFirstMessage && cliSessionId) {
227
+ // Resume existing CLI session (like opcode's resume_claude_code)
228
+ args.push("--resume", cliSessionId);
229
+ } else if (!isFirstMessage) {
230
+ // Continue most recent session (like opcode's continue_claude_code)
231
+ args.push("-c");
232
+ }
233
+
234
+ args.push(
235
+ "-p", message,
236
+ "--output-format", "stream-json",
237
+ "--verbose",
238
+ );
239
+
240
+ return args;
241
+ }
242
+
243
+ /**
244
+ * Read stdout line-by-line and yield ChatEvents.
245
+ * Each line is a complete JSON object — no chunk accumulation needed.
246
+ * Mirrors opcode's spawn_claude_process stdout_task.
247
+ */
248
+ private async *readStreamJsonLines(
249
+ proc: ReturnType<typeof Bun.spawn>,
250
+ sessionId: string,
251
+ onText: (text: string) => void,
252
+ ): AsyncGenerator<ChatEvent> {
253
+ const stdout = proc.stdout as ReadableStream<Uint8Array> | undefined;
254
+ if (!stdout) {
255
+ yield { type: "error", message: "Failed to get stdout from claude CLI" };
256
+ return;
257
+ }
258
+
259
+ const reader = stdout.getReader();
260
+ const decoder = new TextDecoder();
261
+ let buffer = "";
262
+
263
+ while (true) {
264
+ const { done, value } = await reader.read();
265
+ if (done) break;
266
+
267
+ buffer += decoder.decode(value, { stream: true });
268
+
269
+ // Split into complete lines
270
+ const lines = buffer.split("\n");
271
+ // Keep last partial line in buffer
272
+ buffer = lines.pop() ?? "";
273
+
274
+ for (const line of lines) {
275
+ const trimmed = line.trim();
276
+ if (!trimmed) continue;
277
+
278
+ // Parse JSON — each line is one complete event
279
+ let event: CliEvent;
280
+ try {
281
+ event = JSON.parse(trimmed) as CliEvent;
282
+ } catch {
283
+ continue; // Skip non-JSON lines (e.g. progress indicators)
284
+ }
285
+
286
+ // Process the event — may yield multiple ChatEvents per CLI event
287
+ for (const chatEvent of this.mapCliEvent(event, sessionId, onText)) {
288
+ yield chatEvent;
289
+ }
290
+ }
291
+ }
292
+
293
+ // Process remaining buffer (last line without trailing newline)
294
+ if (buffer.trim()) {
295
+ try {
296
+ const event = JSON.parse(buffer.trim()) as CliEvent;
297
+ for (const chatEvent of this.mapCliEvent(event, sessionId, onText)) {
298
+ yield chatEvent;
299
+ }
300
+ } catch {
301
+ // Ignore incomplete trailing data
302
+ }
303
+ }
304
+ }
305
+
306
+ /** Read stderr and yield error events */
307
+ private async *readStderrErrors(
308
+ proc: ReturnType<typeof Bun.spawn>,
309
+ ): AsyncGenerator<ChatEvent> {
310
+ const stderr = proc.stderr as ReadableStream<Uint8Array> | undefined;
311
+ if (!stderr) return;
312
+
313
+ try {
314
+ const reader = stderr.getReader();
315
+ const decoder = new TextDecoder();
316
+ let stderrContent = "";
317
+
318
+ while (true) {
319
+ const { done, value } = await reader.read();
320
+ if (done) break;
321
+ stderrContent += decoder.decode(value, { stream: true });
322
+ }
323
+
324
+ // Only yield if there's meaningful stderr content
325
+ const trimmed = stderrContent.trim();
326
+ if (trimmed && !trimmed.includes("ExperimentalWarning")) {
327
+ yield { type: "error", message: trimmed };
328
+ }
329
+ } catch {
330
+ // Stderr read failure is non-fatal
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Map a single CLI stream-json event to a ChatEvent.
336
+ * Mirrors opcode's event handling in stdout_task.
337
+ *
338
+ * CLI output format (one JSON per line):
339
+ * {type:"system", subtype:"init", session_id:"..."} — extract session ID
340
+ * {type:"assistant", message:{content:[...]}} — text and tool_use blocks
341
+ * {type:"result", result:"...", session_id:"..."} — final result (ignored to avoid duplication)
342
+ * {type:"tool_result", output:"..."} — tool execution result
343
+ * {type:"error", error:"..."} — error event
344
+ * {type:"rate_limit_event"} — ignored
345
+ */
346
+ /**
347
+ * Map a single CLI stream-json event to ChatEvent(s).
348
+ * Returns an array because one `assistant` event can contain
349
+ * multiple content blocks (text + tool_use interleaved).
350
+ */
351
+ private mapCliEvent(
352
+ event: CliEvent,
353
+ sessionId: string,
354
+ onText: (text: string) => void,
355
+ ): ChatEvent[] {
356
+ switch (event.type) {
357
+ case "system": {
358
+ const sysEvent = event as CliSystemEvent;
359
+ if (sysEvent.subtype === "init" && sysEvent.session_id) {
360
+ this.cliSessionIds.set(sessionId, sysEvent.session_id);
361
+ }
362
+ return [];
363
+ }
364
+
365
+ case "assistant": {
366
+ const assistantEvent = event as CliAssistantEvent;
367
+ const content = assistantEvent.message?.content;
368
+ if (!Array.isArray(content)) return [];
369
+
370
+ // Yield ALL blocks in order — text and tool_use interleaved
371
+ const events: ChatEvent[] = [];
372
+ for (const block of content) {
373
+ if (block.type === "text" && typeof block.text === "string") {
374
+ onText(block.text);
375
+ events.push({ type: "text", content: block.text });
376
+ } else if (block.type === "tool_use") {
377
+ events.push({
378
+ type: "tool_use",
379
+ tool: block.name ?? "unknown",
380
+ input: block.input ?? {},
381
+ toolUseId: block.id as string | undefined,
382
+ });
383
+ }
384
+ }
385
+ return events;
386
+ }
387
+
388
+ case "result":
389
+ return [];
390
+
391
+ case "tool_result": {
392
+ const trEvent = event as CliToolResultEvent;
393
+ const output = trEvent.output ?? trEvent.content ?? "";
394
+ return [{
395
+ type: "tool_result",
396
+ output: typeof output === "string" ? output : JSON.stringify(output),
397
+ isError: !!trEvent.is_error,
398
+ }];
399
+ }
400
+
401
+ case "error": {
402
+ const errEvent = event as CliErrorEvent;
403
+ return [{
404
+ type: "error",
405
+ message: errEvent.error ?? errEvent.message ?? "Unknown CLI error",
406
+ }];
407
+ }
408
+
409
+ default:
410
+ return [];
411
+ }
412
+ }
413
+ }