talon-agent 1.0.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 (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Terminal command registry — extensible slash command system.
3
+ *
4
+ * Each command is a self-contained handler registered via `registerCommand()`.
5
+ * New commands = one function call. Handlers are independently testable.
6
+ */
7
+
8
+ import pc from "picocolors";
9
+ import type { TalonConfig } from "../../util/config.js";
10
+ import type { Renderer } from "./renderer.js";
11
+ import { formatTimeAgo } from "./renderer.js";
12
+ import { isTerminalChatId } from "../../util/chat-id.js";
13
+
14
+ // ── Types ────────────────────────────────────────────────────────────────────
15
+
16
+ export type CommandContext = {
17
+ /** Current chat ID (getter — may change on /resume). */
18
+ chatId: () => string;
19
+ config: TalonConfig;
20
+ renderer: Renderer;
21
+ reprompt: () => void;
22
+ initNewChat: (id?: string) => void;
23
+ waitForInput: () => Promise<string>;
24
+ /** Close the terminal (for /quit). */
25
+ close: () => void;
26
+ };
27
+
28
+ export type CommandHandler = (
29
+ args: string,
30
+ ctx: CommandContext,
31
+ ) => Promise<void>;
32
+
33
+ export type Command = {
34
+ name: string;
35
+ aliases?: string[];
36
+ argHint?: string;
37
+ description: string;
38
+ handler: CommandHandler;
39
+ };
40
+
41
+ // ── Registry ─────────────────────────────────────────────────────────────────
42
+
43
+ const commands: Command[] = [];
44
+ const nameIndex = new Map<string, Command>();
45
+
46
+ export function registerCommand(cmd: Command): void {
47
+ commands.push(cmd);
48
+ nameIndex.set(cmd.name, cmd);
49
+ if (cmd.aliases) {
50
+ for (const alias of cmd.aliases) {
51
+ nameIndex.set(alias, cmd);
52
+ }
53
+ }
54
+ }
55
+
56
+ /** Try to run a slash command. Returns true if handled, false if not a command. */
57
+ export async function tryRunCommand(
58
+ text: string,
59
+ ctx: CommandContext,
60
+ ): Promise<boolean> {
61
+ if (!text.startsWith("/")) return false;
62
+
63
+ const spaceIdx = text.indexOf(" ");
64
+ const cmdName = (spaceIdx === -1 ? text : text.slice(0, spaceIdx))
65
+ .slice(1)
66
+ .toLowerCase();
67
+ const args = spaceIdx === -1 ? "" : text.slice(spaceIdx + 1).trim();
68
+
69
+ const cmd = nameIndex.get(cmdName);
70
+ if (!cmd) return false;
71
+
72
+ await cmd.handler(args, ctx);
73
+ return true;
74
+ }
75
+
76
+ /** Get all registered commands (for /help rendering). */
77
+ export function getCommands(): readonly Command[] {
78
+ return commands;
79
+ }
80
+
81
+ /** Clear all registered commands (for testing). */
82
+ export function clearCommands(): void {
83
+ commands.length = 0;
84
+ nameIndex.clear();
85
+ }
86
+
87
+ // ── Built-in commands ────────────────────────────────────────────────────────
88
+
89
+ export function registerBuiltinCommands(): void {
90
+ registerCommand({
91
+ name: "model",
92
+ argHint: "[name]",
93
+ description: "Switch model (opus, sonnet, haiku)",
94
+ async handler(args, ctx) {
95
+ const { getChatSettings, setChatModel, resolveModelName } =
96
+ await import("../../storage/chat-settings.js");
97
+ if (!args) {
98
+ ctx.renderer.writeSystem(
99
+ `Model: ${getChatSettings(ctx.chatId()).model ?? ctx.config.model}`,
100
+ );
101
+ } else {
102
+ setChatModel(ctx.chatId(), resolveModelName(args));
103
+ ctx.renderer.writeSystem(`Model → ${resolveModelName(args)}`);
104
+ }
105
+ ctx.reprompt();
106
+ },
107
+ });
108
+
109
+ registerCommand({
110
+ name: "effort",
111
+ argHint: "[lvl]",
112
+ description: "Thinking effort (off/low/medium/high/max)",
113
+ async handler(args, ctx) {
114
+ const { getChatSettings, setChatEffort } =
115
+ await import("../../storage/chat-settings.js");
116
+ if (!args) {
117
+ ctx.renderer.writeSystem(
118
+ `Effort: ${getChatSettings(ctx.chatId()).effort ?? "adaptive"}`,
119
+ );
120
+ } else {
121
+ setChatEffort(
122
+ ctx.chatId(),
123
+ args === "adaptive"
124
+ ? undefined
125
+ : (args as "off" | "low" | "medium" | "high" | "max"),
126
+ );
127
+ ctx.renderer.writeSystem(`Effort → ${args}`);
128
+ }
129
+ ctx.reprompt();
130
+ },
131
+ });
132
+
133
+ registerCommand({
134
+ name: "status",
135
+ description: "Session stats",
136
+ async handler(_args, ctx) {
137
+ const { getSessionInfo } = await import("../../storage/sessions.js");
138
+ const { getLoadedPlugins } = await import("../../core/plugin.js");
139
+ const info = getSessionInfo(ctx.chatId());
140
+ const u = info.usage;
141
+ const cacheHit =
142
+ u.totalInputTokens + u.totalCacheRead > 0
143
+ ? Math.round(
144
+ (u.totalCacheRead / (u.totalInputTokens + u.totalCacheRead)) *
145
+ 100,
146
+ )
147
+ : 0;
148
+ ctx.renderer.writeln();
149
+ const nameStr = info.sessionName ? `"${info.sessionName}" · ` : "";
150
+ ctx.renderer.writeln(
151
+ ` ${pc.bold("Session")} ${nameStr}turns ${info.turns} · ${cacheHit}% cache`,
152
+ );
153
+ ctx.renderer.writeln(
154
+ ` ${pc.dim(`in ${u.totalInputTokens.toLocaleString()} · out ${u.totalOutputTokens.toLocaleString()} tokens`)}`,
155
+ );
156
+ const plugins = getLoadedPlugins();
157
+ if (plugins.length > 0) {
158
+ ctx.renderer.writeln();
159
+ ctx.renderer.writeln(` ${pc.bold("Plugins")}`);
160
+ for (const p of plugins) {
161
+ const ver = p.plugin.version ? pc.dim(` v${p.plugin.version}`) : "";
162
+ const desc = p.plugin.description
163
+ ? ` ${pc.dim(p.plugin.description)}`
164
+ : "";
165
+ const tools = p.plugin.mcpServerPath
166
+ ? pc.green("mcp")
167
+ : pc.dim("actions only");
168
+ ctx.renderer.writeln(
169
+ ` ${pc.green("●")} ${p.plugin.name}${ver} ${tools}${desc}`,
170
+ );
171
+ }
172
+ }
173
+ ctx.reprompt();
174
+ },
175
+ });
176
+
177
+ registerCommand({
178
+ name: "reset",
179
+ description: "Start a fresh session",
180
+ async handler(_args, ctx) {
181
+ ctx.initNewChat();
182
+ ctx.renderer.writeSystem("Session cleared.");
183
+ ctx.reprompt();
184
+ },
185
+ });
186
+
187
+ registerCommand({
188
+ name: "resume",
189
+ description: "List & resume a past session",
190
+ async handler(_args, ctx) {
191
+ const { getAllSessions } = await import("../../storage/sessions.js");
192
+ const sessions = getAllSessions()
193
+ .filter(
194
+ (s) =>
195
+ isTerminalChatId(s.chatId) &&
196
+ s.chatId !== ctx.chatId() &&
197
+ s.info.turns > 0,
198
+ )
199
+ .sort((a, b) => b.info.lastActive - a.info.lastActive)
200
+ .slice(0, 10);
201
+
202
+ if (sessions.length === 0) {
203
+ ctx.renderer.writeSystem("No previous sessions to resume.");
204
+ ctx.reprompt();
205
+ return;
206
+ }
207
+
208
+ ctx.renderer.writeln();
209
+ ctx.renderer.writeln(` ${pc.bold("Past sessions")}`);
210
+ for (let i = 0; i < sessions.length; i++) {
211
+ const s = sessions[i]!;
212
+ const name = s.info.sessionName
213
+ ? `"${s.info.sessionName}"`
214
+ : pc.dim("(unnamed)");
215
+ const turns = `${s.info.turns} turn${s.info.turns !== 1 ? "s" : ""}`;
216
+ const ago = formatTimeAgo(s.info.lastActive);
217
+ const model = s.info.lastModel
218
+ ? s.info.lastModel
219
+ .replace("claude-", "")
220
+ .replace(/-(\d+)-(\d+).*/, " $1.$2")
221
+ : "";
222
+ ctx.renderer.writeln(
223
+ ` ${pc.green(String(i + 1))}. ${name} ${pc.dim(`${turns} · ${ago}${model ? ` · ${model}` : ""}`)}`,
224
+ );
225
+ }
226
+ ctx.renderer.writeln();
227
+ ctx.renderer.writeln(
228
+ ` ${pc.dim("Enter number to resume (Esc to cancel):")}`,
229
+ );
230
+
231
+ const input = await ctx.waitForInput();
232
+ const num = parseInt(input, 10);
233
+ if (num >= 1 && num <= sessions.length) {
234
+ const selected = sessions[num - 1]!;
235
+ ctx.initNewChat(selected.chatId);
236
+ const name = selected.info.sessionName
237
+ ? `"${selected.info.sessionName}"`
238
+ : `(${selected.info.turns} turns)`;
239
+ ctx.renderer.writeSystem(`Resumed: ${name}`);
240
+ } else {
241
+ ctx.renderer.writeSystem("Cancelled.");
242
+ }
243
+ ctx.reprompt();
244
+ },
245
+ });
246
+
247
+ registerCommand({
248
+ name: "rename",
249
+ argHint: "[name]",
250
+ description: "Name the current session",
251
+ async handler(args, ctx) {
252
+ const { getSession, setSessionName } =
253
+ await import("../../storage/sessions.js");
254
+ // Ensure session exists in store (auto-creates if needed)
255
+ getSession(ctx.chatId());
256
+ if (!args) {
257
+ const session = getSession(ctx.chatId());
258
+ ctx.renderer.writeSystem(
259
+ session.sessionName
260
+ ? `Session name: "${session.sessionName}"`
261
+ : "Session has no name.",
262
+ );
263
+ } else {
264
+ setSessionName(ctx.chatId(), args);
265
+ ctx.renderer.writeSystem(`Session renamed to "${args}"`);
266
+ }
267
+ ctx.reprompt();
268
+ },
269
+ });
270
+
271
+ registerCommand({
272
+ name: "help",
273
+ description: "Show available commands",
274
+ async handler(_args, ctx) {
275
+ ctx.renderer.writeln();
276
+ for (const cmd of getCommands()) {
277
+ if (cmd.name === "help") continue; // show help last
278
+ const nameStr = `/${cmd.name}`;
279
+ const argStr = cmd.argHint ? ` ${cmd.argHint}` : "";
280
+ const pad = " ".repeat(
281
+ Math.max(1, 16 - nameStr.length - argStr.length),
282
+ );
283
+ ctx.renderer.writeln(
284
+ ` ${pc.cyan(nameStr)}${pc.dim(argStr)}${pad}${pc.dim(cmd.description)}`,
285
+ );
286
+ }
287
+ // Help itself at the end
288
+ ctx.renderer.writeln(
289
+ ` ${pc.cyan("/help")} ${pc.dim("Show available commands")}`,
290
+ );
291
+ ctx.reprompt();
292
+ },
293
+ });
294
+
295
+ registerCommand({
296
+ name: "quit",
297
+ aliases: ["exit"],
298
+ description: "Exit",
299
+ async handler(_args, ctx) {
300
+ ctx.close();
301
+ },
302
+ });
303
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Terminal frontend — slim orchestrator wiring renderer, commands, and input.
3
+ *
4
+ * Readline lifecycle:
5
+ * - rl starts active (prompt shown)
6
+ * - User presses Enter → rl.pause() immediately → processing begins
7
+ * - Processing finishes → all output written → rl.resume() + rl.prompt()
8
+ * - Renderer NEVER touches readline. Only this file does.
9
+ */
10
+
11
+ import pc from "picocolors";
12
+ import type { TalonConfig } from "../../util/config.js";
13
+ import type { ContextManager, ActionResult } from "../../core/types.js";
14
+ import type { Gateway } from "../../core/gateway.js";
15
+ import { log } from "../../util/log.js";
16
+ import {
17
+ deriveNumericChatId,
18
+ generateTerminalChatId,
19
+ } from "../../util/chat-id.js";
20
+ import { createRenderer } from "./renderer.js";
21
+ import { createInput } from "./input.js";
22
+ import {
23
+ registerBuiltinCommands,
24
+ tryRunCommand,
25
+ clearCommands,
26
+ type CommandContext,
27
+ } from "./commands.js";
28
+
29
+ // ── State ────────────────────────────────────────────────────────────────────
30
+
31
+ let terminalChatId = "";
32
+ let terminalNumericId = 0;
33
+
34
+ function initNewChat(chatId?: string): void {
35
+ terminalChatId = chatId ?? generateTerminalChatId();
36
+ terminalNumericId = deriveNumericChatId(terminalChatId);
37
+ }
38
+
39
+ // ── Action handler (bridge) ──────────────────────────────────────────────────
40
+
41
+ function createActionHandler(
42
+ gateway: Gateway,
43
+ renderer: ReturnType<typeof createRenderer>,
44
+ ): (
45
+ body: Record<string, unknown>,
46
+ chatId: number,
47
+ ) => Promise<ActionResult | null> {
48
+ return async (body) => {
49
+ const action = body.action as string;
50
+ switch (action) {
51
+ case "send_message": {
52
+ renderer.stopSpinner();
53
+ renderer.renderAssistantMessage(String(body.text ?? ""));
54
+ gateway.incrementMessages(terminalNumericId);
55
+ return { ok: true, message_id: Date.now() };
56
+ }
57
+ case "react": {
58
+ renderer.stopSpinner();
59
+ renderer.writeln(` ${pc.cyan("▍")} ${String(body.emoji ?? "👍")}`);
60
+ gateway.incrementMessages(terminalNumericId);
61
+ return { ok: true };
62
+ }
63
+ case "send_message_with_buttons": {
64
+ renderer.stopSpinner();
65
+ renderer.renderAssistantMessage(String(body.text ?? ""));
66
+ const rows = body.rows as Array<Array<{ text: string }>> | undefined;
67
+ if (rows) {
68
+ for (const row of rows) {
69
+ renderer.writeln(
70
+ ` ${pc.cyan("▍")} ${row.map((b) => pc.dim(`[${b.text}]`)).join(" ")}`,
71
+ );
72
+ }
73
+ }
74
+ gateway.incrementMessages(terminalNumericId);
75
+ return { ok: true, message_id: Date.now() };
76
+ }
77
+ case "edit_message":
78
+ case "delete_message":
79
+ case "pin_message":
80
+ case "unpin_message":
81
+ case "forward_message":
82
+ case "copy_message":
83
+ case "send_chat_action":
84
+ return { ok: true };
85
+ case "get_chat_info":
86
+ return {
87
+ ok: true,
88
+ id: terminalNumericId,
89
+ type: "private",
90
+ title: "Terminal",
91
+ };
92
+ default:
93
+ return null;
94
+ }
95
+ };
96
+ }
97
+
98
+ // ── Frontend interface ───────────────────────────────────────────────────────
99
+
100
+ export type TerminalFrontend = {
101
+ context: ContextManager;
102
+ sendTyping: (chatId: number) => Promise<void>;
103
+ sendMessage: (chatId: number, text: string) => Promise<void>;
104
+ getBridgePort: () => number;
105
+ init: () => Promise<void>;
106
+ start: () => Promise<void>;
107
+ stop: () => Promise<void>;
108
+ };
109
+
110
+ export function createTerminalFrontend(
111
+ config: TalonConfig,
112
+ gateway: Gateway,
113
+ ): TerminalFrontend {
114
+ const renderer = createRenderer(undefined, config.botDisplayName);
115
+ let currentPhase: "idle" | "thinking" | "tool" | "text" = "idle";
116
+ let toolCallCount = 0;
117
+
118
+ const context: ContextManager = {
119
+ acquire: () => gateway.setContext(terminalNumericId, terminalChatId),
120
+ release: () => gateway.clearContext(terminalNumericId),
121
+ getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
122
+ };
123
+
124
+ return {
125
+ context,
126
+ sendTyping: async () => {
127
+ renderer.startSpinner(
128
+ currentPhase === "tool" ? "running tools" : "thinking",
129
+ );
130
+ },
131
+ sendMessage: async (_chatId: number, text: string) => {
132
+ renderer.stopSpinner();
133
+ renderer.renderAssistantMessage(text);
134
+ },
135
+ getBridgePort: () => gateway.getPort(),
136
+
137
+ async init() {
138
+ gateway.setFrontendHandler(createActionHandler(gateway, renderer));
139
+ const port = await gateway.start(19877);
140
+ log("bot", `Terminal gateway on port ${port}`);
141
+ },
142
+
143
+ async start() {
144
+ initNewChat();
145
+
146
+ const modelDisplay = config.model
147
+ .replace("claude-", "")
148
+ .replace(
149
+ /^(\w+)-(\d+)-(\d+)/,
150
+ (_, name: string, maj: string, min: string) =>
151
+ `${name.charAt(0).toUpperCase() + name.slice(1)} ${maj}.${min}`,
152
+ );
153
+
154
+ renderer.writeln();
155
+ renderer.writeln(
156
+ ` ${pc.bold(pc.cyan(config.botDisplayName))} ${pc.dim(modelDisplay)}`,
157
+ );
158
+ renderer.writeln(` ${pc.dim("─".repeat(renderer.cols - 2))}`);
159
+
160
+ const { execute } = await import("../../core/dispatcher.js");
161
+ const { getSessionInfo } = await import("../../storage/sessions.js");
162
+ const input = createInput(` ${pc.green("❯")} `);
163
+
164
+ clearCommands();
165
+ registerBuiltinCommands();
166
+
167
+ function pauseInput(): void {
168
+ input.pause();
169
+ }
170
+
171
+ function reprompt(): void {
172
+ renderer.writeln(); // blank line before prompt
173
+ input.resume();
174
+ input.prompt();
175
+ }
176
+
177
+ const cmdCtx: CommandContext = {
178
+ chatId: () => terminalChatId,
179
+ config,
180
+ renderer,
181
+ reprompt,
182
+ initNewChat,
183
+ waitForInput: () => input.waitForInput(),
184
+ close: () => {
185
+ renderer.writeln();
186
+ renderer.writeln(` ${pc.dim("Goodbye!")}`);
187
+ renderer.writeln();
188
+ input.close();
189
+ process.exit(0);
190
+ },
191
+ };
192
+
193
+ input.onLine(async (text) => {
194
+ if (!text) {
195
+ reprompt();
196
+ return;
197
+ }
198
+
199
+ // Slash commands — these handle their own reprompt
200
+ if (await tryRunCommand(text, cmdCtx)) {
201
+ return;
202
+ }
203
+
204
+ // ── AI query ──
205
+ pauseInput(); // readline off until we're done
206
+ toolCallCount = 0;
207
+ currentPhase = "thinking";
208
+ renderer.startSpinner("thinking");
209
+
210
+ try {
211
+ const result = await execute({
212
+ chatId: terminalChatId,
213
+ numericChatId: terminalNumericId,
214
+ prompt: text,
215
+ senderName: "User",
216
+ isGroup: false,
217
+ source: "message",
218
+ onStreamDelta: (_accumulated, phase) => {
219
+ if (phase === "thinking" && currentPhase !== "thinking") {
220
+ currentPhase = "thinking";
221
+ renderer.updateSpinnerLabel("thinking");
222
+ } else if (phase === "text" && currentPhase !== "text") {
223
+ currentPhase = "text";
224
+ renderer.updateSpinnerLabel("responding");
225
+ }
226
+ },
227
+ onToolUse: (toolName, toolInput) => {
228
+ renderer.stopSpinner();
229
+ currentPhase = "tool";
230
+ toolCallCount++;
231
+ renderer.renderToolCall(toolName, toolInput);
232
+ renderer.startSpinner("running tools");
233
+ },
234
+ onTextBlock: async (blockText) => {
235
+ renderer.stopSpinner();
236
+ renderer.renderAssistantMessage(blockText);
237
+ },
238
+ });
239
+
240
+ renderer.stopSpinner();
241
+ currentPhase = "idle";
242
+
243
+ if (result.bridgeMessageCount === 0 && result.text?.trim()) {
244
+ renderer.renderAssistantMessage(result.text);
245
+ }
246
+
247
+ const info = getSessionInfo(terminalChatId);
248
+ const u = info.usage;
249
+ const cacheHit =
250
+ u.totalInputTokens + u.totalCacheRead > 0
251
+ ? Math.round(
252
+ (u.totalCacheRead / (u.totalInputTokens + u.totalCacheRead)) *
253
+ 100,
254
+ )
255
+ : 0;
256
+ renderer.renderStatusLine(result.durationMs, toolCallCount, {
257
+ model: modelDisplay,
258
+ sessionName: info.sessionName,
259
+ turns: info.turns,
260
+ inputTokens: u.totalInputTokens,
261
+ outputTokens: u.totalOutputTokens,
262
+ cacheHitPct: cacheHit,
263
+ costUsd: u.estimatedCostUsd,
264
+ });
265
+ reprompt(); // readline back on, show prompt
266
+ } catch (err) {
267
+ renderer.stopSpinner();
268
+ currentPhase = "idle";
269
+ renderer.writeError(err instanceof Error ? err.message : String(err));
270
+ reprompt();
271
+ }
272
+ });
273
+
274
+ input.prompt();
275
+ await new Promise(() => {});
276
+ },
277
+
278
+ async stop() {
279
+ await gateway.stop();
280
+ },
281
+ };
282
+ }