@dexh/shannon 0.0.1

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/index.ts ADDED
@@ -0,0 +1,1056 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { randomUUID } from "node:crypto";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { Command } from "commander";
6
+
7
+ type OutputFormat = "stream-json" | "json" | "text";
8
+
9
+ type CliOptions = {
10
+ prompt?: string;
11
+ inputFormat: "text" | "stream-json";
12
+ outputFormat: OutputFormat;
13
+ verbose: boolean;
14
+ replayUserMessages: boolean;
15
+ cwd: string;
16
+ claudeArgs: string[];
17
+ };
18
+
19
+ type JsonRecord = Record<string, unknown>;
20
+
21
+ type TranscriptRow = JsonRecord & {
22
+ type?: string;
23
+ subtype?: string;
24
+ sessionId?: string;
25
+ session_id?: string;
26
+ cwd?: string;
27
+ uuid?: string;
28
+ timestamp?: string;
29
+ message?: {
30
+ role?: string;
31
+ content?: unknown;
32
+ usage?: JsonRecord;
33
+ model?: string;
34
+ stop_reason?: string | null;
35
+ };
36
+ attachment?: {
37
+ type?: string;
38
+ hookName?: string;
39
+ toolUseID?: string;
40
+ hookEvent?: string;
41
+ content?: string;
42
+ stdout?: string;
43
+ stderr?: string;
44
+ exitCode?: number;
45
+ addedNames?: unknown;
46
+ removedNames?: unknown;
47
+ };
48
+ };
49
+
50
+ type SessionMetadata = {
51
+ sessionId: string;
52
+ projectFolder: string;
53
+ transcriptPath: string;
54
+ tmuxSession: string;
55
+ cwd: string;
56
+ };
57
+
58
+ type SessionDiscovery = {
59
+ meta: SessionMetadata;
60
+ rows: TranscriptRow[];
61
+ };
62
+
63
+ type ShutdownSignal = "SIGINT" | "SIGTERM";
64
+
65
+ type AssistantDiscovery = {
66
+ row: TranscriptRow;
67
+ rows: TranscriptRow[];
68
+ };
69
+
70
+ const POLL_MS = 100;
71
+ const START_TIMEOUT_MS = 20_000;
72
+ const TURN_TIMEOUT_MS = 180_000;
73
+ const WEB_SEARCH_COST_USD = 0.01;
74
+
75
+ type ModelPricing = {
76
+ inputPerMTok: number;
77
+ outputPerMTok: number;
78
+ contextWindow?: number;
79
+ maxOutputTokens?: number;
80
+ };
81
+
82
+ const MODEL_PRICING: Array<{ pattern: RegExp; pricing: ModelPricing }> = [
83
+ {
84
+ pattern: /claude-haiku-4-5|haiku/i,
85
+ pricing: { inputPerMTok: 1, outputPerMTok: 5, contextWindow: 200_000, maxOutputTokens: 32_000 },
86
+ },
87
+ {
88
+ pattern: /claude-sonnet-(4|3-7)|sonnet/i,
89
+ pricing: { inputPerMTok: 3, outputPerMTok: 15, contextWindow: 200_000, maxOutputTokens: 64_000 },
90
+ },
91
+ {
92
+ pattern: /claude-opus-4-(5|6|7)|opus-(4-5|4-6|4-7)/i,
93
+ pricing: { inputPerMTok: 5, outputPerMTok: 25, contextWindow: 200_000, maxOutputTokens: 32_000 },
94
+ },
95
+ {
96
+ pattern: /claude-opus|opus/i,
97
+ pricing: { inputPerMTok: 15, outputPerMTok: 75, contextWindow: 200_000, maxOutputTokens: 32_000 },
98
+ },
99
+ ];
100
+
101
+ export const DEFAULT_CLAUDE_TOOLS = [
102
+ "Task",
103
+ "AskUserQuestion",
104
+ "Bash",
105
+ "CronCreate",
106
+ "CronDelete",
107
+ "CronList",
108
+ "Edit",
109
+ "EnterPlanMode",
110
+ "EnterWorktree",
111
+ "ExitPlanMode",
112
+ "ExitWorktree",
113
+ "Glob",
114
+ "Grep",
115
+ "Monitor",
116
+ "NotebookEdit",
117
+ "PushNotification",
118
+ "Read",
119
+ "RemoteTrigger",
120
+ "ScheduleWakeup",
121
+ "Skill",
122
+ "TaskOutput",
123
+ "TaskStop",
124
+ "TodoWrite",
125
+ "WebFetch",
126
+ "WebSearch",
127
+ "Write",
128
+ ];
129
+
130
+ export function parseArgs(argv: string[], cwd = process.cwd()): CliOptions {
131
+ const program = new Command()
132
+ .name("shannon")
133
+ .description("Run a Claude prompt through an interactive Claude CLI session.")
134
+ .exitOverride()
135
+ .allowExcessArguments(false)
136
+ .option("-p, --print <prompt>", "prompt to send to Claude")
137
+ .argument("[prompt]", "prompt to send to Claude")
138
+ .option("--input-format <format>", "input format", "text")
139
+ .option("--output-format <format>", "output format", "stream-json")
140
+ .option("--verbose", "emit verbose stream JSON", false)
141
+ .option("--add-dir <directories...>", "additional directories to allow tool access")
142
+ .option("--agent <agent>", "agent for the current session")
143
+ .option("--agents <json>", "JSON object defining custom agents")
144
+ .option("--allow-dangerously-skip-permissions", "enable bypassing all permission checks as an option")
145
+ .option("--allowedTools, --allowed-tools <tools...>", "tool names to allow")
146
+ .option("--append-system-prompt <prompt>", "append to the default system prompt")
147
+ .option("--bare", "minimal Claude mode")
148
+ .option("--betas <betas...>", "beta headers to include")
149
+ .option("--brief", "enable SendUserMessage tool")
150
+ .option("--chrome", "enable Claude in Chrome integration")
151
+ .option("-c, --continue", "continue the most recent conversation")
152
+ .option("--dangerously-skip-permissions", "bypass all permission checks")
153
+ .option("-d, --debug [filter]", "enable debug mode")
154
+ .option("--debug-file <path>", "write debug logs to a path")
155
+ .option("--disable-slash-commands", "disable all skills")
156
+ .option("--disallowedTools, --disallowed-tools <tools...>", "tool names to deny")
157
+ .option("--effort <level>", "effort level")
158
+ .option("--exclude-dynamic-system-prompt-sections", "exclude dynamic system prompt sections")
159
+ .option("--fallback-model <model>", "fallback model")
160
+ .option("--file <specs...>", "file resources to download at startup")
161
+ .option("--fork-session", "fork when resuming")
162
+ .option("--from-pr [value]", "resume a session linked to a PR")
163
+ .option("--ide", "auto-connect to IDE")
164
+ .option("--include-hook-events", "include hook lifecycle events")
165
+ .option("--include-partial-messages", "include partial message chunks")
166
+ .option("--json-schema <schema>", "JSON schema for structured output")
167
+ .option("--max-budget-usd <amount>", "maximum API budget")
168
+ .option("--mcp-config <configs...>", "MCP configs")
169
+ .option("--mcp-debug", "enable MCP debug mode")
170
+ .option("--model <model>", "model for the session")
171
+ .option("-n, --name <name>", "display name for session")
172
+ .option("--no-chrome", "disable Claude in Chrome integration")
173
+ .option("--no-session-persistence", "disable session persistence")
174
+ .option("--permission-mode <mode>", "permission mode")
175
+ .option("--plugin-dir <path>", "plugin directory", collect, [])
176
+ .option("--plugin-url <url>", "plugin URL", collect, [])
177
+ .option("--remote-control [name]", "start an interactive session with Remote Control enabled")
178
+ .option("--remote-control-session-name-prefix <prefix>", "Remote Control session name prefix")
179
+ .option("-r, --resume [value]", "resume a conversation")
180
+ .option("--replay-user-messages", "re-emit stream-json input user messages")
181
+ .option("--session-id <uuid>", "specific session UUID")
182
+ .option("--setting-sources <sources>", "setting sources")
183
+ .option("--settings <file-or-json>", "settings file or JSON")
184
+ .option("--strict-mcp-config", "strict MCP config")
185
+ .option("--system-prompt <prompt>", "system prompt")
186
+ .option("--tools <tools...>", "available tools")
187
+ .option("--tmux [mode]", "create a tmux session for the worktree")
188
+ .option("-w, --worktree [name]", "create a new git worktree for this session")
189
+ .configureOutput({
190
+ writeOut: () => undefined,
191
+ writeErr: () => undefined,
192
+ });
193
+
194
+ try {
195
+ program.parse(argv, { from: "user" });
196
+ } catch (error) {
197
+ throw new Error(error instanceof Error ? error.message : String(error));
198
+ }
199
+
200
+ const parsed = program.opts<{
201
+ print?: string;
202
+ inputFormat: string;
203
+ outputFormat: string;
204
+ verbose: boolean;
205
+ replayUserMessages?: boolean;
206
+ [key: string]: unknown;
207
+ }>();
208
+ const prompt = parsed.print ?? program.args.join(" ");
209
+ const inputFormat = parsed.inputFormat;
210
+ const outputFormat = parsed.outputFormat;
211
+
212
+ if (inputFormat !== "text" && inputFormat !== "stream-json") {
213
+ throw new Error(`Unsupported --input-format ${inputFormat || "<missing>"}`);
214
+ }
215
+
216
+ if (!prompt && inputFormat === "text") {
217
+ throw new Error("Expected a prompt via -p, --print, or positional prompt");
218
+ }
219
+
220
+ if (!isOutputFormat(outputFormat)) {
221
+ throw new Error(`Unsupported --output-format ${outputFormat || "<missing>"}`);
222
+ }
223
+
224
+ return {
225
+ prompt: prompt || undefined,
226
+ inputFormat,
227
+ outputFormat,
228
+ verbose: parsed.verbose,
229
+ replayUserMessages: parsed.replayUserMessages === true,
230
+ cwd: resolve(cwd),
231
+ claudeArgs: buildClaudeArgs(parsed),
232
+ };
233
+ }
234
+
235
+ function collect(value: string, previous: string[]) {
236
+ return previous.concat(value);
237
+ }
238
+
239
+ function isOutputFormat(value: string): value is OutputFormat {
240
+ return value === "stream-json" || value === "json" || value === "text";
241
+ }
242
+
243
+ export function projectKeyForCwd(cwd: string): string {
244
+ return resolve(cwd).normalize("NFC").replace(/[^a-zA-Z0-9._-]/g, "-");
245
+ }
246
+
247
+ export function claudeProjectFolder(cwd: string, home = Bun.env.HOME ?? ""): string {
248
+ return join(home, ".claude", "projects", projectKeyForCwd(cwd));
249
+ }
250
+
251
+ export function buildClaudeArgs(parsed: Record<string, unknown>): string[] {
252
+ const args: string[] = [];
253
+
254
+ addRepeated(args, "--add-dir", parsed.addDir);
255
+ addString(args, "--agent", parsed.agent);
256
+ addString(args, "--agents", parsed.agents);
257
+ addBoolean(args, "--allow-dangerously-skip-permissions", parsed.allowDangerouslySkipPermissions);
258
+ addRepeated(args, "--allowed-tools", parsed.allowedTools);
259
+ addString(args, "--append-system-prompt", parsed.appendSystemPrompt);
260
+ addBoolean(args, "--bare", parsed.bare);
261
+ addRepeated(args, "--betas", parsed.betas);
262
+ addBoolean(args, "--brief", parsed.brief);
263
+ addBoolean(args, "--chrome", parsed.chrome);
264
+ addBoolean(args, "--continue", parsed.continue);
265
+ addBoolean(args, "--dangerously-skip-permissions", parsed.dangerouslySkipPermissions);
266
+ addOptionalString(args, "--debug", parsed.debug);
267
+ addString(args, "--debug-file", parsed.debugFile);
268
+ addBoolean(args, "--disable-slash-commands", parsed.disableSlashCommands);
269
+ addRepeated(args, "--disallowed-tools", parsed.disallowedTools);
270
+ addString(args, "--effort", parsed.effort);
271
+ addBoolean(args, "--exclude-dynamic-system-prompt-sections", parsed.excludeDynamicSystemPromptSections);
272
+ addString(args, "--fallback-model", parsed.fallbackModel);
273
+ addRepeated(args, "--file", parsed.file);
274
+ addBoolean(args, "--fork-session", parsed.forkSession);
275
+ addOptionalString(args, "--from-pr", parsed.fromPr);
276
+ addBoolean(args, "--ide", parsed.ide);
277
+ addBoolean(args, "--include-hook-events", parsed.includeHookEvents);
278
+ addBoolean(args, "--include-partial-messages", parsed.includePartialMessages);
279
+ addString(args, "--json-schema", parsed.jsonSchema);
280
+ addString(args, "--max-budget-usd", parsed.maxBudgetUsd);
281
+ addRepeated(args, "--mcp-config", parsed.mcpConfig);
282
+ addBoolean(args, "--mcp-debug", parsed.mcpDebug);
283
+ addString(args, "--model", parsed.model);
284
+ addString(args, "--name", parsed.name);
285
+ if (parsed.chrome === false) args.push("--no-chrome");
286
+ addBoolean(args, "--no-session-persistence", parsed.sessionPersistence === false);
287
+ addString(args, "--permission-mode", parsed.permissionMode);
288
+ addRepeatedFlag(args, "--plugin-dir", parsed.pluginDir);
289
+ addRepeatedFlag(args, "--plugin-url", parsed.pluginUrl);
290
+ addOptionalString(args, "--remote-control", parsed.remoteControl);
291
+ addString(args, "--remote-control-session-name-prefix", parsed.remoteControlSessionNamePrefix);
292
+ addOptionalString(args, "--resume", parsed.resume);
293
+ addString(args, "--session-id", parsed.sessionId);
294
+ addString(args, "--setting-sources", parsed.settingSources);
295
+ addString(args, "--settings", parsed.settings);
296
+ addBoolean(args, "--strict-mcp-config", parsed.strictMcpConfig);
297
+ addString(args, "--system-prompt", parsed.systemPrompt);
298
+ addRepeated(args, "--tools", parsed.tools);
299
+ addOptionalString(args, "--tmux", parsed.tmux);
300
+ addOptionalString(args, "--worktree", parsed.worktree);
301
+
302
+ return args;
303
+ }
304
+
305
+ function addString(args: string[], flag: string, value: unknown) {
306
+ if (typeof value === "string" && value.length > 0) args.push(flag, value);
307
+ }
308
+
309
+ function addOptionalString(args: string[], flag: string, value: unknown) {
310
+ if (value === true) args.push(flag);
311
+ else addString(args, flag, value);
312
+ }
313
+
314
+ function addBoolean(args: string[], flag: string, value: unknown) {
315
+ if (value === true) args.push(flag);
316
+ }
317
+
318
+ function addRepeated(args: string[], flag: string, value: unknown) {
319
+ if (Array.isArray(value)) {
320
+ const strings = value.filter((item): item is string => typeof item === "string");
321
+ if (strings.length > 0) args.push(flag, ...strings);
322
+ } else {
323
+ addString(args, flag, value);
324
+ }
325
+ }
326
+
327
+ function addRepeatedFlag(args: string[], flag: string, value: unknown) {
328
+ if (!Array.isArray(value)) return;
329
+ for (const item of value) {
330
+ if (typeof item === "string" && item.length > 0) args.push(flag, item);
331
+ }
332
+ }
333
+
334
+ export function textFromContent(content: unknown): string {
335
+ if (typeof content === "string") return content;
336
+ if (!Array.isArray(content)) return "";
337
+
338
+ return content
339
+ .map((block) => {
340
+ if (block && typeof block === "object" && "type" in block && block.type === "text") {
341
+ return "text" in block && typeof block.text === "string" ? block.text : "";
342
+ }
343
+ return "";
344
+ })
345
+ .join("");
346
+ }
347
+
348
+ export function toSdkAssistant(row: TranscriptRow): JsonRecord {
349
+ return {
350
+ type: "assistant",
351
+ message: row.message,
352
+ parent_tool_use_id: row.parent_tool_use_id ?? null,
353
+ session_id: row.sessionId ?? row.session_id,
354
+ uuid: row.uuid,
355
+ };
356
+ }
357
+
358
+ export function toSdkHookResponse(row: TranscriptRow): JsonRecord | undefined {
359
+ const attachment = row.attachment;
360
+ if (attachment?.type !== "hook_success") return undefined;
361
+
362
+ return {
363
+ type: "system",
364
+ subtype: "hook_response",
365
+ hook_id: attachment.toolUseID ?? row.uuid ?? randomUUID(),
366
+ hook_name: attachment.hookName ?? "unknown",
367
+ hook_event: attachment.hookEvent ?? "unknown",
368
+ output: attachment.content || attachment.stdout || attachment.stderr || "",
369
+ stdout: attachment.stdout ?? "",
370
+ stderr: attachment.stderr ?? "",
371
+ exit_code: attachment.exitCode,
372
+ outcome: "success",
373
+ uuid: row.uuid ?? randomUUID(),
374
+ session_id: row.sessionId ?? row.session_id,
375
+ };
376
+ }
377
+
378
+ export function toSdkHookStarted(row: TranscriptRow): JsonRecord | undefined {
379
+ const attachment = row.attachment;
380
+ if (attachment?.type !== "hook_success") return undefined;
381
+
382
+ return {
383
+ type: "system",
384
+ subtype: "hook_started",
385
+ hook_id: attachment.toolUseID ?? row.uuid ?? randomUUID(),
386
+ hook_name: attachment.hookName ?? "unknown",
387
+ hook_event: attachment.hookEvent ?? "unknown",
388
+ uuid: row.uuid ?? randomUUID(),
389
+ session_id: row.sessionId ?? row.session_id,
390
+ };
391
+ }
392
+
393
+ export function toSdkInit(meta: SessionMetadata, rows: TranscriptRow[]): JsonRecord {
394
+ const userRow = rows.find((row) => row.type === "user");
395
+ const versionedRow = rows.find((row) => typeof row.version === "string");
396
+ const assistantRow = rows.find((row) => typeof row.message?.model === "string");
397
+ const skillNames = skillNamesFromRows(rows);
398
+ const mcpServers = mcpServersFromRows(rows);
399
+ const tools = toolsFromMcpServers(mcpServers);
400
+
401
+ return {
402
+ type: "system",
403
+ subtype: "init",
404
+ cwd: meta.cwd,
405
+ session_id: meta.sessionId,
406
+ tools,
407
+ mcp_servers: mcpServers,
408
+ model: assistantRow?.message?.model ?? "unknown",
409
+ permissionMode: typeof userRow?.permissionMode === "string" ? userRow.permissionMode : "unknown",
410
+ apiKeySource: "none",
411
+ claude_code_version: typeof versionedRow?.version === "string" ? versionedRow.version : undefined,
412
+ output_style: "default",
413
+ agents: [],
414
+ slash_commands: skillNames,
415
+ skills: skillNames,
416
+ plugins: [],
417
+ uuid: randomUUID(),
418
+ };
419
+ }
420
+
421
+ export function toolsFromMcpServers(mcpServers: Array<{ name: string }>): string[] {
422
+ return [
423
+ ...DEFAULT_CLAUDE_TOOLS,
424
+ ...mcpServers.flatMap((server) => mcpToolNames(server.name)),
425
+ ];
426
+ }
427
+
428
+ function mcpToolNames(serverName: string) {
429
+ switch (serverName) {
430
+ case "context7":
431
+ return ["mcp__context7__query-docs", "mcp__context7__resolve-library-id"];
432
+ case "morph-mcp":
433
+ return ["mcp__morph-mcp__codebase_search"];
434
+ default:
435
+ return [];
436
+ }
437
+ }
438
+
439
+ export function mcpServersFromRows(rows: TranscriptRow[]): Array<{ name: string; status: string }> {
440
+ const servers = new Map<string, { name: string; status: string }>();
441
+
442
+ for (const row of rows) {
443
+ if (row.attachment?.type !== "mcp_instructions_delta") continue;
444
+
445
+ for (const name of stringArrayFromUnknown(row.attachment.addedNames)) {
446
+ servers.set(name, { name, status: "connected" });
447
+ }
448
+
449
+ for (const name of stringArrayFromUnknown(row.attachment.removedNames)) {
450
+ servers.delete(name);
451
+ }
452
+ }
453
+
454
+ return [...servers.values()];
455
+ }
456
+
457
+ function stringArrayFromUnknown(value: unknown): string[] {
458
+ if (!Array.isArray(value)) return [];
459
+ return value.filter((item): item is string => typeof item === "string" && item.length > 0);
460
+ }
461
+
462
+ export function skillNamesFromRows(rows: TranscriptRow[]): string[] {
463
+ const names = new Set<string>();
464
+
465
+ for (const row of rows) {
466
+ if (row.attachment?.type !== "skill_listing" || typeof row.attachment.content !== "string") continue;
467
+ for (const name of skillNamesFromListing(row.attachment.content)) {
468
+ names.add(name);
469
+ }
470
+ }
471
+
472
+ return [...names];
473
+ }
474
+
475
+ export function skillNamesFromListing(content: string): string[] {
476
+ const names: string[] = [];
477
+
478
+ for (const line of content.split("\n")) {
479
+ const match = /^-\s+(.+?)(?::\s|\s*$)/.exec(line);
480
+ if (!match) continue;
481
+ const name = match[1]?.trim();
482
+ if (name) names.push(name);
483
+ }
484
+
485
+ return names;
486
+ }
487
+
488
+ export function toSdkResult(row: TranscriptRow, startedAt: number, numTurns = 1): JsonRecord {
489
+ const text = textFromContent(row.message?.content);
490
+ const usage = row.message?.usage ?? {};
491
+ const model = row.message?.model ?? "unknown";
492
+ const durationMs = Date.now() - startedAt;
493
+ const modelUsage = toModelUsage(model, usage);
494
+ const totalCostUsd = modelUsage.costUSD;
495
+
496
+ return {
497
+ type: "result",
498
+ subtype: "success",
499
+ is_error: false,
500
+ duration_ms: durationMs,
501
+ duration_api_ms: durationMs,
502
+ num_turns: numTurns,
503
+ result: text,
504
+ stop_reason: row.message?.stop_reason ?? "end_turn",
505
+ session_id: row.sessionId ?? row.session_id,
506
+ total_cost_usd: totalCostUsd,
507
+ usage,
508
+ modelUsage: {
509
+ [model]: modelUsage,
510
+ },
511
+ permission_denials: [],
512
+ terminal_reason: "completed",
513
+ uuid: randomUUID(),
514
+ };
515
+ }
516
+
517
+ export function estimateCostUSD(model: string, usage: JsonRecord): number {
518
+ const pricing = pricingForModel(model);
519
+ if (!pricing) return 0;
520
+
521
+ const inputTokens = numberFromUsage(usage.input_tokens);
522
+ const outputTokens = numberFromUsage(usage.output_tokens);
523
+ const cacheReadInputTokens = numberFromUsage(usage.cache_read_input_tokens);
524
+ const cacheCreationInputTokens = numberFromUsage(usage.cache_creation_input_tokens);
525
+ const webSearchRequests = webSearchRequestsFromUsage(usage);
526
+
527
+ const tokenCost = (
528
+ inputTokens * pricing.inputPerMTok
529
+ + cacheCreationInputTokens * pricing.inputPerMTok * 1.25
530
+ + cacheReadInputTokens * pricing.inputPerMTok * 0.1
531
+ + outputTokens * pricing.outputPerMTok
532
+ ) / 1_000_000;
533
+
534
+ return tokenCost + webSearchRequests * WEB_SEARCH_COST_USD;
535
+ }
536
+
537
+ function toModelUsage(model: string, usage: JsonRecord): JsonRecord {
538
+ const pricing = pricingForModel(model);
539
+ const costUSD = estimateCostUSD(model, usage);
540
+
541
+ return {
542
+ inputTokens: numberFromUsage(usage.input_tokens),
543
+ outputTokens: numberFromUsage(usage.output_tokens),
544
+ cacheReadInputTokens: numberFromUsage(usage.cache_read_input_tokens),
545
+ cacheCreationInputTokens: numberFromUsage(usage.cache_creation_input_tokens),
546
+ webSearchRequests: webSearchRequestsFromUsage(usage),
547
+ costUSD,
548
+ ...(pricing?.contextWindow !== undefined ? { contextWindow: pricing.contextWindow } : {}),
549
+ ...(pricing?.maxOutputTokens !== undefined ? { maxOutputTokens: pricing.maxOutputTokens } : {}),
550
+ };
551
+ }
552
+
553
+ function pricingForModel(model: string): ModelPricing | undefined {
554
+ return MODEL_PRICING.find(({ pattern }) => pattern.test(model))?.pricing;
555
+ }
556
+
557
+ function numberFromUsage(value: unknown): number {
558
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
559
+ }
560
+
561
+ function webSearchRequestsFromUsage(usage: JsonRecord): number {
562
+ const serverToolUse = usage.server_tool_use;
563
+ if (!serverToolUse || typeof serverToolUse !== "object") return 0;
564
+ return numberFromUsage((serverToolUse as JsonRecord).web_search_requests);
565
+ }
566
+
567
+ export function toShannonMetadata(meta: SessionMetadata, cleanup: JsonRecord): JsonRecord {
568
+ return {
569
+ type: "shannon_session",
570
+ subtype: "metadata",
571
+ session_id: meta.sessionId,
572
+ session_folder: meta.projectFolder,
573
+ transcript_path: meta.transcriptPath,
574
+ tmux_session: meta.tmuxSession,
575
+ cwd: meta.cwd,
576
+ cleanup,
577
+ uuid: randomUUID(),
578
+ };
579
+ }
580
+
581
+ export function toUserReplay(prompt: string): JsonRecord {
582
+ return {
583
+ type: "user",
584
+ message: {
585
+ role: "user",
586
+ content: prompt,
587
+ },
588
+ parent_tool_use_id: null,
589
+ session_id: "",
590
+ uuid: randomUUID(),
591
+ };
592
+ }
593
+
594
+ export function promptFromUserMessage(message: JsonRecord): string | undefined {
595
+ if (message.type !== "user") return undefined;
596
+ const nested = message.message;
597
+ if (!nested || typeof nested !== "object") return undefined;
598
+ const content = (nested as JsonRecord).content;
599
+ const text = textFromContent(content);
600
+ return text || undefined;
601
+ }
602
+
603
+ export function assistantReplyFromRows(prompt: string, rows: TranscriptRow[]): TranscriptRow | undefined {
604
+ let sawPrompt = false;
605
+
606
+ for (const row of rows) {
607
+ if (row.type === "user" && row.message?.content === prompt) {
608
+ sawPrompt = true;
609
+ continue;
610
+ }
611
+
612
+ if (!sawPrompt || row.type !== "assistant" || row.message?.role !== "assistant") continue;
613
+ if (textFromContent(row.message.content)) return row;
614
+ }
615
+ }
616
+
617
+ async function main() {
618
+ const options = parseArgs(Bun.argv.slice(2));
619
+ await runShannon(options);
620
+ }
621
+
622
+ export async function runShannon(options: CliOptions) {
623
+ await validateRuntime();
624
+
625
+ const tmuxSession = `shannon-${randomUUID()}`;
626
+ const projectFolder = claudeProjectFolder(options.cwd);
627
+ const before = await listTranscriptPaths(projectFolder);
628
+ const startedAt = Date.now();
629
+ const prompts = options.prompt
630
+ ? asyncIterableFromArray([options.prompt])
631
+ : readPromptsFromStdin(options.inputFormat);
632
+ let meta: SessionMetadata | undefined;
633
+ let transcriptRowCount = 0;
634
+ let cleanup: JsonRecord = { tmux_killed: false };
635
+ let promptReady = false;
636
+ let promptCount = 0;
637
+ let cleanupStarted = false;
638
+ let metadataEmitted = false;
639
+ const jsonMessages: JsonRecord[] = [];
640
+
641
+ const cleanupOnce = async () => {
642
+ if (cleanupStarted) return cleanup;
643
+ cleanupStarted = true;
644
+ cleanup = await killTmux(tmuxSession);
645
+ return cleanup;
646
+ };
647
+
648
+ const emitMetadataOnce = () => {
649
+ if (!meta || metadataEmitted || options.outputFormat !== "stream-json") return;
650
+ metadataEmitted = true;
651
+ emitJson(toShannonMetadata(meta, cleanup));
652
+ };
653
+
654
+ const disposeSignalHandlers = installSignalHandlers({
655
+ cleanup: cleanupOnce,
656
+ emitMetadata: emitMetadataOnce,
657
+ });
658
+
659
+ try {
660
+ await runCommand([
661
+ "tmux",
662
+ "new-session",
663
+ "-d",
664
+ "-s",
665
+ tmuxSession,
666
+ "-c",
667
+ options.cwd,
668
+ "claude",
669
+ ...options.claudeArgs,
670
+ ]);
671
+ await waitForPrompt(tmuxSession);
672
+ promptReady = true;
673
+
674
+ for await (const prompt of prompts) {
675
+ if (!prompt) continue;
676
+ promptCount += 1;
677
+
678
+ if (!promptReady) {
679
+ await waitForPrompt(tmuxSession);
680
+ promptReady = true;
681
+ }
682
+
683
+ if (options.replayUserMessages && options.outputFormat === "stream-json") {
684
+ emitJson(toUserReplay(prompt));
685
+ }
686
+
687
+ const promptSentAt = Date.now();
688
+ await sendPrompt(tmuxSession, prompt);
689
+ promptReady = false;
690
+
691
+ if (!meta) {
692
+ const discovery = await waitForSessionWithPrompt(
693
+ projectFolder,
694
+ before,
695
+ tmuxSession,
696
+ options.cwd,
697
+ prompt,
698
+ promptSentAt,
699
+ );
700
+ meta = discovery.meta;
701
+ transcriptRowCount = 0;
702
+
703
+ for (const row of discovery.rows) {
704
+ const hookStarted = toSdkHookStarted(row);
705
+ if (hookStarted) {
706
+ if (options.outputFormat === "stream-json") {
707
+ emitJson(hookStarted);
708
+ } else if (options.outputFormat === "json") {
709
+ jsonMessages.push(hookStarted);
710
+ }
711
+ }
712
+
713
+ const hookResponse = toSdkHookResponse(row);
714
+ if (!hookResponse) continue;
715
+ if (options.outputFormat === "stream-json") {
716
+ emitJson(hookResponse);
717
+ } else if (options.outputFormat === "json") {
718
+ jsonMessages.push(hookResponse);
719
+ }
720
+ }
721
+
722
+ const init = toSdkInit(meta, discovery.rows);
723
+ if (options.outputFormat === "stream-json") {
724
+ emitJson(init);
725
+ } else if (options.outputFormat === "json") {
726
+ jsonMessages.push(init);
727
+ }
728
+ }
729
+
730
+ const assistant = await waitForAssistantReply(
731
+ meta.transcriptPath,
732
+ prompt,
733
+ startedAt,
734
+ transcriptRowCount,
735
+ );
736
+ transcriptRowCount = assistant.rows.length;
737
+ const result = toSdkResult(assistant.row, startedAt, promptCount);
738
+ const turnMessages = [toSdkAssistant(assistant.row), result];
739
+ if (options.outputFormat === "json") {
740
+ jsonMessages.push(...turnMessages);
741
+ } else {
742
+ emitOutput(options.outputFormat, turnMessages);
743
+ }
744
+ }
745
+
746
+ if (promptCount === 0) {
747
+ throw new Error("Expected at least one user message on stdin for --input-format=stream-json");
748
+ }
749
+
750
+ if (options.outputFormat === "json") {
751
+ process.stdout.write(`${JSON.stringify(jsonMessages)}\n`);
752
+ }
753
+ } finally {
754
+ disposeSignalHandlers();
755
+ cleanup = await cleanupOnce();
756
+ emitMetadataOnce();
757
+ }
758
+ }
759
+
760
+ export function signalExitCode(signal: ShutdownSignal) {
761
+ return signal === "SIGINT" ? 130 : 143;
762
+ }
763
+
764
+ function installSignalHandlers({
765
+ cleanup,
766
+ emitMetadata,
767
+ }: {
768
+ cleanup: () => Promise<JsonRecord>;
769
+ emitMetadata: () => void;
770
+ }) {
771
+ let shuttingDown = false;
772
+
773
+ const handler = (signal: ShutdownSignal) => {
774
+ if (shuttingDown) return;
775
+ shuttingDown = true;
776
+
777
+ cleanup()
778
+ .then(() => {
779
+ emitMetadata();
780
+ })
781
+ .catch((error) => {
782
+ process.stderr.write(
783
+ `Failed to clean up Shannon tmux session after ${signal}: ${
784
+ error instanceof Error ? error.message : String(error)
785
+ }\n`,
786
+ );
787
+ })
788
+ .finally(() => {
789
+ process.exit(signalExitCode(signal));
790
+ });
791
+ };
792
+
793
+ process.once("SIGINT", handler);
794
+ process.once("SIGTERM", handler);
795
+
796
+ return () => {
797
+ process.off("SIGINT", handler);
798
+ process.off("SIGTERM", handler);
799
+ };
800
+ }
801
+
802
+ export async function validateRuntime() {
803
+ const [claude, tmux] = await Promise.all([
804
+ findExecutable("claude"),
805
+ findExecutable("tmux"),
806
+ ]);
807
+
808
+ if (!claude) {
809
+ throw new Error("Missing required executable: claude. Install Claude Code and make sure `claude` is on PATH.");
810
+ }
811
+
812
+ if (!tmux) {
813
+ throw new Error("Missing required executable: tmux. Install tmux and make sure `tmux` is on PATH.");
814
+ }
815
+
816
+ return { claude, tmux };
817
+ }
818
+
819
+ async function* asyncIterableFromArray(values: string[]): AsyncIterable<string> {
820
+ for (const value of values) yield value;
821
+ }
822
+
823
+ async function* readPromptsFromStdin(inputFormat: "text" | "stream-json"): AsyncIterable<string> {
824
+ if (inputFormat === "text") {
825
+ const stdin = await Bun.stdin.text();
826
+ const prompt = stdin.trimEnd();
827
+ if (prompt) yield prompt;
828
+ return;
829
+ }
830
+
831
+ for await (const line of readStdinLines()) {
832
+ if (!line.trim()) continue;
833
+ const prompt = promptFromUserMessage(JSON.parse(line) as JsonRecord);
834
+ if (prompt) yield prompt;
835
+ }
836
+ }
837
+
838
+ async function* readStdinLines(initialText?: string): AsyncIterable<string> {
839
+ if (initialText !== undefined) {
840
+ yield* initialText.split("\n");
841
+ return;
842
+ }
843
+
844
+ const reader = Bun.stdin.stream().getReader();
845
+ const decoder = new TextDecoder();
846
+ let buffer = "";
847
+
848
+ while (true) {
849
+ const { done, value } = await reader.read();
850
+ if (done) break;
851
+
852
+ buffer += decoder.decode(value, { stream: true });
853
+ const lines = buffer.split("\n");
854
+ buffer = lines.pop() ?? "";
855
+
856
+ for (const line of lines) yield line;
857
+ }
858
+
859
+ buffer += decoder.decode();
860
+ if (buffer) yield buffer;
861
+ }
862
+
863
+ async function waitForSessionWithPrompt(
864
+ projectFolder: string,
865
+ before: Set<string>,
866
+ tmuxSession: string,
867
+ cwd: string,
868
+ prompt: string,
869
+ promptSentAt: number,
870
+ ): Promise<SessionDiscovery> {
871
+ const startedAt = Date.now();
872
+
873
+ while (Date.now() - startedAt < START_TIMEOUT_MS) {
874
+ const paths = await listTranscriptPaths(projectFolder);
875
+ const fresh = [...paths].filter((path) => !before.has(path)).sort();
876
+ const existing = [...paths].filter((path) => before.has(path)).sort();
877
+ const candidates = [...fresh, ...existing];
878
+
879
+ for (const transcriptPath of candidates) {
880
+ const rows = await readTranscript(transcriptPath);
881
+ const hasPrompt = rows.some((row) => rowContainsPromptAfter(row, prompt, promptSentAt, !before.has(transcriptPath)));
882
+ if (!hasPrompt) continue;
883
+
884
+ const sessionId = basename(transcriptPath).replace(/\.jsonl$/, "");
885
+ return {
886
+ meta: { sessionId, projectFolder, transcriptPath, tmuxSession, cwd },
887
+ rows,
888
+ };
889
+ }
890
+
891
+ await sleep(POLL_MS);
892
+ }
893
+
894
+ const pane = await capturePane(tmuxSession);
895
+ throw new Error(
896
+ `Timed out waiting for Claude transcript containing the submitted prompt in ${projectFolder}\n\nCaptured tmux pane:\n${pane}`,
897
+ );
898
+ }
899
+
900
+ export function rowContainsPromptAfter(
901
+ row: TranscriptRow,
902
+ prompt: string,
903
+ promptSentAt: number,
904
+ allowMissingTimestamp = false,
905
+ ) {
906
+ if (row.type !== "user" || row.message?.content !== prompt) return false;
907
+ if (typeof row.timestamp !== "string") return allowMissingTimestamp;
908
+ const timestamp = Date.parse(row.timestamp);
909
+ return Number.isFinite(timestamp) && timestamp >= promptSentAt - 1_000;
910
+ }
911
+
912
+ async function waitForPrompt(tmuxSession: string) {
913
+ const startedAt = Date.now();
914
+
915
+ while (Date.now() - startedAt < START_TIMEOUT_MS) {
916
+ const pane = await runCommand(["tmux", "capture-pane", "-pt", tmuxSession, "-S", "-40"]);
917
+ if (pane.stdout.includes("❯") || pane.stdout.includes(">")) return;
918
+ await sleep(POLL_MS);
919
+ }
920
+
921
+ const pane = await capturePane(tmuxSession);
922
+ throw new Error(`Timed out waiting for Claude prompt\n\nCaptured tmux pane:\n${pane}`);
923
+ }
924
+
925
+ async function sendPrompt(tmuxSession: string, prompt: string) {
926
+ await runCommand(["tmux", "set-buffer", "-b", `shannon-${tmuxSession}`, prompt]);
927
+ await runCommand(["tmux", "paste-buffer", "-b", `shannon-${tmuxSession}`, "-t", tmuxSession]);
928
+ await runCommand(["tmux", "send-keys", "-t", tmuxSession, "Escape"]);
929
+ await runCommand(["tmux", "send-keys", "-t", tmuxSession, "C-m"]);
930
+ }
931
+
932
+ async function waitForAssistantReply(
933
+ transcriptPath: string,
934
+ prompt: string,
935
+ startedAt: number,
936
+ afterRowCount: number,
937
+ ): Promise<AssistantDiscovery> {
938
+ while (Date.now() - startedAt < TURN_TIMEOUT_MS) {
939
+ const rows = await readTranscript(transcriptPath);
940
+ const newRows = rows.slice(afterRowCount);
941
+ const row = assistantReplyFromRows(prompt, newRows);
942
+ if (row) return { row, rows };
943
+
944
+ await sleep(POLL_MS);
945
+ }
946
+
947
+ throw new Error(`Timed out waiting for assistant reply in ${transcriptPath}`);
948
+ }
949
+
950
+ async function readTranscript(transcriptPath: string): Promise<TranscriptRow[]> {
951
+ const file = Bun.file(transcriptPath);
952
+ if (!(await file.exists())) return [];
953
+
954
+ const text = await file.text();
955
+ return text
956
+ .split("\n")
957
+ .filter(Boolean)
958
+ .map((line) => {
959
+ try {
960
+ return JSON.parse(line) as TranscriptRow;
961
+ } catch {
962
+ return { type: "shannon_parse_error", line };
963
+ }
964
+ });
965
+ }
966
+
967
+ async function listTranscriptPaths(projectFolder: string): Promise<Set<string>> {
968
+ const glob = new Bun.Glob("*.jsonl");
969
+ const paths = new Set<string>();
970
+
971
+ try {
972
+ for await (const name of glob.scan(projectFolder)) {
973
+ paths.add(join(projectFolder, name));
974
+ }
975
+ } catch {
976
+ return paths;
977
+ }
978
+
979
+ return paths;
980
+ }
981
+
982
+ async function killTmux(tmuxSession: string): Promise<JsonRecord> {
983
+ const result = await runCommand(["tmux", "kill-session", "-t", tmuxSession], false);
984
+ return {
985
+ tmux_killed: result.exitCode === 0,
986
+ exit_code: result.exitCode,
987
+ stderr: result.stderr.trim(),
988
+ };
989
+ }
990
+
991
+ async function capturePane(tmuxSession: string) {
992
+ const result = await runCommand(
993
+ ["tmux", "capture-pane", "-pt", tmuxSession, "-S", "-80"],
994
+ false,
995
+ );
996
+ return result.stdout.trimEnd() || result.stderr.trimEnd();
997
+ }
998
+
999
+ async function findExecutable(name: string) {
1000
+ const result = await runCommand(["which", name], false);
1001
+ return result.exitCode === 0 ? result.stdout.trim() : undefined;
1002
+ }
1003
+
1004
+ async function runCommand(args: string[], throwOnFailure = true) {
1005
+ const proc = Bun.spawn(args, {
1006
+ stdout: "pipe",
1007
+ stderr: "pipe",
1008
+ });
1009
+
1010
+ const [stdout, stderr, exitCode] = await Promise.all([
1011
+ new Response(proc.stdout).text(),
1012
+ new Response(proc.stderr).text(),
1013
+ proc.exited,
1014
+ ]);
1015
+
1016
+ if (throwOnFailure && exitCode !== 0) {
1017
+ throw new Error(`${args.join(" ")} failed with ${exitCode}: ${stderr}`);
1018
+ }
1019
+
1020
+ return { stdout, stderr, exitCode };
1021
+ }
1022
+
1023
+ function emitJson(value: JsonRecord) {
1024
+ process.stdout.write(`${JSON.stringify(value)}\n`);
1025
+ }
1026
+
1027
+ function emitOutput(outputFormat: OutputFormat, messages: JsonRecord[]) {
1028
+ if (outputFormat === "stream-json") {
1029
+ for (const message of messages) emitJson(message);
1030
+ return;
1031
+ }
1032
+
1033
+ const result = messages.find((message) => message.type === "result") ?? {};
1034
+ if (outputFormat === "json") {
1035
+ emitJson(result);
1036
+ return;
1037
+ }
1038
+
1039
+ const text = typeof result.result === "string" ? result.result : "";
1040
+ process.stdout.write(text ? `${text}\n` : "");
1041
+ }
1042
+
1043
+ function sleep(ms: number) {
1044
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
1045
+ }
1046
+
1047
+ function usage() {
1048
+ return "Usage: shannon -p <prompt> --output-format=stream-json --verbose";
1049
+ }
1050
+
1051
+ if (import.meta.main) {
1052
+ main().catch((error) => {
1053
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
1054
+ process.exit(1);
1055
+ });
1056
+ }