@towles/tool 0.0.20 → 0.0.48

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 (45) hide show
  1. package/{LICENSE.md → LICENSE} +1 -1
  2. package/README.md +86 -85
  3. package/bin/run.ts +5 -0
  4. package/package.json +84 -64
  5. package/patches/prompts.patch +34 -0
  6. package/src/commands/base.ts +27 -0
  7. package/src/commands/config.test.ts +15 -0
  8. package/src/commands/config.ts +44 -0
  9. package/src/commands/doctor.ts +136 -0
  10. package/src/commands/gh/branch-clean.ts +116 -0
  11. package/src/commands/gh/branch.test.ts +124 -0
  12. package/src/commands/gh/branch.ts +135 -0
  13. package/src/commands/gh/pr.ts +175 -0
  14. package/src/commands/graph-template.html +1214 -0
  15. package/src/commands/graph.test.ts +176 -0
  16. package/src/commands/graph.ts +970 -0
  17. package/src/commands/install.ts +154 -0
  18. package/src/commands/journal/daily-notes.ts +70 -0
  19. package/src/commands/journal/meeting.ts +89 -0
  20. package/src/commands/journal/note.ts +89 -0
  21. package/src/commands/ralph/plan/add.ts +75 -0
  22. package/src/commands/ralph/plan/done.ts +82 -0
  23. package/src/commands/ralph/plan/list.test.ts +48 -0
  24. package/src/commands/ralph/plan/list.ts +99 -0
  25. package/src/commands/ralph/plan/remove.ts +71 -0
  26. package/src/commands/ralph/run.test.ts +521 -0
  27. package/src/commands/ralph/run.ts +345 -0
  28. package/src/commands/ralph/show.ts +88 -0
  29. package/src/config/settings.ts +136 -0
  30. package/src/lib/journal/utils.ts +399 -0
  31. package/src/lib/ralph/execution.ts +292 -0
  32. package/src/lib/ralph/formatter.ts +238 -0
  33. package/src/lib/ralph/index.ts +4 -0
  34. package/src/lib/ralph/state.ts +166 -0
  35. package/src/types/journal.ts +16 -0
  36. package/src/utils/date-utils.test.ts +97 -0
  37. package/src/utils/date-utils.ts +54 -0
  38. package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
  39. package/src/utils/git/gh-cli-wrapper.ts +54 -0
  40. package/src/utils/git/git-wrapper.test.ts +26 -0
  41. package/src/utils/git/git-wrapper.ts +15 -0
  42. package/src/utils/render.test.ts +71 -0
  43. package/src/utils/render.ts +34 -0
  44. package/dist/index.d.mts +0 -1
  45. package/dist/index.mjs +0 -805
@@ -0,0 +1,399 @@
1
+ import { exec } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import consola from "consola";
6
+ import { colors } from "consola/utils";
7
+ import { DateTime } from "luxon";
8
+ import { formatDate, getMondayOfWeek, getWeekInfo } from "../../utils/date-utils.js";
9
+ import type { JournalSettings } from "../../config/settings.js";
10
+ import { JOURNAL_TYPES } from "../../types/journal.js";
11
+ import type { JournalType } from "../../types/journal.js";
12
+
13
+ // Default template file names
14
+ const TEMPLATE_FILES = {
15
+ dailyNotes: "daily-notes.md",
16
+ meeting: "meeting.md",
17
+ note: "note.md",
18
+ } as const;
19
+
20
+ const execAsync = promisify(exec);
21
+
22
+ /**
23
+ * Create journal directory if it doesn't exist
24
+ */
25
+ export function ensureDirectoryExists(folderPath: string): void {
26
+ if (!existsSync(folderPath)) {
27
+ consola.info(`Creating journal directory: ${colors.cyan(folderPath)}`);
28
+ mkdirSync(folderPath, { recursive: true });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Load template from external file or return null if not found
34
+ */
35
+ export function loadTemplate(templateDir: string, templateFile: string): string | null {
36
+ const templatePath = path.join(templateDir, templateFile);
37
+ if (existsSync(templatePath)) {
38
+ return readFileSync(templatePath, "utf8");
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Get default template content for initial setup
45
+ */
46
+ function getDefaultDailyNotesTemplate(): string {
47
+ return `# Journal for Week {monday:yyyy-MM-dd}
48
+
49
+ ## {monday:yyyy-MM-dd} Monday
50
+
51
+ ## {tuesday:yyyy-MM-dd} Tuesday
52
+
53
+ ## {wednesday:yyyy-MM-dd} Wednesday
54
+
55
+ ## {thursday:yyyy-MM-dd} Thursday
56
+
57
+ ## {friday:yyyy-MM-dd} Friday
58
+ `;
59
+ }
60
+
61
+ function getDefaultMeetingTemplate(): string {
62
+ return `# Meeting: {title}
63
+
64
+ **Date:** {date}
65
+ **Time:** {time}
66
+ **Attendees:**
67
+
68
+ ## Agenda
69
+
70
+ -
71
+
72
+ ## Notes
73
+
74
+ ## Action Items
75
+
76
+ - [ ]
77
+
78
+ ## Follow-up
79
+ `;
80
+ }
81
+
82
+ function getDefaultNoteTemplate(): string {
83
+ return `# {title}
84
+
85
+ **Created:** {date} {time}
86
+
87
+ ## Summary
88
+
89
+ ## Details
90
+
91
+ ## References
92
+ `;
93
+ }
94
+
95
+ /**
96
+ * Initialize template directory with default templates (first run)
97
+ */
98
+ export function ensureTemplatesExist(templateDir: string): void {
99
+ ensureDirectoryExists(templateDir);
100
+
101
+ const templates = [
102
+ { file: TEMPLATE_FILES.dailyNotes, content: getDefaultDailyNotesTemplate() },
103
+ { file: TEMPLATE_FILES.meeting, content: getDefaultMeetingTemplate() },
104
+ { file: TEMPLATE_FILES.note, content: getDefaultNoteTemplate() },
105
+ ];
106
+
107
+ for (const { file, content } of templates) {
108
+ const templatePath = path.join(templateDir, file);
109
+ if (!existsSync(templatePath)) {
110
+ writeFileSync(templatePath, content, "utf8");
111
+ consola.info(`Created default template: ${colors.cyan(templatePath)}`);
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Render template with variables
118
+ */
119
+ function renderTemplate(template: string, vars: Record<string, string>): string {
120
+ return template.replace(/\{([^}]+)\}/g, (match, key) => {
121
+ return vars[key] ?? match;
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Create initial journal content with date header
127
+ */
128
+ export function createJournalContent({
129
+ mondayDate,
130
+ templateDir,
131
+ }: {
132
+ mondayDate: Date;
133
+ templateDir?: string;
134
+ }): string {
135
+ const weekInfo = getWeekInfo(mondayDate);
136
+
137
+ // Try external template first
138
+ if (templateDir) {
139
+ const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.dailyNotes);
140
+ if (externalTemplate) {
141
+ return renderTemplate(externalTemplate, {
142
+ "monday:yyyy-MM-dd": formatDate(weekInfo.mondayDate),
143
+ "tuesday:yyyy-MM-dd": formatDate(weekInfo.tuesdayDate),
144
+ "wednesday:yyyy-MM-dd": formatDate(weekInfo.wednesdayDate),
145
+ "thursday:yyyy-MM-dd": formatDate(weekInfo.thursdayDate),
146
+ "friday:yyyy-MM-dd": formatDate(weekInfo.fridayDate),
147
+ });
148
+ }
149
+ }
150
+
151
+ // Fallback to hardcoded template
152
+ const content = [`# Journal for Week ${formatDate(mondayDate)}`];
153
+ content.push(``);
154
+ content.push(`## ${formatDate(weekInfo.mondayDate)} Monday`);
155
+ content.push(``);
156
+ content.push(`## ${formatDate(weekInfo.tuesdayDate)} Tuesday`);
157
+ content.push(``);
158
+ content.push(`## ${formatDate(weekInfo.wednesdayDate)} Wednesday`);
159
+ content.push(``);
160
+ content.push(`## ${formatDate(weekInfo.thursdayDate)} Thursday`);
161
+ content.push(``);
162
+ content.push(`## ${formatDate(weekInfo.fridayDate)} Friday`);
163
+ content.push(``);
164
+
165
+ return content.join("\n");
166
+ }
167
+
168
+ /**
169
+ * Create meeting template content
170
+ */
171
+ export function createMeetingContent({
172
+ title,
173
+ date,
174
+ templateDir,
175
+ }: {
176
+ title?: string;
177
+ date: Date;
178
+ templateDir?: string;
179
+ }): string {
180
+ const dateStr = formatDate(date);
181
+ const timeStr = date.toLocaleTimeString("en-US", {
182
+ hour12: false,
183
+ hour: "2-digit",
184
+ minute: "2-digit",
185
+ });
186
+ const meetingTitle = title || "Meeting";
187
+
188
+ // Try external template first
189
+ if (templateDir) {
190
+ const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.meeting);
191
+ if (externalTemplate) {
192
+ return renderTemplate(externalTemplate, {
193
+ title: meetingTitle,
194
+ date: dateStr,
195
+ time: timeStr,
196
+ });
197
+ }
198
+ }
199
+
200
+ // Fallback to hardcoded template
201
+ const content = [`# Meeting: ${meetingTitle}`];
202
+ content.push(``);
203
+ content.push(`**Date:** ${dateStr}`);
204
+ content.push(`**Time:** ${timeStr}`);
205
+ content.push(`**Attendees:** `);
206
+ content.push(``);
207
+ content.push(`## Agenda`);
208
+ content.push(``);
209
+ content.push(`- `);
210
+ content.push(``);
211
+ content.push(`## Notes`);
212
+ content.push(``);
213
+ content.push(`## Action Items`);
214
+ content.push(``);
215
+ content.push(`- [ ] `);
216
+ content.push(``);
217
+ content.push(`## Follow-up`);
218
+ content.push(``);
219
+
220
+ return content.join("\n");
221
+ }
222
+
223
+ /**
224
+ * Create note template content
225
+ */
226
+ export function createNoteContent({
227
+ title,
228
+ date,
229
+ templateDir,
230
+ }: {
231
+ title?: string;
232
+ date: Date;
233
+ templateDir?: string;
234
+ }): string {
235
+ const dateStr = formatDate(date);
236
+ const timeStr = date.toLocaleTimeString("en-US", {
237
+ hour12: false,
238
+ hour: "2-digit",
239
+ minute: "2-digit",
240
+ });
241
+ const noteTitle = title || "Note";
242
+
243
+ // Try external template first
244
+ if (templateDir) {
245
+ const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.note);
246
+ if (externalTemplate) {
247
+ return renderTemplate(externalTemplate, {
248
+ title: noteTitle,
249
+ date: dateStr,
250
+ time: timeStr,
251
+ });
252
+ }
253
+ }
254
+
255
+ // Fallback to hardcoded template
256
+ const content = [`# ${noteTitle}`];
257
+ content.push(``);
258
+ content.push(`**Created:** ${dateStr} ${timeStr}`);
259
+ content.push(``);
260
+ content.push(`## Summary`);
261
+ content.push(``);
262
+ content.push(`## Details`);
263
+ content.push(``);
264
+ content.push(`## References`);
265
+ content.push(``);
266
+
267
+ return content.join("\n");
268
+ }
269
+
270
+ /**
271
+ * Open file in default editor with folder context
272
+ */
273
+ export async function openInEditor({
274
+ editor,
275
+ filePath,
276
+ folderPath,
277
+ }: {
278
+ editor: string;
279
+ filePath: string;
280
+ folderPath?: string;
281
+ }): Promise<void> {
282
+ try {
283
+ if (folderPath) {
284
+ // Open both folder and file - this works with VS Code and similar editors
285
+ // the purpose is to open the folder context for better navigation
286
+ await execAsync(`"${editor}" "${folderPath}" "${filePath}"`);
287
+ } else {
288
+ await execAsync(`"${editor}" "${filePath}"`);
289
+ }
290
+ } catch (ex) {
291
+ consola.warn(
292
+ `Could not open in editor : '${editor}'. Modify your editor in the config: examples include 'code', 'code-insiders', etc...`,
293
+ ex,
294
+ );
295
+ }
296
+ }
297
+
298
+ export function resolvePathTemplate(
299
+ template: string,
300
+ title: string,
301
+ date: Date,
302
+ mondayDate: Date,
303
+ ): string {
304
+ const dateTime = DateTime.fromJSDate(date, { zone: "utc" });
305
+
306
+ // Replace Luxon format tokens wrapped in curly braces
307
+ return template.replace(/\{([^}]+)\}/g, (match, token) => {
308
+ try {
309
+ if (token === "title") {
310
+ return title.toLowerCase().replace(/\s+/g, "-");
311
+ }
312
+
313
+ if (token.startsWith("monday:")) {
314
+ const mondayToken = token.substring(7); // Remove 'monday:' prefix
315
+ const mondayDateTime = DateTime.fromJSDate(mondayDate, { zone: "utc" });
316
+ return mondayDateTime.toFormat(mondayToken);
317
+ }
318
+
319
+ const result = dateTime.toFormat(token);
320
+ // Check if the result contains suspicious patterns that indicate invalid tokens
321
+ // This is a heuristic to detect when Luxon produces garbage output for invalid tokens
322
+ const isLikelyInvalid =
323
+ token.includes("invalid") ||
324
+ result.length > 20 || // Very long results are likely garbage
325
+ (result.length > token.length * 2 && /\d{10,}/.test(result)) || // Contains very long numbers
326
+ result.includes("UTC");
327
+
328
+ if (isLikelyInvalid) {
329
+ consola.warn(`Invalid date format token: ${token}`);
330
+ return match;
331
+ }
332
+ return result;
333
+ } catch (error) {
334
+ consola.warn(`Invalid date format token: ${token}`);
335
+ return match; // Return original token if format is invalid
336
+ }
337
+ });
338
+ }
339
+
340
+ interface GenerateJournalFileResult {
341
+ fullPath: string;
342
+ mondayDate: Date;
343
+ currentDate: Date;
344
+ }
345
+
346
+ interface GenerateJournalFileParams {
347
+ date: Date;
348
+ type: JournalType;
349
+ title: string;
350
+ journalSettings: JournalSettings;
351
+ }
352
+
353
+ /**
354
+ * Generate journal file info for different types using individual path templates
355
+ */
356
+ export function generateJournalFileInfoByType({
357
+ journalSettings,
358
+ date = new Date(),
359
+ type,
360
+ title,
361
+ }: GenerateJournalFileParams): GenerateJournalFileResult {
362
+ const currentDate = new Date(date);
363
+
364
+ let templatePath: string = "";
365
+ let mondayDate: Date = getMondayOfWeek(currentDate);
366
+
367
+ switch (type) {
368
+ case JOURNAL_TYPES.DAILY_NOTES: {
369
+ const monday = getMondayOfWeek(currentDate);
370
+ templatePath = journalSettings.dailyPathTemplate;
371
+ mondayDate = monday;
372
+ break;
373
+ }
374
+ case JOURNAL_TYPES.MEETING: {
375
+ templatePath = journalSettings.meetingPathTemplate;
376
+ mondayDate = currentDate;
377
+ break;
378
+ }
379
+ case JOURNAL_TYPES.NOTE: {
380
+ templatePath = journalSettings.notePathTemplate;
381
+ mondayDate = currentDate;
382
+ break;
383
+ }
384
+ default:
385
+ throw new Error(`Unknown JournalType: ${type}`);
386
+ }
387
+
388
+ // Resolve the path template and extract directory structure
389
+ const resolvedPath = resolvePathTemplate(templatePath, title, currentDate, mondayDate);
390
+
391
+ // Join baseFolder with the resolved path
392
+ const fullPath = path.join(journalSettings.baseFolder, resolvedPath);
393
+
394
+ return {
395
+ currentDate: currentDate,
396
+ fullPath: fullPath,
397
+ mondayDate,
398
+ } satisfies GenerateJournalFileResult;
399
+ }
@@ -0,0 +1,292 @@
1
+ import type { WriteStream } from "node:fs";
2
+ // NOTE: We use spawn instead of tinyexec for runIteration because we need
3
+ // real-time streaming of stdout/stderr. tinyexec waits for command completion
4
+ // before returning output, which doesn't work for long-running claude sessions.
5
+ import { spawn } from "node:child_process";
6
+ import pc from "picocolors";
7
+ import { x } from "tinyexec";
8
+ import { CLAUDE_DEFAULT_ARGS } from "./state.js";
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ interface StreamEvent {
15
+ type: string;
16
+ message?: {
17
+ content?: Array<{ type: string; text?: string }>;
18
+ usage?: {
19
+ input_tokens?: number;
20
+ output_tokens?: number;
21
+ cache_read_input_tokens?: number;
22
+ cache_creation_input_tokens?: number;
23
+ };
24
+ };
25
+ result?: string;
26
+ total_cost_usd?: number;
27
+ num_turns?: number;
28
+ session_id?: string;
29
+ usage?: {
30
+ input_tokens?: number;
31
+ output_tokens?: number;
32
+ cache_read_input_tokens?: number;
33
+ cache_creation_input_tokens?: number;
34
+ };
35
+ }
36
+
37
+ // Claude model context windows (tokens)
38
+ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
39
+ "claude-sonnet-4-20250514": 200000,
40
+ "claude-opus-4-20250514": 200000,
41
+ "claude-3-5-sonnet-20241022": 200000,
42
+ "claude-3-opus-20240229": 200000,
43
+ default: 200000,
44
+ };
45
+
46
+ export interface IterationResult {
47
+ output: string;
48
+ exitCode: number;
49
+ contextUsedPercent?: number;
50
+ sessionId?: string;
51
+ }
52
+
53
+ interface ParsedLine {
54
+ text: string | null;
55
+ tool?: { name: string; summary: string };
56
+ usage?: StreamEvent["usage"];
57
+ sessionId?: string;
58
+ }
59
+
60
+ // ============================================================================
61
+ // Claude CLI Check
62
+ // ============================================================================
63
+
64
+ export async function checkClaudeCli(): Promise<boolean> {
65
+ try {
66
+ const result = await x("which", ["claude"]);
67
+ return result.exitCode === 0;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ // ============================================================================
74
+ // Stream Parsing
75
+ // ============================================================================
76
+
77
+ // Track accumulated text from assistant messages to compute deltas
78
+ let lastAssistantText = "";
79
+
80
+ /**
81
+ * Reset stream parsing state between iterations.
82
+ */
83
+ export function resetStreamState(): void {
84
+ lastAssistantText = "";
85
+ }
86
+
87
+ function summarizeTool(name: string, input: Record<string, unknown>): string {
88
+ switch (name) {
89
+ case "Read":
90
+ return (
91
+ String(input.file_path || input.path || "")
92
+ .split("/")
93
+ .pop() || "file"
94
+ );
95
+ case "Write":
96
+ case "Edit":
97
+ return (
98
+ String(input.file_path || input.path || "")
99
+ .split("/")
100
+ .pop() || "file"
101
+ );
102
+ case "Glob":
103
+ return String(input.pattern || "");
104
+ case "Grep":
105
+ return String(input.pattern || "");
106
+ case "Bash":
107
+ return String(input.command || "").substring(0, 40);
108
+ case "TodoWrite":
109
+ return "updating todos";
110
+ default:
111
+ return Object.values(input)[0]?.toString().substring(0, 30) || "";
112
+ }
113
+ }
114
+
115
+ function parseStreamLine(line: string): ParsedLine {
116
+ if (!line.trim()) return { text: null };
117
+ try {
118
+ const data = JSON.parse(line) as StreamEvent & {
119
+ tool_use?: { name: string; input: Record<string, unknown> };
120
+ content_block?: { type: string; name?: string; input?: Record<string, unknown> };
121
+ };
122
+
123
+ // Handle tool_use events
124
+ if (data.type === "tool_use" && data.tool_use) {
125
+ const name = data.tool_use.name;
126
+ const summary = summarizeTool(name, data.tool_use.input || {});
127
+ return { text: null, tool: { name, summary } };
128
+ }
129
+
130
+ // Handle content_block with tool_use (streaming format)
131
+ if (data.type === "content_block" && data.content_block?.type === "tool_use") {
132
+ const name = data.content_block.name || "Tool";
133
+ const summary = summarizeTool(name, data.content_block.input || {});
134
+ return { text: null, tool: { name, summary } };
135
+ }
136
+
137
+ // Handle assistant messages with content array
138
+ if (data.type === "assistant" && data.message) {
139
+ // Check for tool_use in content blocks
140
+ const toolBlocks = data.message.content?.filter((c) => c.type === "tool_use") || [];
141
+ if (toolBlocks.length > 0) {
142
+ const tb = toolBlocks[toolBlocks.length - 1] as {
143
+ name?: string;
144
+ input?: Record<string, unknown>;
145
+ };
146
+ const name = tb.name || "Tool";
147
+ const summary = summarizeTool(name, tb.input || {});
148
+ return {
149
+ text: null,
150
+ tool: { name, summary },
151
+ usage: data.message.usage || data.usage,
152
+ sessionId: data.session_id,
153
+ };
154
+ }
155
+
156
+ // Extract full text from content blocks
157
+ const fullText =
158
+ data.message.content
159
+ ?.filter((c) => c.type === "text" && c.text)
160
+ .map((c) => c.text)
161
+ .join("") || "";
162
+
163
+ // Compute delta (only new portion) to avoid duplicate output
164
+ let delta: string | null = null;
165
+ if (fullText.startsWith(lastAssistantText)) {
166
+ delta = fullText.slice(lastAssistantText.length) || null;
167
+ } else {
168
+ // Text doesn't match prefix - new context
169
+ delta = fullText || null;
170
+ }
171
+ lastAssistantText = fullText;
172
+
173
+ return { text: delta, usage: data.message.usage || data.usage, sessionId: data.session_id };
174
+ }
175
+ // Capture final result with usage and session_id
176
+ if (data.type === "result") {
177
+ const resultText = data.result
178
+ ? `\n[Result: ${data.result.substring(0, 100)}${data.result.length > 100 ? "..." : ""}]\n`
179
+ : null;
180
+ return { text: resultText, usage: data.usage, sessionId: data.session_id };
181
+ }
182
+ } catch {
183
+ // Not JSON, return raw
184
+ return { text: line };
185
+ }
186
+ return { text: null };
187
+ }
188
+
189
+ // ============================================================================
190
+ // Run Iteration
191
+ // ============================================================================
192
+
193
+ export async function runIteration(
194
+ prompt: string,
195
+ claudeArgs: string[],
196
+ logStream?: WriteStream,
197
+ ): Promise<IterationResult> {
198
+ // Reset accumulated text state from previous iteration
199
+ resetStreamState();
200
+
201
+ // Pass task context as system prompt via --append-system-prompt
202
+ // 'continue' is the user prompt - required by claude CLI when using --print
203
+ const allArgs = [
204
+ ...CLAUDE_DEFAULT_ARGS,
205
+ ...claudeArgs,
206
+ "--append-system-prompt",
207
+ prompt,
208
+ "continue",
209
+ ];
210
+
211
+ let output = "";
212
+ let lineBuffer = "";
213
+ let finalUsage: StreamEvent["usage"] | undefined;
214
+ let sessionId: string | undefined;
215
+ let lastCharWasNewline = true;
216
+
217
+ const processLine = (line: string) => {
218
+ const { text: parsed, tool, usage, sessionId: sid } = parseStreamLine(line);
219
+ if (usage) finalUsage = usage;
220
+ if (sid) sessionId = sid;
221
+ if (tool) {
222
+ const prefix = lastCharWasNewline ? "" : "\n";
223
+ const toolLine = `${prefix}${pc.yellow("⚡")} ${pc.cyan(tool.name)}: ${tool.summary}\n`;
224
+ process.stdout.write(toolLine);
225
+ logStream?.write(`${prefix}⚡ ${tool.name}: ${tool.summary}\n`);
226
+ lastCharWasNewline = true;
227
+ }
228
+ if (parsed) {
229
+ process.stdout.write(parsed);
230
+ logStream?.write(parsed);
231
+ output += parsed;
232
+ lastCharWasNewline = parsed.endsWith("\n");
233
+ }
234
+ };
235
+
236
+ return new Promise((resolve) => {
237
+ const proc = spawn("claude", allArgs, {
238
+ stdio: ["inherit", "pipe", "pipe"],
239
+ });
240
+
241
+ proc.stdout.on("data", (chunk: Buffer) => {
242
+ const text = chunk.toString();
243
+ lineBuffer += text;
244
+
245
+ const lines = lineBuffer.split("\n");
246
+ lineBuffer = lines.pop() || "";
247
+
248
+ for (const line of lines) {
249
+ processLine(line);
250
+ }
251
+ });
252
+
253
+ proc.stderr.on("data", (chunk: Buffer) => {
254
+ const text = chunk.toString();
255
+ process.stderr.write(text);
256
+ logStream?.write(text);
257
+ output += text;
258
+ });
259
+
260
+ proc.on("close", (code: number | null) => {
261
+ if (lineBuffer) {
262
+ processLine(lineBuffer);
263
+ }
264
+
265
+ if (output && !output.endsWith("\n")) {
266
+ process.stdout.write("\n");
267
+ logStream?.write("\n");
268
+ output += "\n";
269
+ }
270
+
271
+ // Calculate context usage percent
272
+ let contextUsedPercent: number | undefined;
273
+ if (finalUsage) {
274
+ const totalTokens =
275
+ (finalUsage.input_tokens || 0) +
276
+ (finalUsage.output_tokens || 0) +
277
+ (finalUsage.cache_read_input_tokens || 0) +
278
+ (finalUsage.cache_creation_input_tokens || 0);
279
+ const maxContext = MODEL_CONTEXT_WINDOWS.default;
280
+ contextUsedPercent = Math.round((totalTokens / maxContext) * 100);
281
+ }
282
+
283
+ resolve({ output, exitCode: code ?? 0, contextUsedPercent, sessionId });
284
+ });
285
+
286
+ proc.on("error", (err: Error) => {
287
+ console.error(pc.red(`Error running claude: ${err}`));
288
+ logStream?.write(`Error running claude: ${err}\n`);
289
+ resolve({ output, exitCode: 1 });
290
+ });
291
+ });
292
+ }