@tjamescouch/gro 1.3.2 → 1.3.4

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Couch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tjamescouch/gro",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "Provider-agnostic LLM runtime with context management",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -7,7 +7,7 @@ import { rateLimiter } from "../utils/rate-limiter.js";
7
7
  import { timedFetch } from "../utils/timed-fetch.js";
8
8
  import { MAX_RETRIES, isRetryable, retryDelay, sleep } from "../utils/retry.js";
9
9
  import { groError, asError, isGroError, errorLogFields } from "../errors.js";
10
- import type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall } from "./types.js";
10
+ import type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall, TokenUsage } from "./types.js";
11
11
 
12
12
  export interface AnthropicDriverConfig {
13
13
  apiKey: string;
@@ -26,12 +26,14 @@ function convertToolDefs(tools: any[]): any[] {
26
26
  return tools.map(t => {
27
27
  if (t.type === "function" && t.function) {
28
28
  return {
29
+ type: "custom",
29
30
  name: t.function.name,
30
31
  description: t.function.description || "",
31
32
  input_schema: t.function.parameters || { type: "object", properties: {} },
32
33
  };
33
34
  }
34
- // Already in Anthropic format — pass through
35
+ // Already in Anthropic format — ensure type is set
36
+ if (!t.type) return { type: "custom", ...t };
35
37
  return t;
36
38
  });
37
39
  }
@@ -105,6 +107,40 @@ function convertMessages(messages: ChatMessage[]): { system: string | undefined;
105
107
  return { system: systemPrompt, apiMessages };
106
108
  }
107
109
 
110
+ /** Pattern matching transient network errors that should be retried */
111
+ const TRANSIENT_ERROR_RE = /fetch timeout|fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN|socket hang up/i;
112
+
113
+ /** Parse response content blocks into text + tool calls + token usage */
114
+ function parseResponseContent(data: any, onToken?: (t: string) => void): ChatOutput {
115
+ let text = "";
116
+ const toolCalls: ChatToolCall[] = [];
117
+
118
+ for (const block of data.content ?? []) {
119
+ if (block.type === "text") {
120
+ text += block.text;
121
+ if (onToken) {
122
+ try { onToken(block.text); } catch {}
123
+ }
124
+ } else if (block.type === "tool_use") {
125
+ toolCalls.push({
126
+ id: block.id,
127
+ type: "custom",
128
+ function: {
129
+ name: block.name,
130
+ arguments: JSON.stringify(block.input),
131
+ },
132
+ });
133
+ }
134
+ }
135
+
136
+ const usage: TokenUsage | undefined = data.usage ? {
137
+ inputTokens: data.usage.input_tokens ?? 0,
138
+ outputTokens: data.usage.output_tokens ?? 0,
139
+ } : undefined;
140
+
141
+ return { text, toolCalls, usage };
142
+ }
143
+
108
144
  export function makeAnthropicDriver(cfg: AnthropicDriverConfig): ChatDriver {
109
145
  const base = (cfg.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
110
146
  const endpoint = `${base}/v1/messages`;
@@ -122,6 +158,9 @@ export function makeAnthropicDriver(cfg: AnthropicDriverConfig): ChatDriver {
122
158
 
123
159
  const body: any = {
124
160
  model: resolvedModel,
161
+ thinking: {
162
+ type: "adaptive"
163
+ },
125
164
  max_tokens: maxTokens,
126
165
  messages: apiMessages,
127
166
  };
@@ -174,32 +213,59 @@ export function makeAnthropicDriver(cfg: AnthropicDriverConfig): ChatDriver {
174
213
  }
175
214
 
176
215
  const data = await res.json() as any;
216
+ return parseResponseContent(data, onToken);
217
+ } catch (e: unknown) {
218
+ if (isGroError(e)) throw e; // already wrapped above
219
+
220
+ // Classify the error: fetch timeouts and network errors are transient
221
+ const errMsg = asError(e).message;
222
+ const isTransient = TRANSIENT_ERROR_RE.test(errMsg);
223
+
224
+ if (isTransient) {
225
+ // Retry transient network errors (e.g. auth proxy down during container restart)
226
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
227
+ const delay = retryDelay(attempt);
228
+ Logger.warn(`Transient error: ${errMsg.substring(0, 120)}, retry ${attempt + 1}/${MAX_RETRIES} in ${Math.round(delay)}ms`);
229
+ await sleep(delay);
230
+
231
+ try {
232
+ const retryRes = await timedFetch(endpoint, {
233
+ method: "POST",
234
+ headers,
235
+ body: JSON.stringify(body),
236
+ where: "driver:anthropic",
237
+ timeoutMs,
238
+ });
177
239
 
178
- let text = "";
179
- const toolCalls: ChatToolCall[] = [];
240
+ if (!retryRes.ok) {
241
+ const text = await retryRes.text().catch(() => "");
242
+ if (isRetryable(retryRes.status) && attempt < MAX_RETRIES - 1) continue;
243
+ throw groError("provider_error", `Anthropic API failed (${retryRes.status}): ${text}`, {
244
+ provider: "anthropic", model: resolvedModel, retryable: false, cause: new Error(text),
245
+ });
246
+ }
180
247
 
181
- for (const block of data.content ?? []) {
182
- if (block.type === "text") {
183
- text += block.text;
184
- if (onToken) {
185
- try { onToken(block.text); } catch {}
248
+ // Success on retry parse and return
249
+ const data = await retryRes.json() as any;
250
+ Logger.info(`Recovered from transient error after ${attempt + 1} retries`);
251
+ return parseResponseContent(data, onToken);
252
+ } catch (retryErr: unknown) {
253
+ if (isGroError(retryErr)) throw retryErr;
254
+ if (attempt === MAX_RETRIES - 1) {
255
+ // Exhausted retries — throw with context
256
+ const ge = groError("provider_error", `Anthropic driver error (after ${MAX_RETRIES} retries): ${errMsg}`, {
257
+ provider: "anthropic", model: resolvedModel, request_id: requestId,
258
+ retryable: false, cause: e,
259
+ });
260
+ Logger.error("Anthropic driver error (retries exhausted):", errorLogFields(ge));
261
+ throw ge;
262
+ }
186
263
  }
187
- } else if (block.type === "tool_use") {
188
- toolCalls.push({
189
- id: block.id,
190
- type: "custom",
191
- function: {
192
- name: block.name,
193
- arguments: JSON.stringify(block.input),
194
- },
195
- });
196
264
  }
197
265
  }
198
266
 
199
- return { text, toolCalls };
200
- } catch (e: unknown) {
201
- if (isGroError(e)) throw e; // already wrapped above
202
- const ge = groError("provider_error", `Anthropic driver error: ${asError(e).message}`, {
267
+ // Non-transient error throw immediately
268
+ const ge = groError("provider_error", `Anthropic driver error: ${errMsg}`, {
203
269
  provider: "anthropic",
204
270
  model: resolvedModel,
205
271
  request_id: requestId,
@@ -1,4 +1,4 @@
1
- export type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall } from "./types.js";
1
+ export type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall, TokenUsage } from "./types.js";
2
2
  export { makeStreamingOpenAiDriver } from "./streaming-openai.js";
3
3
  export type { OpenAiDriverConfig } from "./streaming-openai.js";
4
4
  export { makeAnthropicDriver } from "./anthropic.js";
@@ -7,7 +7,7 @@ import { asError } from "../errors.js";
7
7
  import { rateLimiter } from "../utils/rate-limiter.js";
8
8
  import { timedFetch } from "../utils/timed-fetch.js";
9
9
  import { MAX_RETRIES, isRetryable, retryDelay, sleep } from "../utils/retry.js";
10
- import type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall } from "./types.js";
10
+ import type { ChatDriver, ChatMessage, ChatOutput, ChatToolCall, TokenUsage } from "./types.js";
11
11
 
12
12
  export interface OpenAiDriverConfig {
13
13
  baseUrl: string;
@@ -108,7 +108,11 @@ export function makeStreamingOpenAiDriver(cfg: OpenAiDriverConfig): ChatDriver {
108
108
  const content = typeof msg?.content === "string" ? msg.content : "";
109
109
  const toolCalls: ChatToolCall[] = Array.isArray(msg?.tool_calls) ? msg.tool_calls : [];
110
110
  if (content && onToken) onToken(content);
111
- return { text: content, reasoning: msg?.reasoning || undefined, toolCalls };
111
+ const usage: TokenUsage | undefined = data?.usage ? {
112
+ inputTokens: data.usage.prompt_tokens ?? 0,
113
+ outputTokens: data.usage.completion_tokens ?? 0,
114
+ } : undefined;
115
+ return { text: content, reasoning: msg?.reasoning || undefined, toolCalls, usage };
112
116
  }
113
117
 
114
118
  // SSE streaming
@@ -117,6 +121,7 @@ export function makeStreamingOpenAiDriver(cfg: OpenAiDriverConfig): ChatDriver {
117
121
  let buf = "";
118
122
  let fullText = "";
119
123
  let fullReasoning = "";
124
+ let streamUsage: TokenUsage | undefined;
120
125
  const toolByIndex = new Map<number, ChatToolCall>();
121
126
 
122
127
  const pumpEvent = async (rawEvent: string) => {
@@ -133,6 +138,14 @@ export function makeStreamingOpenAiDriver(cfg: OpenAiDriverConfig): ChatDriver {
133
138
  let payload: any;
134
139
  try { payload = JSON.parse(joined); } catch { return; }
135
140
 
141
+ // Capture usage from final streaming chunk (if stream_options.include_usage was set)
142
+ if (payload?.usage) {
143
+ streamUsage = {
144
+ inputTokens: payload.usage.prompt_tokens ?? 0,
145
+ outputTokens: payload.usage.completion_tokens ?? 0,
146
+ };
147
+ }
148
+
136
149
  const delta = payload?.choices?.[0]?.delta;
137
150
  if (!delta) return;
138
151
 
@@ -230,7 +243,7 @@ export function makeStreamingOpenAiDriver(cfg: OpenAiDriverConfig): ChatDriver {
230
243
  .sort((a, b) => a[0] - b[0])
231
244
  .map(([, v]) => v);
232
245
 
233
- return { text: fullText, reasoning: fullReasoning || undefined, toolCalls };
246
+ return { text: fullText, reasoning: fullReasoning || undefined, toolCalls, usage: streamUsage };
234
247
  } catch (e: unknown) {
235
248
  const wrapped = asError(e);
236
249
  if (wrapped.name === "AbortError") Logger.debug("timeout(stream)", { ms: defaultTimeout });
@@ -16,10 +16,16 @@ export interface ChatToolCall {
16
16
  raw?: string;
17
17
  }
18
18
 
19
+ export interface TokenUsage {
20
+ inputTokens: number;
21
+ outputTokens: number;
22
+ }
23
+
19
24
  export interface ChatOutput {
20
25
  text: string;
21
26
  toolCalls: ChatToolCall[];
22
27
  reasoning?: string;
28
+ usage?: TokenUsage;
23
29
  }
24
30
 
25
31
  export interface ChatDriver {
package/src/main.ts CHANGED
@@ -21,13 +21,23 @@ import { McpManager } from "./mcp/index.js";
21
21
  import { newSessionId, findLatestSession, loadSession, ensureGroDir } from "./session.js";
22
22
  import { groError, asError, isGroError, errorLogFields } from "./errors.js";
23
23
  import type { McpServerConfig } from "./mcp/index.js";
24
- import type { ChatDriver, ChatMessage, ChatOutput } from "./drivers/types.js";
24
+ import type { ChatDriver, ChatMessage, ChatOutput, TokenUsage } from "./drivers/types.js";
25
25
  import type { AgentMemory } from "./memory/agent-memory.js";
26
26
  import { bashToolDefinition, executeBash } from "./tools/bash.js";
27
27
  import { agentpatchToolDefinition, executeAgentpatch } from "./tools/agentpatch.js";
28
28
 
29
29
  const VERSION = "0.3.1";
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Graceful shutdown state — module-level so signal handlers can save sessions.
33
+ // ---------------------------------------------------------------------------
34
+ let _shutdownMemory: AgentMemory | null = null;
35
+ let _shutdownSessionId: string | null = null;
36
+ let _shutdownSessionPersistence = false;
37
+
38
+ /** Auto-save interval: save session every N tool rounds in persistent mode */
39
+ const AUTO_SAVE_INTERVAL = 10;
40
+
31
41
  // Wake notes: a runner-global file that is prepended to the system prompt on process start
32
42
  // so agents reliably see dev workflow + memory pointers on wake.
33
43
  const WAKE_NOTES_DEFAULT_PATH = join(process.env.HOME || "", ".claude", "WAKE.md");
@@ -45,6 +55,7 @@ interface GroConfig {
45
55
  wakeNotes: string;
46
56
  wakeNotesEnabled: boolean;
47
57
  contextTokens: number;
58
+ maxTokens: number;
48
59
  interactive: boolean;
49
60
  print: boolean;
50
61
  maxToolRounds: number;
@@ -158,6 +169,7 @@ function loadConfig(): GroConfig {
158
169
  else if (arg === "--wake-notes") { flags.wakeNotes = args[++i]; }
159
170
  else if (arg === "--no-wake-notes") { flags.noWakeNotes = "true"; }
160
171
  else if (arg === "--context-tokens") { flags.contextTokens = args[++i]; }
172
+ else if (arg === "--max-tokens") { flags.maxTokens = args[++i]; }
161
173
  else if (arg === "--max-tool-rounds" || arg === "--max-turns") { flags.maxToolRounds = args[++i]; }
162
174
  else if (arg === "--bash") { flags.bash = "true"; }
163
175
  else if (arg === "--persistent" || arg === "--keep-alive") { flags.persistent = "true"; }
@@ -277,6 +289,7 @@ ${systemPrompt}` : wake;
277
289
  wakeNotes: flags.wakeNotes || WAKE_NOTES_DEFAULT_PATH,
278
290
  wakeNotesEnabled: flags.noWakeNotes !== "true",
279
291
  contextTokens: parseInt(flags.contextTokens || "8192"),
292
+ maxTokens: parseInt(flags.maxTokens || "16384"),
280
293
  interactive: interactiveMode,
281
294
  print: printMode,
282
295
  maxToolRounds: parseInt(flags.maxToolRounds || "10"),
@@ -351,6 +364,7 @@ options:
351
364
  --wake-notes path to wake notes file (default: ~/.claude/WAKE.md)
352
365
  --no-wake-notes disable auto-prepending wake notes
353
366
  --context-tokens context window budget (default: 8192)
367
+ --max-tokens max response tokens per turn (default: 16384)
354
368
  --max-turns max agentic rounds per turn (default: 10)
355
369
  --max-tool-rounds alias for --max-turns
356
370
  --bash enable built-in bash tool for shell command execution
@@ -381,6 +395,7 @@ function createDriverForModel(
381
395
  model: string,
382
396
  apiKey: string,
383
397
  baseUrl: string,
398
+ maxTokens?: number,
384
399
  ): ChatDriver {
385
400
  switch (provider) {
386
401
  case "anthropic":
@@ -388,7 +403,7 @@ function createDriverForModel(
388
403
  Logger.error("gro: ANTHROPIC_API_KEY not set (set ANTHROPIC_BASE_URL for proxy mode)");
389
404
  process.exit(1);
390
405
  }
391
- return makeAnthropicDriver({ apiKey: apiKey || "proxy-managed", model, baseUrl });
406
+ return makeAnthropicDriver({ apiKey: apiKey || "proxy-managed", model, baseUrl, maxTokens });
392
407
 
393
408
  case "openai":
394
409
  if (!apiKey && baseUrl === "https://api.openai.com") {
@@ -408,7 +423,7 @@ function createDriverForModel(
408
423
  }
409
424
 
410
425
  function createDriver(cfg: GroConfig): ChatDriver {
411
- return createDriverForModel(cfg.provider, cfg.model, cfg.apiKey, cfg.baseUrl);
426
+ return createDriverForModel(cfg.provider, cfg.model, cfg.apiKey, cfg.baseUrl, cfg.maxTokens);
412
427
  }
413
428
 
414
429
  // ---------------------------------------------------------------------------
@@ -477,11 +492,14 @@ async function executeTurn(
477
492
  memory: AgentMemory,
478
493
  mcp: McpManager,
479
494
  cfg: GroConfig,
495
+ sessionId?: string,
480
496
  ): Promise<string> {
481
497
  const tools = mcp.getToolDefinitions();
482
498
  tools.push(agentpatchToolDefinition());
483
499
  if (cfg.bash) tools.push(bashToolDefinition());
484
500
  let finalText = "";
501
+ let turnTokensIn = 0;
502
+ let turnTokensOut = 0;
485
503
 
486
504
  const onToken = cfg.outputFormat === "stream-json"
487
505
  ? (t: string) => process.stdout.write(JSON.stringify({ type: "token", token: t }) + "\n")
@@ -496,6 +514,14 @@ async function executeTurn(
496
514
  onToken,
497
515
  });
498
516
 
517
+ // Track token usage for niki budget enforcement
518
+ if (output.usage) {
519
+ turnTokensIn += output.usage.inputTokens;
520
+ turnTokensOut += output.usage.outputTokens;
521
+ // Log cumulative usage to stderr — niki parses these patterns for budget enforcement
522
+ process.stderr.write(`"input_tokens": ${turnTokensIn}, "output_tokens": ${turnTokensOut}\n`);
523
+ }
524
+
499
525
  // Accumulate text
500
526
  if (output.text) finalText += output.text;
501
527
 
@@ -540,7 +566,8 @@ async function executeTurn(
540
566
  let fnArgs: Record<string, any>;
541
567
  try {
542
568
  fnArgs = JSON.parse(tc.function.arguments);
543
- } catch {
569
+ } catch (e: unknown) {
570
+ Logger.debug(`Failed to parse args for ${fnName}: ${asError(e).message}, using empty args`);
544
571
  fnArgs = {};
545
572
  }
546
573
 
@@ -556,12 +583,14 @@ async function executeTurn(
556
583
  result = await mcp.callTool(fnName, fnArgs);
557
584
  }
558
585
  } catch (e: unknown) {
559
- const ge = groError("tool_error", `Tool "${fnName}" failed: ${asError(e).message}`, {
586
+ const raw = asError(e);
587
+ const ge = groError("tool_error", `Tool "${fnName}" failed: ${raw.message}`, {
560
588
  retryable: false,
561
589
  cause: e,
562
590
  });
563
591
  Logger.error("Tool execution error:", errorLogFields(ge));
564
- result = `Error: ${ge.message}`;
592
+ if (raw.stack) Logger.error(raw.stack);
593
+ result = `Error: ${ge.message}${raw.stack ? '\nStack: ' + raw.stack : ''}`;
565
594
  }
566
595
 
567
596
  // Feed tool result back into memory
@@ -573,6 +602,16 @@ async function executeTurn(
573
602
  name: fnName,
574
603
  });
575
604
  }
605
+
606
+ // Auto-save periodically in persistent mode to survive SIGTERM/crashes
607
+ if (cfg.persistent && cfg.sessionPersistence && sessionId && round > 0 && round % AUTO_SAVE_INTERVAL === 0) {
608
+ try {
609
+ await memory.save(sessionId);
610
+ Logger.debug(`Auto-saved session ${sessionId} at round ${round}`);
611
+ } catch (e: unknown) {
612
+ Logger.warn(`Auto-save failed at round ${round}: ${asError(e).message}`);
613
+ }
614
+ }
576
615
  }
577
616
 
578
617
  // If we exhausted maxToolRounds (loop didn't break via no-tool-calls),
@@ -583,6 +622,11 @@ async function executeTurn(
583
622
  model: cfg.model,
584
623
  onToken,
585
624
  });
625
+ if (finalOutput.usage) {
626
+ turnTokensIn += finalOutput.usage.inputTokens;
627
+ turnTokensOut += finalOutput.usage.outputTokens;
628
+ process.stderr.write(`"input_tokens": ${turnTokensIn}, "output_tokens": ${turnTokensOut}\n`);
629
+ }
586
630
  if (finalOutput.text) finalText += finalOutput.text;
587
631
  await memory.add({ role: "assistant", from: "Assistant", content: finalOutput.text || "" });
588
632
  }
@@ -619,6 +663,11 @@ async function singleShot(
619
663
 
620
664
  const memory = createMemory(cfg, driver);
621
665
 
666
+ // Register for graceful shutdown
667
+ _shutdownMemory = memory;
668
+ _shutdownSessionId = sessionId;
669
+ _shutdownSessionPersistence = cfg.sessionPersistence;
670
+
622
671
  // Resume existing session if requested
623
672
  if (cfg.continueSession || cfg.resumeSession) {
624
673
  await memory.load(sessionId);
@@ -627,11 +676,13 @@ async function singleShot(
627
676
  await memory.add({ role: "user", from: "User", content: prompt });
628
677
 
629
678
  let text: string | undefined;
679
+ let fatalError = false;
630
680
  try {
631
- text = await executeTurn(driver, memory, mcp, cfg);
681
+ text = await executeTurn(driver, memory, mcp, cfg, sessionId);
632
682
  } catch (e: unknown) {
633
683
  const ge = isGroError(e) ? e : groError("provider_error", asError(e).message, { cause: e });
634
684
  Logger.error(C.red(`error: ${ge.message}`), errorLogFields(ge));
685
+ fatalError = true;
635
686
  }
636
687
 
637
688
  // Save session (even on error — preserve conversation state)
@@ -643,6 +694,12 @@ async function singleShot(
643
694
  }
644
695
  }
645
696
 
697
+ // Exit with non-zero code on fatal API errors so the supervisor
698
+ // can distinguish "finished cleanly" from "crashed on API call"
699
+ if (fatalError) {
700
+ process.exit(1);
701
+ }
702
+
646
703
  if (text) {
647
704
  if (cfg.outputFormat === "json") {
648
705
  process.stdout.write(formatOutput(text, "json") + "\n");
@@ -661,6 +718,11 @@ async function interactive(
661
718
  const memory = createMemory(cfg, driver);
662
719
  const readline = await import("readline");
663
720
 
721
+ // Register for graceful shutdown
722
+ _shutdownMemory = memory;
723
+ _shutdownSessionId = sessionId;
724
+ _shutdownSessionPersistence = cfg.sessionPersistence;
725
+
664
726
  // Resume existing session if requested
665
727
  if (cfg.continueSession || cfg.resumeSession) {
666
728
  await memory.load(sessionId);
@@ -691,7 +753,7 @@ async function interactive(
691
753
 
692
754
  try {
693
755
  await memory.add({ role: "user", from: "User", content: input });
694
- await executeTurn(driver, memory, mcp, cfg);
756
+ await executeTurn(driver, memory, mcp, cfg, sessionId);
695
757
  } catch (e: unknown) {
696
758
  const ge = isGroError(e) ? e : groError("provider_error", asError(e).message, { cause: e });
697
759
  Logger.error(C.red(`error: ${ge.message}`), errorLogFields(ge));
@@ -774,7 +836,7 @@ async function main() {
774
836
  "--provider", "-P", "--model", "-m", "--base-url",
775
837
  "--system-prompt", "--system-prompt-file",
776
838
  "--append-system-prompt", "--append-system-prompt-file",
777
- "--context-tokens", "--max-tool-rounds", "--max-turns",
839
+ "--context-tokens", "--max-tokens", "--max-tool-rounds", "--max-turns",
778
840
  "--max-thinking-tokens", "--max-budget-usd",
779
841
  "--summarizer-model", "--output-format", "--mcp-config",
780
842
  "--resume", "-r",
@@ -808,20 +870,32 @@ async function main() {
808
870
  }
809
871
  }
810
872
 
811
- // Graceful shutdown on signals
873
+ // Graceful shutdown on signals — save session before exiting
812
874
  for (const sig of ["SIGTERM", "SIGHUP"] as const) {
813
- process.on(sig, () => {
814
- Logger.info(C.gray(`\nreceived ${sig}, shutting down...`));
875
+ process.on(sig, async () => {
876
+ Logger.info(C.gray(`\nreceived ${sig}, saving session and shutting down...`));
877
+ if (_shutdownMemory && _shutdownSessionId && _shutdownSessionPersistence) {
878
+ try {
879
+ await _shutdownMemory.save(_shutdownSessionId);
880
+ Logger.info(C.gray(`session ${_shutdownSessionId} saved on ${sig}`));
881
+ } catch (e: unknown) {
882
+ Logger.error(C.red(`session save on ${sig} failed: ${asError(e).message}`));
883
+ }
884
+ }
815
885
  process.exit(0);
816
886
  });
817
887
  }
818
888
 
819
889
  // Catch unhandled promise rejections (e.g. background summarization)
820
890
  process.on("unhandledRejection", (reason: unknown) => {
821
- Logger.error(C.red(`unhandled rejection: ${asError(reason).message}`));
891
+ const err = asError(reason);
892
+ Logger.error(C.red(`unhandled rejection: ${err.message}`));
893
+ if (err.stack) Logger.error(C.red(err.stack));
822
894
  });
823
895
 
824
896
  main().catch((e: unknown) => {
825
- Logger.error("gro:", asError(e).message);
897
+ const err = asError(e);
898
+ Logger.error("gro:", err.message);
899
+ if (err.stack) Logger.error(err.stack);
826
900
  process.exit(1);
827
901
  });
package/src/mcp/client.ts CHANGED
@@ -109,20 +109,32 @@ export class McpManager {
109
109
  for (const server of this.servers.values()) {
110
110
  const tool = server.tools.find(t => t.name === name);
111
111
  if (tool) {
112
- const result = await server.client.callTool({ name, arguments: args }, undefined, { timeout: 5 * 60 * 1000 });
113
- // Extract text content from result
114
- if (Array.isArray(result.content)) {
115
- return result.content
116
- .map((c: any) => {
117
- if (c.type === "text") return c.text;
118
- return JSON.stringify(c);
119
- })
120
- .join("\n");
112
+ try {
113
+ const result = await server.client.callTool({ name, arguments: args }, undefined, { timeout: 5 * 60 * 1000 });
114
+ // Extract text content from result
115
+ if (Array.isArray(result.content)) {
116
+ return result.content
117
+ .map((c: any) => {
118
+ if (c.type === "text") return c.text;
119
+ return JSON.stringify(c);
120
+ })
121
+ .join("\n");
122
+ }
123
+ return JSON.stringify(result);
124
+ } catch (e: unknown) {
125
+ const err = asError(e);
126
+ const ge = groError("mcp_error", `MCP tool "${name}" (server: ${server.name}) failed: ${err.message}`, {
127
+ retryable: true,
128
+ cause: e,
129
+ });
130
+ Logger.error(`MCP tool call failed [${server.name}/${name}]:`, errorLogFields(ge));
131
+ throw ge;
121
132
  }
122
- return JSON.stringify(result);
123
133
  }
124
134
  }
125
- throw new Error(`No MCP server provides tool "${name}"`);
135
+ const ge = groError("mcp_error", `No MCP server provides tool "${name}"`, { retryable: false });
136
+ Logger.error(ge.message, errorLogFields(ge));
137
+ throw ge;
126
138
  }
127
139
 
128
140
  /**
@@ -140,7 +152,11 @@ export class McpManager {
140
152
  */
141
153
  async disconnectAll(): Promise<void> {
142
154
  for (const server of this.servers.values()) {
143
- try { await server.client.close(); } catch {}
155
+ try {
156
+ await server.client.close();
157
+ } catch (e: unknown) {
158
+ Logger.debug(`MCP server "${server.name}" close error: ${asError(e).message}`);
159
+ }
144
160
  }
145
161
  this.servers.clear();
146
162
  }