whale-code 6.4.0 → 6.5.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.
Files changed (187) hide show
  1. package/bin/swagmanager-mcp.js +51 -0
  2. package/dist/cli/app.js +30 -2
  3. package/dist/cli/chat/ChatApp.d.ts +4 -4
  4. package/dist/cli/chat/ChatApp.js +114 -44
  5. package/dist/cli/chat/ChatInput.d.ts +13 -6
  6. package/dist/cli/chat/ChatInput.js +433 -89
  7. package/dist/cli/chat/MemoryManager.d.ts +15 -0
  8. package/dist/cli/chat/MemoryManager.js +61 -0
  9. package/dist/cli/chat/MessageList.d.ts +8 -0
  10. package/dist/cli/chat/MessageList.js +1 -1
  11. package/dist/cli/chat/NodeManager.d.ts +30 -0
  12. package/dist/cli/chat/NodeManager.js +89 -0
  13. package/dist/cli/chat/NodeSelector.d.ts +19 -0
  14. package/dist/cli/chat/NodeSelector.js +37 -0
  15. package/dist/cli/chat/PlanApproval.d.ts +17 -0
  16. package/dist/cli/chat/PlanApproval.js +82 -0
  17. package/dist/cli/chat/SessionManager.d.ts +16 -0
  18. package/dist/cli/chat/SessionManager.js +43 -0
  19. package/dist/cli/chat/SlashMenu.d.ts +38 -0
  20. package/dist/cli/chat/SlashMenu.js +208 -0
  21. package/dist/cli/chat/StatusBar.d.ts +16 -0
  22. package/dist/cli/chat/StatusBar.js +22 -0
  23. package/dist/cli/chat/ThemeSelector.d.ts +14 -0
  24. package/dist/cli/chat/ThemeSelector.js +29 -0
  25. package/dist/cli/chat/ToolIndicator.d.ts +8 -0
  26. package/dist/cli/chat/ToolIndicator.js +33 -9
  27. package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
  28. package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
  29. package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
  30. package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
  31. package/dist/cli/commands/config-cmd.js +4 -25
  32. package/dist/cli/commands/db.d.ts +13 -0
  33. package/dist/cli/commands/db.js +243 -0
  34. package/dist/cli/commands/doctor.js +6 -9
  35. package/dist/cli/commands/mcp.js +1 -20
  36. package/dist/cli/services/agent-events.d.ts +22 -1
  37. package/dist/cli/services/agent-events.js +9 -0
  38. package/dist/cli/services/agent-loop.js +65 -8
  39. package/dist/cli/services/agent-worker-base.js +21 -6
  40. package/dist/cli/services/api-retry.d.ts +25 -0
  41. package/dist/cli/services/api-retry.js +91 -0
  42. package/dist/cli/services/auth-service.d.ts +1 -1
  43. package/dist/cli/services/auth-service.js +40 -19
  44. package/dist/cli/services/background-processes.js +26 -2
  45. package/dist/cli/services/config-store.d.ts +13 -1
  46. package/dist/cli/services/config-store.js +116 -13
  47. package/dist/cli/services/format-server-response.js +12 -6
  48. package/dist/cli/services/ink-resize-fix.d.ts +18 -0
  49. package/dist/cli/services/ink-resize-fix.js +66 -0
  50. package/dist/cli/services/interactive-tools.d.ts +14 -0
  51. package/dist/cli/services/interactive-tools.js +47 -2
  52. package/dist/cli/services/keybinding-manager.js +1 -1
  53. package/dist/cli/services/local-tools.js +35 -2
  54. package/dist/cli/services/server-tools.js +175 -3
  55. package/dist/cli/services/subagent.js +7 -6
  56. package/dist/cli/services/system-prompt.js +5 -3
  57. package/dist/cli/services/task-decomposer.d.ts +35 -0
  58. package/dist/cli/services/task-decomposer.js +199 -0
  59. package/dist/cli/services/team-lead.d.ts +18 -0
  60. package/dist/cli/services/team-lead.js +80 -0
  61. package/dist/cli/services/teammate.js +5 -5
  62. package/dist/cli/services/telemetry.d.ts +8 -2
  63. package/dist/cli/services/telemetry.js +116 -92
  64. package/dist/cli/services/tools/agent-tools.d.ts +1 -0
  65. package/dist/cli/services/tools/agent-tools.js +50 -4
  66. package/dist/cli/services/tools/file-ops.d.ts +2 -0
  67. package/dist/cli/services/tools/file-ops.js +85 -19
  68. package/dist/cli/services/tools/shell-exec.js +22 -12
  69. package/dist/cli/shared/Theme.d.ts +1 -2
  70. package/dist/cli/shared/Theme.js +1 -1
  71. package/dist/cli/shared/WhaleBanner.d.ts +4 -1
  72. package/dist/cli/shared/WhaleBanner.js +12 -8
  73. package/dist/cli/shared/markdown.d.ts +5 -4
  74. package/dist/cli/shared/markdown.js +376 -334
  75. package/dist/cli/shared/theme-manager.d.ts +27 -0
  76. package/dist/cli/shared/theme-manager.js +178 -0
  77. package/dist/cli/shared/theme-presets.d.ts +16 -0
  78. package/dist/cli/shared/theme-presets.js +265 -0
  79. package/dist/index.js +0 -51
  80. package/dist/node/adapters/imessage.d.ts +10 -0
  81. package/dist/node/adapters/imessage.js +45 -6
  82. package/dist/node/cli.js +459 -8
  83. package/dist/node/config.d.ts +17 -0
  84. package/dist/node/gateway-client.d.ts +55 -0
  85. package/dist/node/gateway-client.js +201 -0
  86. package/dist/node/portal/clipboard.d.ts +28 -0
  87. package/dist/node/portal/clipboard.js +183 -0
  88. package/dist/node/portal/discovery.d.ts +29 -0
  89. package/dist/node/portal/discovery.js +61 -0
  90. package/dist/node/portal/forward.d.ts +30 -0
  91. package/dist/node/portal/forward.js +90 -0
  92. package/dist/node/portal/index.d.ts +47 -0
  93. package/dist/node/portal/index.js +250 -0
  94. package/dist/node/portal/multiplexer.d.ts +48 -0
  95. package/dist/node/portal/multiplexer.js +207 -0
  96. package/dist/node/portal/permissions.d.ts +36 -0
  97. package/dist/node/portal/permissions.js +131 -0
  98. package/dist/node/portal/protocol.d.ts +140 -0
  99. package/dist/node/portal/protocol.js +193 -0
  100. package/dist/node/portal/screen.d.ts +18 -0
  101. package/dist/node/portal/screen.js +93 -0
  102. package/dist/node/portal/session.d.ts +68 -0
  103. package/dist/node/portal/session.js +127 -0
  104. package/dist/node/portal/shell.d.ts +26 -0
  105. package/dist/node/portal/shell.js +142 -0
  106. package/dist/node/portal/stream.d.ts +43 -0
  107. package/dist/node/portal/stream.js +90 -0
  108. package/dist/node/portal/transfer.d.ts +33 -0
  109. package/dist/node/portal/transfer.js +231 -0
  110. package/dist/node/portal/ui.d.ts +16 -0
  111. package/dist/node/portal/ui.js +148 -0
  112. package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
  113. package/dist/node/remote-desktop/compile-helper.js +73 -0
  114. package/dist/node/remote-desktop/index.d.ts +67 -0
  115. package/dist/node/remote-desktop/index.js +220 -0
  116. package/dist/node/remote-desktop/protocol.d.ts +96 -0
  117. package/dist/node/remote-desktop/protocol.js +67 -0
  118. package/dist/node/runtime.d.ts +8 -1
  119. package/dist/node/runtime.js +117 -9
  120. package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
  121. package/dist/server/handlers/__test-utils__/test-db.js +128 -0
  122. package/dist/server/handlers/api-keys.js +26 -2
  123. package/dist/server/handlers/browser.d.ts +0 -4
  124. package/dist/server/handlers/browser.js +0 -46
  125. package/dist/server/handlers/catalog.js +37 -14
  126. package/dist/server/handlers/clickhouse.d.ts +10 -0
  127. package/dist/server/handlers/clickhouse.js +215 -0
  128. package/dist/server/handlers/comms.d.ts +308 -4
  129. package/dist/server/handlers/comms.js +444 -11
  130. package/dist/server/handlers/creations.js +1 -1
  131. package/dist/server/handlers/crm.d.ts +54 -8
  132. package/dist/server/handlers/crm.js +353 -68
  133. package/dist/server/handlers/embeddings.js +3 -3
  134. package/dist/server/handlers/enrichment.js +39 -55
  135. package/dist/server/handlers/inventory.js +1 -1
  136. package/dist/server/handlers/kali.d.ts +9 -1
  137. package/dist/server/handlers/kali.js +50 -1
  138. package/dist/server/handlers/media.d.ts +8 -0
  139. package/dist/server/handlers/media.js +902 -0
  140. package/dist/server/handlers/meta-ads.js +6 -3
  141. package/dist/server/handlers/nodes.d.ts +2 -0
  142. package/dist/server/handlers/nodes.js +331 -40
  143. package/dist/server/handlers/operations.d.ts +4 -6
  144. package/dist/server/handlers/operations.js +99 -38
  145. package/dist/server/handlers/platform.js +224 -107
  146. package/dist/server/handlers/remove-bg.d.ts +6 -0
  147. package/dist/server/handlers/remove-bg.js +96 -0
  148. package/dist/server/handlers/storefront.d.ts +6 -0
  149. package/dist/server/handlers/storefront.js +477 -0
  150. package/dist/server/handlers/supply-chain.js +21 -3
  151. package/dist/server/handlers/workflow-steps.js +87 -31
  152. package/dist/server/handlers/workflows.js +4 -1
  153. package/dist/server/index.js +334 -88
  154. package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
  155. package/dist/server/lib/clickhouse-buffer.js +175 -0
  156. package/dist/server/lib/clickhouse-client.d.ts +112 -0
  157. package/dist/server/lib/clickhouse-client.js +141 -0
  158. package/dist/server/lib/coa-renderer.d.ts +91 -0
  159. package/dist/server/lib/coa-renderer.js +411 -0
  160. package/dist/server/lib/compaction-service.js +46 -1
  161. package/dist/server/lib/pdf-renderer.d.ts +143 -0
  162. package/dist/server/lib/pdf-renderer.js +867 -0
  163. package/dist/server/lib/react-pdf-layout.d.ts +40 -0
  164. package/dist/server/lib/react-pdf-layout.js +437 -0
  165. package/dist/server/lib/server-agent-loop.d.ts +2 -0
  166. package/dist/server/lib/server-agent-loop.js +36 -17
  167. package/dist/server/lib/server-subagent.d.ts +3 -0
  168. package/dist/server/lib/server-subagent.js +9 -6
  169. package/dist/server/lib/supabase-client.js +51 -3
  170. package/dist/server/lib/template-resolver.js +14 -4
  171. package/dist/server/lib/utils.js +15 -0
  172. package/dist/server/local-agent-gateway.d.ts +44 -0
  173. package/dist/server/local-agent-gateway.js +389 -49
  174. package/dist/server/providers/anthropic.js +12 -2
  175. package/dist/server/providers/gemini.js +17 -2
  176. package/dist/server/proxy-handlers.js +151 -0
  177. package/dist/server/tool-router.d.ts +2 -2
  178. package/dist/server/tool-router.js +25 -35
  179. package/dist/shared/agent-core.d.ts +25 -2
  180. package/dist/shared/agent-core.js +66 -5
  181. package/dist/shared/api-client.js +54 -3
  182. package/dist/shared/sse-parser.d.ts +1 -1
  183. package/dist/shared/sse-parser.js +5 -2
  184. package/dist/shared/tool-dispatch.js +15 -1
  185. package/package.json +16 -10
  186. package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
  187. package/dist/server/handlers/__test-utils__/mock-supabase.js +0 -393
@@ -9,26 +9,7 @@
9
9
  * whale mcp remove <name> Remove server
10
10
  * whale mcp get <name> Show server config
11
11
  */
12
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
13
- import { join } from "path";
14
- import { homedir } from "os";
15
- const CONFIG_PATH = join(homedir(), ".swagmanager", "config.json");
16
- function loadConfig() {
17
- if (!existsSync(CONFIG_PATH))
18
- return {};
19
- try {
20
- return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
21
- }
22
- catch {
23
- return {};
24
- }
25
- }
26
- function saveConfig(config) {
27
- const dir = join(homedir(), ".swagmanager");
28
- if (!existsSync(dir))
29
- mkdirSync(dir, { recursive: true });
30
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
31
- }
12
+ import { loadConfig, saveConfig } from "../services/config-store.js";
32
13
  function getServers(config) {
33
14
  return config.mcpServers || {};
34
15
  }
@@ -54,6 +54,18 @@ export interface ToolOutputEvent {
54
54
  toolName: string;
55
55
  line: string;
56
56
  }
57
+ export interface ToolProgressEvent {
58
+ type: "tool_progress";
59
+ toolName: string;
60
+ progress: {
61
+ phase?: string;
62
+ elapsed_s: number;
63
+ stdout_lines: number;
64
+ stderr_lines: number;
65
+ stdout_bytes: number;
66
+ last_line?: string;
67
+ };
68
+ }
57
69
  export interface SubagentStartEvent {
58
70
  type: "subagent_start";
59
71
  id: string;
@@ -132,12 +144,20 @@ export interface TeamDoneEvent {
132
144
  };
133
145
  durationMs: number;
134
146
  }
135
- export type AgentEvent = AgentTextEvent | AgentToolStartEvent | AgentToolEndEvent | AgentUsageEvent | AgentDoneEvent | AgentErrorEvent | AgentCompactEvent | ToolOutputEvent | SubagentStartEvent | SubagentProgressEvent | SubagentDoneEvent | SubagentToolStartEvent | SubagentToolEndEvent | TeamStartEvent | TeamProgressEvent | TeamTaskEvent | TeamDoneEvent;
147
+ export interface AgentThinkingEvent {
148
+ type: "thinking";
149
+ chunkCount: number;
150
+ }
151
+ export type AgentEvent = AgentTextEvent | AgentThinkingEvent | AgentToolStartEvent | AgentToolEndEvent | AgentUsageEvent | AgentDoneEvent | AgentErrorEvent | AgentCompactEvent | ToolOutputEvent | ToolProgressEvent | SubagentStartEvent | SubagentProgressEvent | SubagentDoneEvent | SubagentToolStartEvent | SubagentToolEndEvent | TeamStartEvent | TeamProgressEvent | TeamTaskEvent | TeamDoneEvent;
136
152
  export declare class AgentEventEmitter extends EventEmitter {
137
153
  /**
138
154
  * Emit text immediately — UI-side handles batching via single flush timer
139
155
  */
140
156
  emitText(text: string): void;
157
+ /**
158
+ * Emit thinking chunk count — UI renders activity dots
159
+ */
160
+ emitThinking(chunkCount: number): void;
141
161
  /**
142
162
  * No-op — kept for interface compat
143
163
  */
@@ -149,6 +169,7 @@ export declare class AgentEventEmitter extends EventEmitter {
149
169
  emitError(error: string): void;
150
170
  emitCompact(before: number, after: number, tokensSaved: number): void;
151
171
  emitToolOutput(toolName: string, line: string): void;
172
+ emitToolProgress(toolName: string, progress: ToolProgressEvent["progress"]): void;
152
173
  emitSubagentStart(id: string, agentType: string, model: string, description: string): void;
153
174
  emitSubagentProgress(id: string, agentType: string, message: string, turn?: number, toolName?: string): void;
154
175
  emitSubagentDone(id: string, agentType: string, success: boolean, output: string, tokens: {
@@ -19,6 +19,12 @@ export class AgentEventEmitter extends EventEmitter {
19
19
  accumulated: "", // Set by consumer
20
20
  });
21
21
  }
22
+ /**
23
+ * Emit thinking chunk count — UI renders activity dots
24
+ */
25
+ emitThinking(chunkCount) {
26
+ this.emit("event", { type: "thinking", chunkCount });
27
+ }
22
28
  /**
23
29
  * No-op — kept for interface compat
24
30
  */
@@ -55,6 +61,9 @@ export class AgentEventEmitter extends EventEmitter {
55
61
  emitToolOutput(toolName, line) {
56
62
  this.emit("event", { type: "tool_output", toolName, line });
57
63
  }
64
+ emitToolProgress(toolName, progress) {
65
+ this.emit("event", { type: "tool_progress", toolName, progress });
66
+ }
58
67
  emitSubagentStart(id, agentType, model, description) {
59
68
  this.emit("event", { type: "subagent_start", id, agentType, model, description });
60
69
  }
@@ -17,16 +17,16 @@
17
17
  * - system-prompt.ts (buildSystemPrompt)
18
18
  */
19
19
  import { LOCAL_TOOL_DEFINITIONS, executeLocalTool, isLocalTool, } from "./local-tools.js";
20
- import { INTERACTIVE_TOOL_DEFINITIONS, executeInteractiveTool, } from "./interactive-tools.js";
20
+ import { INTERACTIVE_TOOL_DEFINITIONS, executeInteractiveTool, waitForPlanApproval, } from "./interactive-tools.js";
21
21
  import { loadConfig, resolveConfig, getProxyUrl } from "./config-store.js";
22
22
  import { getValidToken, refreshSession } from "./auth-service.js";
23
23
  import { isServerTool, loadServerToolDefinitions, executeServerTool, getServerStatus, setServerToolContext, } from "./server-tools.js";
24
- import { nextTurn, createTurnContext, logSpan, generateSpanId, } from "./telemetry.js";
24
+ import { nextTurn, createTurnContext, logSpan, generateSpanId, flushCliSpans, } from "./telemetry.js";
25
25
  import { captureError, addBreadcrumb } from "./error-logger.js";
26
26
  import { setGlobalEmitter, clearGlobalEmitter, } from "./agent-events.js";
27
27
  import { mcpClientManager } from "./mcp-client.js";
28
28
  import { loadHooks, runBeforeToolHook, runAfterToolHook, runSessionHook } from "./hooks.js";
29
- import { LoopDetector, COMPACTION_TRIGGER_TOKENS, COMPACTION_TOTAL_BUDGET, getCompactionConfig } from "../../shared/agent-core.js";
29
+ import { LoopDetector, COMPACTION_TRIGGER_TOKENS, COMPACTION_TOTAL_BUDGET, getCompactionConfig, DEFAULT_SESSION_COST_BUDGET_USD, emitCostWarningIfNeeded } from "../../shared/agent-core.js";
30
30
  import { parseSSEStream, processStreamWithCallbacks, collectStreamResult } from "../../shared/sse-parser.js";
31
31
  import { callServerProxy, callTranscribe, buildAPIRequest, buildSystemBlocks, prepareWithCaching, trimGeminiContext, trimOpenAIContext, requestProviderCompaction } from "../../shared/api-client.js";
32
32
  import { getProvider, MODELS } from "../../shared/constants.js";
@@ -35,6 +35,7 @@ import { dispatchTools, buildAssistantContent } from "../../shared/tool-dispatch
35
35
  import { loadMemory, addMemory, removeMemory, listMemories } from "./memory-manager.js";
36
36
  import { refreshGitContext, resetGitContext } from "./git-context.js";
37
37
  import { loadClaudeMd, reloadClaudeMd, resetClaudeMdCache } from "./claude-md-loader.js";
38
+ import { clearReadCache } from "./tools/file-ops.js";
38
39
  import { setPermissionMode, getPermissionMode, isToolAllowedByPermission } from "./permission-modes.js";
39
40
  import { setModel, setModelById, getModel, getModelShortName, estimateCostUsd } from "./model-manager.js";
40
41
  import { saveSession, loadSession, listSessions, findLatestSessionForCwd } from "./session-persistence.js";
@@ -86,6 +87,7 @@ export function resetSessionState() {
86
87
  sessionLoopDetector = null;
87
88
  resetGitContext();
88
89
  resetClaudeMdCache();
90
+ clearReadCache();
89
91
  }
90
92
  /** CLI-only: loop detector — persists session error state across turns (reset by resetSessionState) */
91
93
  let sessionLoopDetector = null;
@@ -305,6 +307,8 @@ export async function runAgentLoop(opts) {
305
307
  });
306
308
  let sessionCostUsd = 0;
307
309
  let compactionCount = 0;
310
+ const costWarningsEmitted = new Set();
311
+ const effectiveBudget = opts.maxBudgetUsd ?? DEFAULT_SESSION_COST_BUDGET_USD;
308
312
  const activeModel = getModel();
309
313
  // Tool executor — routes to interactive, local, server, or MCP tools.
310
314
  // Wraps execution with before/after hooks when hooks are loaded.
@@ -331,6 +335,24 @@ export async function runAgentLoop(opts) {
331
335
  let result;
332
336
  if (INTERACTIVE_TOOL_NAMES.has(name)) {
333
337
  result = await executeInteractiveTool(name, effectiveInput);
338
+ // For exit_plan_mode: wait for UI approval, then map decision to result
339
+ if (name === "exit_plan_mode" && result.success) {
340
+ const decision = await waitForPlanApproval();
341
+ switch (decision.action) {
342
+ case "execute":
343
+ result = { success: true, output: `__PLAN_APPROVED_CLEAN__\n${result.output}` };
344
+ break;
345
+ case "edit":
346
+ result = { success: true, output: "Plan returned for revision. Make changes and use ExitPlanMode again when ready." };
347
+ break;
348
+ case "feedback":
349
+ result = { success: true, output: `User feedback on plan:\n\n${decision.feedback || "(no feedback provided)"}\n\nRevise the plan based on this feedback, then use ExitPlanMode again.` };
350
+ break;
351
+ case "cancel":
352
+ result = { success: true, output: "Plan cancelled by user. Do not proceed with implementation." };
353
+ break;
354
+ }
355
+ }
334
356
  }
335
357
  else if (isLocalTool(name)) {
336
358
  result = await executeLocalTool(name, effectiveInput);
@@ -363,16 +385,16 @@ export async function runAgentLoop(opts) {
363
385
  callbacks.onError("Cancelled", messages);
364
386
  return;
365
387
  }
366
- // Budget enforcement
367
- if (opts.maxBudgetUsd && sessionCostUsd >= opts.maxBudgetUsd) {
368
- logSpan({ action: "chat.budget_exceeded", durationMs: Date.now() - sessionStart, context: turnCtx, storeId: storeId || undefined, severity: "warn", details: { session_cost_usd: sessionCostUsd, max_budget_usd: opts.maxBudgetUsd, iteration } });
369
- callbacks.onError(`Budget exceeded: $${sessionCostUsd.toFixed(4)} >= $${opts.maxBudgetUsd}`, messages);
388
+ // Budget enforcement — always enforced (defaults to DEFAULT_SESSION_COST_BUDGET_USD)
389
+ if (sessionCostUsd >= effectiveBudget) {
390
+ logSpan({ action: "chat.budget_exceeded", durationMs: Date.now() - sessionStart, context: turnCtx, storeId: storeId || undefined, severity: "warn", details: { session_cost_usd: sessionCostUsd, max_budget_usd: effectiveBudget, iteration } });
391
+ callbacks.onError(`Budget exceeded: $${sessionCostUsd.toFixed(4)} >= $${effectiveBudget}`, messages);
370
392
  return;
371
393
  }
372
394
  const apiStart = Date.now();
373
395
  const apiSpanId = generateSpanId();
374
396
  const apiRowId = crypto.randomUUID(); // UUID for this span's row — children reference via parent_id
375
- const costContext = `Session cost: $${sessionCostUsd.toFixed(2)}${opts.maxBudgetUsd ? ` | Budget remaining: $${(opts.maxBudgetUsd - sessionCostUsd).toFixed(2)}` : ""}`;
397
+ const costContext = `Session cost: $${sessionCostUsd.toFixed(2)} | Budget remaining: $${(effectiveBudget - sessionCostUsd).toFixed(2)}`;
376
398
  // Build API request config
377
399
  const currentModel = getModel();
378
400
  const apiConfig = buildAPIRequest({
@@ -449,6 +471,7 @@ export async function runAgentLoop(opts) {
449
471
  },
450
472
  });
451
473
  // Process stream events with UI callbacks
474
+ let thinkingChunks = 0;
452
475
  const result = await processStreamWithCallbacks(parseSSEStream(stream, abortSignal), {
453
476
  onText: (text) => {
454
477
  if (emitter) {
@@ -458,6 +481,10 @@ export async function runAgentLoop(opts) {
458
481
  callbacks.onText(text);
459
482
  }
460
483
  },
484
+ onThinking: () => {
485
+ thinkingChunks++;
486
+ emitter?.emitThinking(thinkingChunks);
487
+ },
461
488
  onToolStart: (name, input) => {
462
489
  // NOTE: Do NOT call callbacks.onToolStart here — dispatchTools.onStart
463
490
  // fires it once per tool at execution time. Calling it here too would
@@ -489,6 +516,13 @@ export async function runAgentLoop(opts) {
489
516
  totalCacheRead += result.usage.cacheReadTokens;
490
517
  totalThinking += result.thinkingTokens;
491
518
  sessionCostUsd += estimateCostUsd(result.usage.inputTokens, result.usage.outputTokens, currentModel, result.thinkingTokens, result.usage.cacheReadTokens, result.usage.cacheCreationTokens);
519
+ // Graduated cost warnings
520
+ emitCostWarningIfNeeded(sessionCostUsd, effectiveBudget, costWarningsEmitted, (text) => { if (emitter) {
521
+ emitter.emitText(text);
522
+ }
523
+ else {
524
+ callbacks.onText(text);
525
+ } });
492
526
  // Server-side context management notification
493
527
  if (result.contextManagementApplied) {
494
528
  callbacks.onAutoCompact?.(messages.length, messages.length, 0);
@@ -616,6 +650,25 @@ export async function runAgentLoop(opts) {
616
650
  compactionContent: result.compactionContent,
617
651
  });
618
652
  messages.push({ role: "assistant", content: assistantContent });
653
+ // Check for __PLAN_APPROVED_CLEAN__ marker — clear context and start fresh with just the plan
654
+ const planCleanMarker = "__PLAN_APPROVED_CLEAN__\n";
655
+ const hasCleanPlanApproval = finalToolResults.some((tr) => {
656
+ const content = typeof tr.content === "string" ? tr.content : "";
657
+ return content.startsWith(planCleanMarker);
658
+ });
659
+ if (hasCleanPlanApproval) {
660
+ // Extract plan content from the marker
661
+ const planResult = finalToolResults.find((tr) => {
662
+ const content = typeof tr.content === "string" ? tr.content : "";
663
+ return content.startsWith(planCleanMarker);
664
+ });
665
+ const planText = planResult.content.slice(planCleanMarker.length);
666
+ const beforeCount = messages.length;
667
+ messages.length = 0;
668
+ messages.push({ role: "user", content: [{ type: "text", text: `Implement this plan:\n\n${planText}` }] });
669
+ callbacks.onAutoCompact?.(beforeCount, 1, 0);
670
+ continue;
671
+ }
619
672
  messages.push({ role: "user", content: finalToolResults });
620
673
  // Non-native compaction for OpenAI/Gemini — fires after tool results appended
621
674
  const compactionCfg = getCompactionConfig(currentModel);
@@ -683,6 +736,8 @@ export async function runAgentLoop(opts) {
683
736
  });
684
737
  const turnCostUsd = estimateCostUsd(totalIn, totalOut, activeModel, totalThinking, totalCacheRead, totalCacheCreation);
685
738
  callbacks.onUsage(totalIn, totalOut, totalThinking, activeModel, turnCostUsd, totalCacheRead, totalCacheCreation);
739
+ // Flush telemetry spans to server before session ends
740
+ flushCliSpans();
686
741
  // Fire SessionEnd hook (non-blocking)
687
742
  if (hooks.length > 0) {
688
743
  runSessionHook(hooks, "SessionEnd", { session_id: `turn-${sessionStart}` }).catch(() => { });
@@ -719,6 +774,8 @@ export async function runAgentLoop(opts) {
719
774
  tags: { model: activeModel, turn: String(turnNum) },
720
775
  });
721
776
  }
777
+ // Flush telemetry on error path too
778
+ flushCliSpans();
722
779
  emitter?.emitError(errorMsg);
723
780
  if (emitter)
724
781
  clearGlobalEmitter();
@@ -13,6 +13,7 @@ import { getProxyUrl, resolveConfig } from "./config-store.js";
13
13
  import { getValidToken, refreshSession } from "./auth-service.js";
14
14
  import { parseSSEStream, collectStreamResult } from "../../shared/sse-parser.js";
15
15
  import { callServerProxy, buildAPIRequest, buildSystemBlocks, } from "../../shared/api-client.js";
16
+ import { addPromptCaching } from "../../shared/agent-core.js";
16
17
  import { isLocalTool, executeLocalTool, } from "./local-tools.js";
17
18
  import { isServerTool, executeServerTool, } from "./server-tools.js";
18
19
  // ============================================================================
@@ -20,7 +21,7 @@ import { isServerTool, executeServerTool, } from "./server-tools.js";
20
21
  // ============================================================================
21
22
  /** Tools that spawn workers/subprocesses and need much longer than 2 min */
22
23
  const LONG_RUNNING_TOOL_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
23
- const LONG_RUNNING_TOOLS = new Set(["team_create", "task", "delegate_task"]);
24
+ const LONG_RUNNING_TOOLS = new Set(["team_create", "task", "delegate_task", "exit_plan_mode", "ask_user_question"]);
24
25
  // ============================================================================
25
26
  // API CLIENT — unified proxy caller
26
27
  // ============================================================================
@@ -87,13 +88,15 @@ export async function callAgentAPI(opts) {
87
88
  tools = [...opts.tools];
88
89
  }
89
90
  const system = buildSystemBlocks(opts.systemPrompt);
91
+ // Apply prompt caching to messages (tools already cached above)
92
+ const { messages: cachedMessages } = addPromptCaching([], opts.messages);
90
93
  const { storeId } = resolveConfig();
91
94
  const stream = await callServerProxy({
92
95
  proxyUrl: getProxyUrl(),
93
96
  token,
94
97
  model: opts.modelId,
95
98
  system,
96
- messages: opts.messages,
99
+ messages: cachedMessages,
97
100
  tools,
98
101
  apiConfig,
99
102
  storeId: storeId || undefined,
@@ -119,7 +122,7 @@ export async function callAgentAPI(opts) {
119
122
  * Returns tool results ready to append to messages, plus metadata.
120
123
  */
121
124
  export async function executeToolBlocks(opts) {
122
- const { toolBlocks, loopDetector, callbacks, customExecutor, maxToolResultChars = 30_000, toolTimeoutMs, } = opts;
125
+ const { toolBlocks, loopDetector, callbacks, customExecutor, maxToolResultChars = 500_000, toolTimeoutMs, } = opts;
123
126
  const toolResults = [];
124
127
  const toolsUsed = [];
125
128
  const customSignals = {};
@@ -187,9 +190,21 @@ export async function executeToolBlocks(opts) {
187
190
  content: contentStr,
188
191
  };
189
192
  }
190
- // Execute all tool calls in parallel — the model already determined these are independent
191
- const batchResults = await Promise.all(toolBlocks.map(tu => executeSingle(tu)));
192
- toolResults.push(...batchResults);
193
+ // Split into file-mutating (sequential) and read-only/server (parallel) batches.
194
+ // File writes/edits must be sequential to prevent race conditions on the same file.
195
+ const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "multi_edit", "notebook_edit", "run_command"]);
196
+ const sequential = toolBlocks.filter(tu => FILE_MUTATING_TOOLS.has(tu.name));
197
+ const parallel = toolBlocks.filter(tu => !FILE_MUTATING_TOOLS.has(tu.name));
198
+ // Run read-only/server tools in parallel
199
+ if (parallel.length > 0) {
200
+ const parallelResults = await Promise.all(parallel.map(tu => executeSingle(tu)));
201
+ toolResults.push(...parallelResults);
202
+ }
203
+ // Run file-mutating tools sequentially
204
+ for (const tu of sequential) {
205
+ const result = await executeSingle(tu);
206
+ toolResults.push(result);
207
+ }
193
208
  return { toolResults, toolsUsed, customSignals };
194
209
  }
195
210
  // ============================================================================
@@ -0,0 +1,25 @@
1
+ /**
2
+ * API Retry Utility — shared exponential backoff for Anthropic API calls.
3
+ *
4
+ * Retries on: 429 (rate limit), 5xx (server error), 529 (overloaded),
5
+ * timeout errors, and network errors.
6
+ *
7
+ * Respects `retry-after` header on 429 responses.
8
+ * 429 gets a 4x delay multiplier since the server is actively rate-limiting.
9
+ */
10
+ export interface RetryOptions {
11
+ maxRetries?: number;
12
+ baseDelayMs?: number;
13
+ maxDelayMs?: number;
14
+ label?: string;
15
+ }
16
+ /**
17
+ * Call an async function with automatic retry and exponential backoff.
18
+ *
19
+ * Usage:
20
+ * const response = await callWithRetry(
21
+ * () => client.messages.create({ ... }),
22
+ * { label: "decomposer" }
23
+ * );
24
+ */
25
+ export declare function callWithRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * API Retry Utility — shared exponential backoff for Anthropic API calls.
3
+ *
4
+ * Retries on: 429 (rate limit), 5xx (server error), 529 (overloaded),
5
+ * timeout errors, and network errors.
6
+ *
7
+ * Respects `retry-after` header on 429 responses.
8
+ * 429 gets a 4x delay multiplier since the server is actively rate-limiting.
9
+ */
10
+ const RETRIABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504, 529]);
11
+ const RETRIABLE_ERROR_PATTERNS = [
12
+ "ECONNRESET",
13
+ "ECONNREFUSED",
14
+ "ETIMEDOUT",
15
+ "ENOTFOUND",
16
+ "fetch failed",
17
+ "network",
18
+ "socket hang up",
19
+ "aborted",
20
+ ];
21
+ function isRetriable(err) {
22
+ if (!err || typeof err !== "object")
23
+ return { retriable: false };
24
+ const e = err;
25
+ // Anthropic SDK errors expose `.status` for HTTP status codes
26
+ const status = e.status ?? e.statusCode;
27
+ if (status && RETRIABLE_STATUS_CODES.has(status)) {
28
+ let retryAfterMs;
29
+ // Check for retry-after header (Anthropic SDK surfaces headers on error)
30
+ const headers = e.headers;
31
+ if (headers?.["retry-after"]) {
32
+ const retryAfterSec = parseFloat(headers["retry-after"]);
33
+ if (!isNaN(retryAfterSec)) {
34
+ retryAfterMs = retryAfterSec * 1000;
35
+ }
36
+ }
37
+ return { retriable: true, statusCode: status, retryAfterMs };
38
+ }
39
+ // Check error message for network-level failures
40
+ const message = (e.message ?? "").toLowerCase();
41
+ const code = e.code ?? "";
42
+ if (RETRIABLE_ERROR_PATTERNS.some(p => message.includes(p.toLowerCase()) || code === p)) {
43
+ return { retriable: true };
44
+ }
45
+ return { retriable: false };
46
+ }
47
+ /**
48
+ * Call an async function with automatic retry and exponential backoff.
49
+ *
50
+ * Usage:
51
+ * const response = await callWithRetry(
52
+ * () => client.messages.create({ ... }),
53
+ * { label: "decomposer" }
54
+ * );
55
+ */
56
+ export async function callWithRetry(fn, opts = {}) {
57
+ const maxRetries = opts.maxRetries ?? 3;
58
+ const baseDelayMs = opts.baseDelayMs ?? 2000;
59
+ const maxDelayMs = opts.maxDelayMs ?? 30000;
60
+ const label = opts.label ?? "api";
61
+ let lastError;
62
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
63
+ try {
64
+ return await fn();
65
+ }
66
+ catch (err) {
67
+ lastError = err;
68
+ if (attempt >= maxRetries)
69
+ break;
70
+ const { retriable, statusCode, retryAfterMs } = isRetriable(err);
71
+ if (!retriable)
72
+ throw err;
73
+ // Exponential backoff: 2s → 8s → 30s
74
+ let delayMs = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
75
+ // 429 gets 4x multiplier — the server is rate-limiting us
76
+ if (statusCode === 429) {
77
+ delayMs = retryAfterMs
78
+ ? Math.min(retryAfterMs, maxDelayMs)
79
+ : Math.min(delayMs * 4, maxDelayMs);
80
+ }
81
+ console.warn(`[${label}] Attempt ${attempt + 1}/${maxRetries + 1} failed` +
82
+ (statusCode ? ` (HTTP ${statusCode})` : "") +
83
+ ` — retrying in ${(delayMs / 1000).toFixed(1)}s: ${err.message?.slice(0, 100)}`);
84
+ await sleep(delayMs);
85
+ }
86
+ }
87
+ throw lastError;
88
+ }
89
+ function sleep(ms) {
90
+ return new Promise(resolve => setTimeout(resolve, ms));
91
+ }
@@ -25,6 +25,6 @@ export declare function signUp(email: string, password: string): Promise<AuthRes
25
25
  export declare function refreshSession(): Promise<AuthResult>;
26
26
  export declare function getValidToken(): Promise<string | null>;
27
27
  export declare function signOut(): void;
28
- export declare function getStoresForUser(accessToken: string, _userId: string): Promise<StoreInfo[]>;
28
+ export declare function getStoresForUser(accessToken: string, userId: string): Promise<StoreInfo[]>;
29
29
  export declare function selectStore(storeId: string, storeName: string): void;
30
30
  export declare function isLoggedIn(): boolean;
@@ -121,33 +121,54 @@ export function signOut() {
121
121
  clearConfig();
122
122
  }
123
123
  // Get stores for the authenticated user
124
- // RLS on the stores table filters to only stores the user has access to,
125
- // same as the Swift app does: client.from("stores").select()
126
- export async function getStoresForUser(accessToken, _userId) {
124
+ // With a user JWT, RLS on the stores table handles access control directly
125
+ // same approach the platform exchange endpoint uses. For service role keys,
126
+ // we fall back to store_members to prevent cross-tenant leakage.
127
+ export async function getStoresForUser(accessToken, userId) {
128
+ if (!userId)
129
+ return [];
127
130
  const client = createAuthenticatedClient(accessToken);
128
- // Let RLS filtersame pattern as the macOS app (SupabaseService.fetchStores)
129
- const { data: stores, error } = await client
131
+ // Primary: query stores directly RLS filters to user's stores
132
+ const { data: stores, error: storeError } = await client
130
133
  .from("stores")
131
134
  .select("id, store_name, slug")
132
135
  .limit(20);
133
- if (error) {
134
- // Fallback: try via the users table (auth_user_id column, not id)
135
- const { data: userData } = await client
136
- .from("users")
137
- .select("store_id")
138
- .eq("auth_user_id", _userId)
139
- .single();
140
- if (userData?.store_id) {
141
- const { data: store } = await client
136
+ if (!storeError && stores && stores.length > 0) {
137
+ return stores.map((s) => ({ id: s.id, name: s.store_name, slug: s.slug }));
138
+ }
139
+ // Fallback 1: try via users table (auth_user_id → store_id)
140
+ const { data: userData } = await client
141
+ .from("users")
142
+ .select("store_id")
143
+ .eq("auth_user_id", userId);
144
+ if (userData && userData.length > 0) {
145
+ const storeIds = userData.map((u) => u.store_id).filter(Boolean);
146
+ if (storeIds.length > 0) {
147
+ const { data: userStores } = await client
142
148
  .from("stores")
143
149
  .select("id, store_name, slug")
144
- .eq("id", userData.store_id)
145
- .single();
146
- return store ? [{ id: store.id, name: store.store_name, slug: store.slug }] : [];
150
+ .in("id", storeIds);
151
+ if (userStores && userStores.length > 0) {
152
+ return userStores.map((s) => ({ id: s.id, name: s.store_name, slug: s.slug }));
153
+ }
154
+ }
155
+ }
156
+ // Fallback 2: store_members table
157
+ const { data: memberships } = await client
158
+ .from("store_members")
159
+ .select("store_id")
160
+ .eq("user_id", userId);
161
+ if (memberships && memberships.length > 0) {
162
+ const storeIds = memberships.map((m) => m.store_id);
163
+ const { data: memberStores } = await client
164
+ .from("stores")
165
+ .select("id, store_name, slug")
166
+ .in("id", storeIds);
167
+ if (memberStores && memberStores.length > 0) {
168
+ return memberStores.map((s) => ({ id: s.id, name: s.store_name, slug: s.slug }));
147
169
  }
148
- return [];
149
170
  }
150
- return (stores || []).map((s) => ({ id: s.id, name: s.store_name, slug: s.slug }));
171
+ return [];
151
172
  }
152
173
  // Select a store and save to config
153
174
  export function selectStore(storeId, storeName) {
@@ -250,8 +250,32 @@ export function readAgentOutput(id) {
250
250
  if (!agent)
251
251
  return null;
252
252
  try {
253
- const content = existsSync(agent.outputFile) ? readFileSync(agent.outputFile, "utf-8") : "(no output yet)";
254
- return { status: agent.status, output: content };
253
+ const raw = existsSync(agent.outputFile) ? readFileSync(agent.outputFile, "utf-8") : "";
254
+ if (!raw)
255
+ return { status: agent.status, output: "(no output yet)" };
256
+ // Parse ---DONE--- sentinel: extract .output from the JSON blob after it
257
+ const doneIdx = raw.indexOf("\n---DONE---\n");
258
+ if (doneIdx !== -1) {
259
+ try {
260
+ const json = JSON.parse(raw.slice(doneIdx + "\n---DONE---\n".length).trim());
261
+ const clean = (json.output || json.result || json.message || "").toString().trim();
262
+ return { status: agent.status, output: clean || raw.slice(0, doneIdx).trim() };
263
+ }
264
+ catch {
265
+ // JSON parse failed — return content before sentinel
266
+ return { status: agent.status, output: raw.slice(0, doneIdx).trim() };
267
+ }
268
+ }
269
+ // Parse ---ERROR--- sentinel
270
+ const errIdx = raw.indexOf("\n---ERROR---\n");
271
+ if (errIdx !== -1) {
272
+ const errMsg = raw.slice(errIdx + "\n---ERROR---\n".length).trim();
273
+ return { status: agent.status, output: errMsg || raw.slice(0, errIdx).trim() };
274
+ }
275
+ // Still running — strip the "Agent X started" header line
276
+ const lines = raw.split("\n");
277
+ const content = lines[0]?.startsWith("Agent ") ? lines.slice(1).join("\n").trim() : raw.trim();
278
+ return { status: agent.status, output: content || "(running...)" };
255
279
  }
256
280
  catch {
257
281
  return { status: agent.status, output: "(failed to read output file)" };
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Config Store
3
3
  *
4
- * Persistent configuration at ~/.swagmanager/config.json
4
+ * Unified auth session at ~/.whaletools/session.json
5
+ * User preferences at ~/.whaletools/preferences.json
5
6
  *
6
7
  * v2.0: Raw Supabase/Anthropic keys (for MCP server env vars)
7
8
  * v2.1: Auth tokens from login flow (for CLI chat/status)
9
+ * v4.0: Shared auth with Swift apps via ~/.whaletools/session.json
8
10
  *
9
11
  * Environment variables always override file-based config for MCP server mode.
10
12
  */
@@ -26,10 +28,20 @@ export interface SwagManagerConfig {
26
28
  agent_api_key?: string;
27
29
  platform_url?: string;
28
30
  }
31
+ /** Preferences that survive sign-out (stored in preferences.json) */
32
+ export interface WhalePreferences {
33
+ default_model?: string;
34
+ thinking_enabled?: boolean;
35
+ permission_mode?: string;
36
+ platform_url?: string;
37
+ theme?: string;
38
+ }
29
39
  export declare function loadConfig(): SwagManagerConfig;
30
40
  export declare function saveConfig(config: SwagManagerConfig): void;
31
41
  export declare function updateConfig(partial: Partial<SwagManagerConfig>): void;
32
42
  export declare function clearConfig(): void;
43
+ export declare function loadPreferences(): WhalePreferences;
44
+ export declare function savePreferences(prefs: WhalePreferences): void;
33
45
  export interface ResolvedConfig {
34
46
  supabaseUrl: string;
35
47
  supabaseKey: string;