@nghyane/arcane 0.1.26 → 0.1.28

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.28] - 2026-03-08
6
+
7
+ ### Fixed
8
+
9
+ - Fix edit tool: hashline delete missing `saveForUndo` causing unrecoverable file deletion
10
+ - Fix `any` types on `EditTool` class — replaced with `EditToolDetails`
11
+ - Fix `applyHashlineEdits` mutating caller's input array via splice
12
+
13
+ ## [0.1.27] - 2026-03-08
14
+
15
+ ### Fixed
16
+
17
+ - Fix GitHub tool: typed API interfaces replacing `any`, cache key collision with different media types, 403 rate limit not retried, `retry-after` NaN crash, raw file detection returning `[object Object]` for JSON files
18
+ - Fix bash timeout misreported as "Command aborted" instead of "Command timed out"
19
+ - Fix unhandled promise rejection crash in interactive bash PTY finalization
20
+ - Reorder HTML render pipeline to native-first, avoiding unnecessary network calls to jina.ai
21
+
22
+ ### Changed
23
+
24
+ - Add GitHub tool guidance to system prompt
25
+ - Remove dead code: `normalizeBashCommand`, `expandSkillUrls`, `BashToolOptions`, `isInteractiveResult`
26
+
5
27
  ## [0.1.26] - 2026-03-08
6
28
 
7
29
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@nghyane/arcane",
4
- "version": "0.1.26",
4
+ "version": "0.1.28",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/nghyane/arcane",
7
7
  "author": "Can Bölük",
@@ -27,6 +27,7 @@ export interface BashResult {
27
27
  output: string;
28
28
  exitCode: number | undefined;
29
29
  cancelled: boolean;
30
+ timedOut: boolean;
30
31
  truncated: boolean;
31
32
  totalLines: number;
32
33
  totalBytes: number;
@@ -44,11 +45,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
44
45
  const { shell, env: shellEnv, prefix } = settings.getShellConfig();
45
46
  const snapshotPath = shell.includes("bash") ? await getOrCreateSnapshot(shell, shellEnv) : null;
46
47
 
47
- // Apply command prefix if configured
48
48
  const prefixedCommand = prefix ? `${prefix} ${command}` : command;
49
- const finalCommand = prefixedCommand;
50
49
 
51
- // Create output sink for truncation and artifact handling
52
50
  const sink = new OutputSink({
53
51
  onChunk: options?.onChunk,
54
52
  artifactPath: options?.artifactPath,
@@ -64,6 +62,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
64
62
  return {
65
63
  exitCode: undefined,
66
64
  cancelled: true,
65
+ timedOut: false,
67
66
  ...(await sink.dump("Command cancelled")),
68
67
  };
69
68
  }
@@ -96,7 +95,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
96
95
  try {
97
96
  const runPromise = shellSession.run(
98
97
  {
99
- command: finalCommand,
98
+ command: prefixedCommand,
100
99
  cwd: options?.cwd,
101
100
  env: options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV,
102
101
  timeoutMs: options?.timeout,
@@ -121,11 +120,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions
121
120
  return {
122
121
  exitCode: undefined,
123
122
  cancelled: true,
123
+ timedOut: true,
124
124
  ...(await sink.dump(`Command exceeded hard timeout after ${Math.round(hardTimeoutMs / 1000)} seconds`)),
125
125
  };
126
126
  }
127
127
 
128
- // Handle timeout
129
128
  if (winner.result.timedOut) {
130
129
  const annotation = options?.timeout
131
130
  ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
@@ -133,25 +132,26 @@ export async function executeBash(command: string, options?: BashExecutorOptions
133
132
  resetSession = true;
134
133
  return {
135
134
  exitCode: undefined,
136
- cancelled: true,
135
+ cancelled: false,
136
+ timedOut: true,
137
137
  ...(await sink.dump(annotation)),
138
138
  };
139
139
  }
140
140
 
141
- // Handle cancellation
142
141
  if (winner.result.cancelled) {
143
142
  resetSession = true;
144
143
  return {
145
144
  exitCode: undefined,
146
145
  cancelled: true,
146
+ timedOut: false,
147
147
  ...(await sink.dump("Command cancelled")),
148
148
  };
149
149
  }
150
150
 
151
- // Normal completion
152
151
  return {
153
152
  exitCode: winner.result.exitCode,
154
153
  cancelled: false,
154
+ timedOut: false,
155
155
  ...(await sink.dump()),
156
156
  };
157
157
  } catch (err) {
@@ -134,13 +134,21 @@ function mergeDiagnosticsWithWarnings(
134
134
  };
135
135
  }
136
136
 
137
- export class EditTool implements AgentTool<TInput, any, Theme> {
137
+ export class EditTool implements AgentTool<TInput, EditToolDetails, Theme> {
138
138
  readonly name = "edit";
139
139
  readonly label = "Edit";
140
140
  readonly nonAbortable = true;
141
141
  readonly concurrency = "exclusive";
142
- readonly renderCall = editToolRenderer.renderCall as unknown as AgentTool<TInput, any, Theme>["renderCall"];
143
- readonly renderResult = editToolRenderer.renderResult as unknown as AgentTool<TInput, any, Theme>["renderResult"];
142
+ readonly renderCall = editToolRenderer.renderCall as unknown as AgentTool<
143
+ TInput,
144
+ EditToolDetails,
145
+ Theme
146
+ >["renderCall"];
147
+ readonly renderResult = editToolRenderer.renderResult as unknown as AgentTool<
148
+ TInput,
149
+ EditToolDetails,
150
+ Theme
151
+ >["renderResult"];
144
152
 
145
153
  readonly #allowFuzzy: boolean;
146
154
  readonly #fuzzyThreshold: number;
@@ -252,6 +260,7 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
252
260
 
253
261
  if (deleteFile) {
254
262
  if (await file.exists()) {
263
+ saveForUndo(absolutePath, await file.text());
255
264
  await file.unlink();
256
265
  }
257
266
  invalidateFsScanAfterDelete(absolutePath);
@@ -592,7 +592,7 @@ function autocorrectEscapedTabs(lines: string[]): string[] {
592
592
  */
593
593
  export function applyHashlineEdits(
594
594
  content: string,
595
- edits: HashlineEdit[],
595
+ edits: readonly HashlineEdit[],
596
596
  ): {
597
597
  content: string;
598
598
  firstChangedLine: number | undefined;
@@ -603,6 +603,8 @@ export function applyHashlineEdits(
603
603
  return { content, firstChangedLine: undefined };
604
604
  }
605
605
 
606
+ const mutableEdits: HashlineEdit[] = edits.map(e => ({ ...e, content: [...e.content] }));
607
+
606
608
  const fileLines = content.split("\n");
607
609
  const hadFinalNewline = content.endsWith("\n");
608
610
  const originalFileLines = [...fileLines];
@@ -612,7 +614,7 @@ export function applyHashlineEdits(
612
614
  const autocorrect = Bun.env.ARCANE_HL_AUTOCORRECT === "1";
613
615
 
614
616
  const warnings: string[] = [];
615
- for (const edit of edits) {
617
+ for (const edit of mutableEdits) {
616
618
  const unicodeWarning = detectUnicodeEscapePlaceholders(edit.content);
617
619
  if (unicodeWarning && !warnings.includes(unicodeWarning)) {
618
620
  warnings.push(unicodeWarning);
@@ -622,7 +624,7 @@ export function applyHashlineEdits(
622
624
 
623
625
  function collectExplicitlyTouchedLines(): Set<number> {
624
626
  const touched = new Set<number>();
625
- for (const edit of edits) {
627
+ for (const edit of mutableEdits) {
626
628
  switch (edit.op) {
627
629
  case "replace":
628
630
  touched.add(edit.target.line);
@@ -652,7 +654,7 @@ export function applyHashlineEdits(
652
654
  mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
653
655
  return false;
654
656
  }
655
- for (const edit of edits) {
657
+ for (const edit of mutableEdits) {
656
658
  switch (edit.op) {
657
659
  case "replace": {
658
660
  if (!validateRef(edit.target)) continue;
@@ -678,8 +680,8 @@ export function applyHashlineEdits(
678
680
  }
679
681
  const seenEditKeys = new Map<string, number>();
680
682
  const dedupIndices = new Set<number>();
681
- for (let i = 0; i < edits.length; i++) {
682
- const edit = edits[i];
683
+ for (let i = 0; i < mutableEdits.length; i++) {
684
+ const edit = mutableEdits[i];
683
685
  let lineKey: string;
684
686
  switch (edit.op) {
685
687
  case "replace":
@@ -697,13 +699,13 @@ export function applyHashlineEdits(
697
699
  }
698
700
  }
699
701
  if (dedupIndices.size > 0) {
700
- for (let i = edits.length - 1; i >= 0; i--) {
701
- if (dedupIndices.has(i)) edits.splice(i, 1);
702
+ for (let i = mutableEdits.length - 1; i >= 0; i--) {
703
+ if (dedupIndices.has(i)) mutableEdits.splice(i, 1);
702
704
  }
703
705
  }
704
706
 
705
707
  // Compute sort key (descending) — bottom-up application
706
- const annotated = edits.map((edit, idx) => {
708
+ const annotated = mutableEdits.map((edit, idx) => {
707
709
  let sortLine: number;
708
710
  let precedence: number;
709
711
  switch (edit.op) {
@@ -182,6 +182,16 @@ Tools: `find_thread`, `read_thread`
182
182
  - Generic coding question with no project-specific history
183
183
  - User explicitly provides all needed context
184
184
 
185
+ {{#has tools "github"}}
186
+ ### GitHub
187
+
188
+ Tool: `github`
189
+ Use for **remote** GitHub API calls — repos, issues, PRs, commits, file contents, tree listings. Never use for the local repo; use read/grep/explore for local files.
190
+ - **Use `github`** for: reading issues/PRs, listing commits, fetching files from remote repos, searching repos, getting repo metadata.
191
+ - **Use `librarian`** for: cross-repo exploration, understanding architectural patterns, tracing how other projects solve problems.
192
+ - **Avoid `bash gh`** when `github` covers the action — the tool has caching, pagination, and structured output.
193
+ {{/has}}
194
+
185
195
  ### Verification
186
196
  Work incrementally. Make a small change, verify it works, then continue. Prefer a sequence of small, validated edits over one large change. Use commands from AGENTS.md or the project's config to verify. Address all errors caused by your changes before yielding.
187
197
 
@@ -303,15 +303,29 @@ export async function runInteractiveBashPty(
303
303
  component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
304
304
  tui.requestRender();
305
305
  void (async () => {
306
- await component.flushOutput();
307
- await pendingChunks;
308
- const summary = await sink.dump();
309
- done({
310
- exitCode: run.exitCode,
311
- cancelled: run.cancelled,
312
- timedOut: run.timedOut,
313
- ...summary,
314
- });
306
+ try {
307
+ await component.flushOutput();
308
+ await pendingChunks;
309
+ const summary = await sink.dump();
310
+ done({
311
+ exitCode: run.exitCode,
312
+ cancelled: run.cancelled,
313
+ timedOut: run.timedOut,
314
+ ...summary,
315
+ });
316
+ } catch {
317
+ done({
318
+ exitCode: run.exitCode,
319
+ cancelled: run.cancelled,
320
+ timedOut: run.timedOut,
321
+ output: "",
322
+ truncated: false,
323
+ totalLines: 0,
324
+ totalBytes: 0,
325
+ outputLines: 0,
326
+ outputBytes: 0,
327
+ });
328
+ }
315
329
  })();
316
330
  };
317
331
  const cols = Math.max(20, tui.terminal.columns - 2);
@@ -82,7 +82,6 @@ export function checkBashInterception(
82
82
  availableTools: string[],
83
83
  rules: BashInterceptorRule[] = DEFAULT_BASH_INTERCEPTOR_RULES,
84
84
  ): InterceptionResult {
85
- // Normalize command for pattern matching
86
85
  const normalizedCommand = command.trim();
87
86
  const compiled = compileRules(rules);
88
87
 
@@ -1,72 +1,3 @@
1
- /**
2
- * Bash command normalizer - extracts patterns that are better handled natively.
3
- *
4
- * Detects and extracts:
5
- * - `| head -n N` / `| head -N` - extracted to headLines
6
- * - `| tail -n N` / `| tail -N` - extracted to tailLines
7
- */
8
-
9
- interface NormalizedCommand {
10
- /** Cleaned command with patterns stripped */
11
- command: string;
12
- /** Extracted head line count, if any */
13
- headLines?: number;
14
- /** Extracted tail line count, if any */
15
- tailLines?: number;
16
- }
17
-
18
- /**
19
- * Pattern to match trailing pipe to head/tail.
20
- * Captures: full match, command (head/tail), line count
21
- *
22
- * Matches:
23
- * - `| head -n 50`
24
- * - `| head -50`
25
- * - `| tail -n 100`
26
- * - `| tail -100`
27
- *
28
- * Does NOT match head/tail with other flags or without line count.
29
- */
30
- const TRAILING_HEAD_TAIL_PATTERN = /\|\s*(head|tail)\s+(?:-n\s*(\d+)|(-\d+))\s*$/;
31
-
32
- /**
33
- * Normalize a bash command by stripping patterns better handled natively.
34
- *
35
- * Extracts `| head -n N` and `| tail -n N` suffixes into separate fields
36
- * so they can be applied post-execution without breaking streaming.
37
- *
38
- * Strips `2>&1` since we already merge stdout/stderr.
39
- */
40
- export function normalizeBashCommand(command: string): NormalizedCommand {
41
- let normalized = command;
42
- let headLines: number | undefined;
43
- let tailLines: number | undefined;
44
-
45
- // Extract trailing head/tail
46
- const match = normalized.match(TRAILING_HEAD_TAIL_PATTERN);
47
- if (match) {
48
- const [fullMatch, cmd, nValue, dashValue] = match;
49
- const lineCount = nValue ? Number.parseInt(nValue, 10) : Number.parseInt(dashValue.slice(1), 10);
50
-
51
- if (cmd === "head") {
52
- headLines = lineCount;
53
- } else {
54
- tailLines = lineCount;
55
- }
56
-
57
- normalized = normalized.slice(0, -fullMatch.length);
58
- }
59
-
60
- // Preserve internal whitespace (important for heredocs / indentation-sensitive scripts)
61
- normalized = normalized.trim();
62
-
63
- return {
64
- command: normalized,
65
- headLines,
66
- tailLines,
67
- };
68
- }
69
-
70
1
  /**
71
2
  * Apply head/tail limits to output text.
72
3
  *
@@ -86,13 +17,11 @@ export function applyHeadTail(
86
17
  let headApplied: number | undefined;
87
18
  let tailApplied: number | undefined;
88
19
 
89
- // Apply head first (keep first N lines)
90
20
  if (headLines !== undefined && headLines > 0 && lines.length > headLines) {
91
21
  lines = lines.slice(0, headLines);
92
22
  headApplied = headLines;
93
23
  }
94
24
 
95
- // Then apply tail (keep last N lines)
96
25
  if (tailLines !== undefined && tailLines > 0 && lines.length > tailLines) {
97
26
  lines = lines.slice(-tailLines);
98
27
  tailApplied = tailLines;
@@ -4,9 +4,6 @@ import { validateRelativePath } from "../internal-urls/skill-protocol";
4
4
  import type { InternalResource } from "../internal-urls/types";
5
5
  import { ToolError } from "./tool-errors";
6
6
 
7
- /** Regex to find skill:// tokens in command text. */
8
- const SKILL_URL_PATTERN = /'skill:\/\/[^'\s")`\\]+'|"skill:\/\/[^"\s')`\\]+"|skill:\/\/[^\s'")`\\]+/g;
9
-
10
7
  /** Regex to find supported internal URL tokens in command text. */
11
8
  const INTERNAL_URL_PATTERN =
12
9
  /'(?:skill|agent|artifact|plan|rule):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|rule):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|rule):\/\/[^\s'")`\\]+/g;
@@ -133,23 +130,6 @@ async function resolveInternalUrlToPath(
133
130
  return path.resolve(resource.sourcePath);
134
131
  }
135
132
 
136
- /**
137
- * Expand all skill:// URIs in a bash command string.
138
- * Returns the command with URIs replaced by shell-escaped absolute paths.
139
- * Throws ToolError if any URI cannot be resolved.
140
- */
141
- export function expandSkillUrls(command: string, skills: readonly Skill[]): string {
142
- if (skills.length === 0 || !command.includes("skill://")) {
143
- return command;
144
- }
145
-
146
- return command.replace(SKILL_URL_PATTERN, token => {
147
- const url = unquoteToken(token);
148
- const resolvedPath = resolveSkillUrlToPath(url, skills);
149
- return shellEscape(resolvedPath);
150
- });
151
- }
152
-
153
133
  /**
154
134
  * Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
155
135
  * Supported schemes: skill://, agent://, artifact://, plan://, rule://
package/src/tools/bash.ts CHANGED
@@ -38,20 +38,10 @@ export interface BashToolDetails {
38
38
  meta?: OutputMeta;
39
39
  }
40
40
 
41
- export interface BashToolOptions {}
42
-
43
41
  function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
44
42
  return result.output || "";
45
43
  }
46
44
 
47
- function isInteractiveResult(result: BashResult | BashInteractiveResult): result is BashInteractiveResult {
48
- return "timedOut" in result;
49
- }
50
- /**
51
- * Bash tool implementation.
52
- *
53
- * Executes bash commands with optional timeout and working directory.
54
- */
55
45
  export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, Theme> {
56
46
  readonly name = "bash";
57
47
  readonly label = "Bash";
@@ -71,11 +61,9 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
71
61
  ): Promise<AgentToolResult<BashToolDetails>> {
72
62
  let command = rawCommand;
73
63
 
74
- // Only apply explicit head/tail params from tool input.
75
64
  const headLines = head;
76
65
  const tailLines = tail;
77
66
 
78
- // Check interception if enabled and available tools are known
79
67
  if (this.session.settings.get("bashInterceptor.enabled")) {
80
68
  const rules = this.session.settings.getBashInterceptorRules();
81
69
  const interception = checkBashInterception(command, ctx?.toolNames ?? [], rules);
@@ -103,14 +91,11 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
103
91
  throw new ToolError(`Working directory is not a directory: ${commandCwd}`);
104
92
  }
105
93
 
106
- // Clamp to reasonable range: 1s - 3600s (1 hour)
107
94
  const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
108
95
  const timeoutMs = timeoutSec * 1000;
109
96
 
110
- // Track output for streaming updates (tail only)
111
97
  const tailBuffer = createTailBuffer(DEFAULT_MAX_BYTES);
112
98
 
113
- // Set up artifacts environment and allocation
114
99
  const artifactsDir = this.session.getArtifactsDir?.();
115
100
  const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
116
101
  const { artifactPath, artifactId } = await allocateOutputArtifact(this.session, "bash");
@@ -148,16 +133,15 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
148
133
  }
149
134
  },
150
135
  });
136
+ if (result.timedOut) {
137
+ throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
138
+ }
151
139
  if (result.cancelled) {
152
140
  if (signal?.aborted) {
153
141
  throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");
154
142
  }
155
143
  throw new ToolError(normalizeResultOutput(result) || "Command aborted");
156
144
  }
157
- if (isInteractiveResult(result) && result.timedOut) {
158
- throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
159
- }
160
- // Apply head/tail filtering if specified
161
145
  let outputText = normalizeResultOutput(result);
162
146
  const headTailResult = applyHeadTail(outputText, headLines, tailLines);
163
147
  if (headTailResult.applied) {
@@ -171,7 +155,7 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
171
155
  if (result.exitCode === undefined) {
172
156
  throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
173
157
  }
174
- if (result.exitCode !== 0 && result.exitCode !== undefined) {
158
+ if (result.exitCode !== 0) {
175
159
  throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
176
160
  }
177
161
 
@@ -399,25 +399,15 @@ async function renderHtmlToText(
399
399
  signal,
400
400
  };
401
401
 
402
- // Try jina first (reader API)
403
402
  try {
404
- const jinaUrl = `https://r.jina.ai/${url}`;
405
- const response = await fetch(jinaUrl, {
406
- headers: { Accept: "text/markdown" },
407
- signal,
408
- });
409
- if (response.ok) {
410
- const content = await response.text();
411
- if (content.trim().length > 100 && !isLowQualityOutput(content)) {
412
- return { content, ok: true, method: "jina" };
413
- }
403
+ const content = await htmlToMarkdown(html, { cleanContent: true });
404
+ if (content.trim().length > 100 && !isLowQualityOutput(content)) {
405
+ return { content, ok: true, method: "native" };
414
406
  }
415
407
  } catch {
416
- // Jina failed, continue to next method
417
408
  signal?.throwIfAborted();
418
409
  }
419
410
 
420
- // Try trafilatura (auto-install via uv/pip)
421
411
  const trafilatura = await ensureTool("trafilatura", { signal, silent: true });
422
412
  if (trafilatura) {
423
413
  const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
@@ -426,7 +416,6 @@ async function renderHtmlToText(
426
416
  }
427
417
  }
428
418
 
429
- // Try lynx (can't auto-install, system package)
430
419
  const lynx = hasCommand("lynx");
431
420
  if (lynx) {
432
421
  const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
@@ -435,14 +424,19 @@ async function renderHtmlToText(
435
424
  }
436
425
  }
437
426
 
438
- // Fall back to native converter (fastest, no network/subprocess)
439
427
  try {
440
- const content = await htmlToMarkdown(html, { cleanContent: true });
441
- if (content.trim().length > 100 && !isLowQualityOutput(content)) {
442
- return { content, ok: true, method: "native" };
428
+ const jinaUrl = `https://r.jina.ai/${url}`;
429
+ const response = await fetch(jinaUrl, {
430
+ headers: { Accept: "text/markdown" },
431
+ signal,
432
+ });
433
+ if (response.ok) {
434
+ const content = await response.text();
435
+ if (content.trim().length > 100 && !isLowQualityOutput(content)) {
436
+ return { content, ok: true, method: "jina" };
437
+ }
443
438
  }
444
439
  } catch {
445
- // Native converter failed, continue to next method
446
440
  signal?.throwIfAborted();
447
441
  }
448
442
  return { content: "", ok: false, method: "none" };
@@ -6,9 +6,107 @@ import type { ToolSession } from ".";
6
6
  import { type OutputMeta, toolResult } from "./output-meta";
7
7
 
8
8
  // =============================================================================
9
- // Schema
9
+ // GitHub API Types
10
10
  // =============================================================================
11
11
 
12
+ interface GitHubUser {
13
+ login: string;
14
+ }
15
+
16
+ interface GitHubLabel {
17
+ name: string;
18
+ }
19
+
20
+ interface GitHubLicense {
21
+ spdx_id: string;
22
+ }
23
+
24
+ interface GitHubRepo {
25
+ full_name: string;
26
+ description: string | null;
27
+ default_branch: string;
28
+ language: string | null;
29
+ stargazers_count: number;
30
+ forks_count: number;
31
+ topics: string[];
32
+ license: GitHubLicense | null;
33
+ created_at: string;
34
+ updated_at: string;
35
+ homepage: string | null;
36
+ }
37
+
38
+ interface GitHubTreeEntry {
39
+ type: string;
40
+ path?: string;
41
+ name?: string;
42
+ size?: number;
43
+ }
44
+
45
+ interface GitHubIssue {
46
+ number: number;
47
+ title: string;
48
+ state: string;
49
+ user: GitHubUser | null;
50
+ labels: GitHubLabel[];
51
+ assignees: GitHubUser[];
52
+ body: string | null;
53
+ created_at: string;
54
+ pull_request?: unknown;
55
+ }
56
+
57
+ interface GitHubComment {
58
+ user: GitHubUser | null;
59
+ created_at: string;
60
+ body: string | null;
61
+ }
62
+
63
+ interface GitHubPR {
64
+ number: number;
65
+ title: string;
66
+ state: string;
67
+ merged: boolean;
68
+ merged_at: string | null;
69
+ user: GitHubUser | null;
70
+ base: { ref: string };
71
+ head: { ref: string };
72
+ changed_files: number | null;
73
+ additions: number;
74
+ deletions: number;
75
+ body: string | null;
76
+ }
77
+
78
+ interface GitHubCommitAuthor {
79
+ name: string;
80
+ email: string;
81
+ date: string;
82
+ }
83
+
84
+ interface GitHubCommitFile {
85
+ status: string;
86
+ filename: string;
87
+ additions: number;
88
+ deletions: number;
89
+ }
90
+
91
+ interface GitHubCommit {
92
+ sha: string;
93
+ commit: {
94
+ author: GitHubCommitAuthor;
95
+ message: string;
96
+ };
97
+ author: GitHubUser | null;
98
+ stats?: { total: number; additions: number; deletions: number };
99
+ files?: GitHubCommitFile[];
100
+ }
101
+
102
+ interface GitHubSearchResult<T> {
103
+ total_count: number;
104
+ items: T[];
105
+ }
106
+
107
+ // =============================================================================
108
+ // Schema
109
+ // =============================================================================
12
110
  const ActionEnum = Type.Union([
13
111
  Type.Literal("get_repo"),
14
112
  Type.Literal("get_file"),
@@ -55,7 +153,7 @@ export interface GitHubToolDetails {
55
153
  // Response Formatters
56
154
  // =============================================================================
57
155
 
58
- function formatRepo(data: any): string {
156
+ function formatRepo(data: GitHubRepo): string {
59
157
  return [
60
158
  `# ${data.full_name}`,
61
159
  data.description ? `${data.description}` : "",
@@ -72,7 +170,7 @@ function formatRepo(data: any): string {
72
170
  .join("\n");
73
171
  }
74
172
 
75
- function formatTreeEntry(entry: any): string {
173
+ function formatTreeEntry(entry: GitHubTreeEntry): string {
76
174
  const icon = entry.type === "dir" || entry.type === "tree" ? "dir" : "file";
77
175
  const size = entry.size ? ` (${formatSize(entry.size)})` : "";
78
176
  const name = entry.path ?? entry.name;
@@ -85,12 +183,12 @@ function formatSize(bytes: number): string {
85
183
  return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
86
184
  }
87
185
 
88
- function formatIssue(data: any, comments: any[] = []): string {
186
+ function formatIssue(data: GitHubIssue, comments: GitHubComment[] = []): string {
89
187
  const lines = [
90
188
  `# #${data.number}: ${data.title}`,
91
189
  `State: ${data.state} | Author: @${data.user?.login} | Created: ${data.created_at}`,
92
- data.labels?.length ? `Labels: ${data.labels.map((l: any) => l.name).join(", ")}` : "",
93
- data.assignees?.length ? `Assignees: ${data.assignees.map((a: any) => `@${a.login}`).join(", ")}` : "",
190
+ data.labels?.length ? `Labels: ${data.labels.map(l => l.name).join(", ")}` : "",
191
+ data.assignees?.length ? `Assignees: ${data.assignees.map(a => `@${a.login}`).join(", ")}` : "",
94
192
  "",
95
193
  data.body ?? "(no description)",
96
194
  ].filter(l => l !== "");
@@ -102,12 +200,12 @@ function formatIssue(data: any, comments: any[] = []): string {
102
200
  return lines.join("\n");
103
201
  }
104
202
 
105
- function formatIssueMinimal(issue: any): string {
106
- const labels = issue.labels?.length ? ` [${issue.labels.map((l: any) => l.name).join(", ")}]` : "";
203
+ function formatIssueMinimal(issue: GitHubIssue): string {
204
+ const labels = issue.labels?.length ? ` [${issue.labels.map(l => l.name).join(", ")}]` : "";
107
205
  return `#${issue.number} [${issue.state}] ${issue.title}${labels} (@${issue.user?.login}, ${issue.created_at})`;
108
206
  }
109
207
 
110
- function formatPR(data: any, diff?: string): string {
208
+ function formatPR(data: GitHubPR, diff?: string): string {
111
209
  const lines = [
112
210
  `# PR #${data.number}: ${data.title}`,
113
211
  `State: ${data.state}${data.merged ? " (merged)" : ""} | Author: @${data.user?.login}`,
@@ -124,12 +222,12 @@ function formatPR(data: any, diff?: string): string {
124
222
  return lines.join("\n");
125
223
  }
126
224
 
127
- function formatPRMinimal(pr: any): string {
225
+ function formatPRMinimal(pr: GitHubPR): string {
128
226
  const merged = pr.merged_at ? " (merged)" : "";
129
227
  return `#${pr.number} [${pr.state}${merged}] ${pr.title} (@${pr.user?.login}, ${pr.base?.ref} <- ${pr.head?.ref})`;
130
228
  }
131
229
 
132
- function formatCommit(data: any, diff?: string): string {
230
+ function formatCommit(data: GitHubCommit, diff?: string): string {
133
231
  const lines = [
134
232
  `Commit: ${data.sha}`,
135
233
  `Author: ${data.commit?.author?.name} <${data.commit?.author?.email}>`,
@@ -156,7 +254,7 @@ function formatCommit(data: any, diff?: string): string {
156
254
  return lines.join("\n");
157
255
  }
158
256
 
159
- function formatCommitMinimal(c: any): string {
257
+ function formatCommitMinimal(c: GitHubCommit): string {
160
258
  const sha = (c.sha ?? "").slice(0, 7);
161
259
  const msg = (c.commit?.message ?? "").split("\n")[0];
162
260
  const author = c.commit?.author?.name ?? c.author?.login ?? "?";
@@ -164,7 +262,7 @@ function formatCommitMinimal(c: any): string {
164
262
  return `${sha} ${msg} (${author}, ${date})`;
165
263
  }
166
264
 
167
- function formatSearchReposResult(data: any): string {
265
+ function formatSearchReposResult(data: GitHubSearchResult<GitHubRepo>): string {
168
266
  const items = data.items ?? [];
169
267
  const lines = [`Found ${data.total_count} repositories (showing ${items.length}):`, ""];
170
268
  for (const item of items) {
@@ -188,7 +286,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
188
286
 
189
287
  switch (action) {
190
288
  case "get_repo": {
191
- const res = await githubClient.request(base, opts);
289
+ const res = await githubClient.request<GitHubRepo>(base, opts);
192
290
  if (!res.ok) return error(res, "repository");
193
291
  return { text: formatRepo(res.data), url: `https://github.com/${owner}/${repo}` };
194
292
  }
@@ -216,7 +314,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
216
314
  blobRes.data.encoding === "base64"
217
315
  ? Buffer.from(blobRes.data.content, "base64").toString("utf-8")
218
316
  : blobRes.data.content;
219
- res = { data: decoded as any, ok: true, status: 200 };
317
+ res = { data: decoded as string, ok: true, status: 200 };
220
318
  }
221
319
  }
222
320
  }
@@ -236,10 +334,13 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
236
334
  const treePath = input.path ?? "";
237
335
  if (input.recursive) {
238
336
  const ref = input.ref ?? "HEAD";
239
- const res = await githubClient.request<any>(`${base}/git/trees/${ref}?recursive=1`, opts);
337
+ const res = await githubClient.request<{ tree: GitHubTreeEntry[]; truncated: boolean }>(
338
+ `${base}/git/trees/${ref}?recursive=1`,
339
+ opts,
340
+ );
240
341
  if (!res.ok) return error(res, "tree");
241
342
  const entries = (res.data.tree ?? [])
242
- .filter((e: any) => !treePath || e.path.startsWith(treePath))
343
+ .filter(e => !treePath || (e.path ?? "").startsWith(treePath))
243
344
  .slice(0, 500);
244
345
  return {
245
346
  text: `# Tree: ${owner}/${repo}${treePath ? `/${treePath}` : ""} (recursive)\n\n${entries.map(formatTreeEntry).join("\n")}`,
@@ -247,7 +348,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
247
348
  }
248
349
  const ref = input.ref ? `?ref=${input.ref}` : "";
249
350
  const endpoint = treePath ? `${base}/contents/${treePath}${ref}` : `${base}/contents${ref}`;
250
- const res = await githubClient.request<any[]>(endpoint, opts);
351
+ const res = await githubClient.request<GitHubTreeEntry[]>(endpoint, opts);
251
352
  if (!res.ok) return error(res, "directory");
252
353
  const entries = Array.isArray(res.data) ? res.data : [res.data];
253
354
  return {
@@ -258,7 +359,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
258
359
  case "search_repos": {
259
360
  const q = input.query ?? `${owner}/${repo}`;
260
361
  const perPage = Math.min(input.limit ?? 30, 100);
261
- const res = await githubClient.request<any>(
362
+ const res = await githubClient.request<GitHubSearchResult<GitHubRepo>>(
262
363
  `/search/repositories?q=${encodeURIComponent(q)}&per_page=${perPage}`,
263
364
  opts,
264
365
  );
@@ -270,8 +371,8 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
270
371
  const num = input.number;
271
372
  if (!num) return { text: "Error: 'number' is required for get_issue" };
272
373
  const [issueRes, commentsRes] = await Promise.all([
273
- githubClient.request<any>(`${base}/issues/${num}`, opts),
274
- githubClient.requestPaginated<any>(`${base}/issues/${num}/comments`, {
374
+ githubClient.request<GitHubIssue>(`${base}/issues/${num}`, opts),
375
+ githubClient.requestPaginated<GitHubComment>(`${base}/issues/${num}/comments`, {
275
376
  ...opts,
276
377
  perPage: 100,
277
378
  maxPages: MAX_COMMENTS_PAGES,
@@ -291,13 +392,13 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
291
392
  const limit = Math.min(input.limit ?? 100, 500);
292
393
  const perPage = Math.min(limit, 100);
293
394
  const maxPages = Math.ceil(limit / perPage);
294
- const res = await githubClient.requestPaginated<any>(`${base}/issues?${params}`, {
395
+ const res = await githubClient.requestPaginated<GitHubIssue>(`${base}/issues?${params}`, {
295
396
  ...opts,
296
397
  perPage,
297
398
  maxPages,
298
399
  });
299
400
  if (!res.ok) return error(res, "issues");
300
- const issues = (res.data ?? []).filter((i: any) => !i.pull_request).slice(0, limit);
401
+ const issues = (res.data ?? []).filter(i => !i.pull_request).slice(0, limit);
301
402
  const header = `${issues.length} issue(s)${issues.length >= limit ? " (limit reached, increase limit for more)" : ""}`;
302
403
  return {
303
404
  text: issues.length ? `${header}\n${issues.map(formatIssueMinimal).join("\n")}` : "No issues found.",
@@ -307,7 +408,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
307
408
  case "get_pull": {
308
409
  const num = input.number;
309
410
  if (!num) return { text: "Error: 'number' is required for get_pull" };
310
- const prRes = await githubClient.request<any>(`${base}/pulls/${num}`, opts);
411
+ const prRes = await githubClient.request<GitHubPR>(`${base}/pulls/${num}`, opts);
311
412
  if (!prRes.ok) return error(prRes, `PR #${num}`);
312
413
 
313
414
  let diff: string | undefined;
@@ -336,7 +437,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
336
437
  const limit = Math.min(input.limit ?? 100, 500);
337
438
  const perPage = Math.min(limit, 100);
338
439
  const maxPages = Math.ceil(limit / perPage);
339
- const res = await githubClient.requestPaginated<any>(`${base}/pulls?${params}`, {
440
+ const res = await githubClient.requestPaginated<GitHubPR>(`${base}/pulls?${params}`, {
340
441
  ...opts,
341
442
  perPage,
342
443
  maxPages,
@@ -356,7 +457,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
356
457
  const limit = Math.min(input.limit ?? 100, 500);
357
458
  const perPage = Math.min(limit, 100);
358
459
  const maxPages = Math.ceil(limit / perPage);
359
- const res = await githubClient.requestPaginated<any>(`${base}/commits?${params}`, {
460
+ const res = await githubClient.requestPaginated<GitHubCommit>(`${base}/commits?${params}`, {
360
461
  ...opts,
361
462
  perPage,
362
463
  maxPages,
@@ -372,7 +473,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
372
473
  case "get_commit": {
373
474
  const sha = input.sha;
374
475
  if (!sha) return { text: "Error: 'sha' is required for get_commit" };
375
- const res = await githubClient.request<any>(`${base}/commits/${sha}`, opts);
476
+ const res = await githubClient.request<GitHubCommit>(`${base}/commits/${sha}`, opts);
376
477
  if (!res.ok) return error(res, `commit ${sha}`);
377
478
 
378
479
  let diff: string | undefined;
@@ -423,7 +524,8 @@ export class GitHubTool implements AgentTool<typeof schema, GitHubToolDetails, T
423
524
  readonly name = "github";
424
525
  readonly label = "GitHub";
425
526
  readonly parameters = schema;
426
- description = "Interact with GitHub API: repos, issues, PRs, commits";
527
+ description =
528
+ "Interact with GitHub API: repos, issues, PRs, commits. For remote repositories only — use read/grep for local files.";
427
529
 
428
530
  constructor(readonly _session: ToolSession) {}
429
531
 
@@ -59,7 +59,6 @@ export {
59
59
  BashTool,
60
60
  type BashToolDetails,
61
61
  type BashToolInput,
62
- type BashToolOptions,
63
62
  } from "./bash";
64
63
  export { BrowserTool, type BrowserToolDetails } from "./browser";
65
64
  export { exploreConfig } from "./explore";
@@ -76,8 +76,8 @@ const etagCache = new Map<string, CacheEntry>();
76
76
  const CACHE_MAX_SIZE = 200;
77
77
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
78
78
 
79
- function getCacheKey(endpoint: string): string {
80
- return endpoint;
79
+ function getCacheKey(endpoint: string, mediaType?: string): string {
80
+ return mediaType ? `${endpoint}::${mediaType}` : endpoint;
81
81
  }
82
82
 
83
83
  function pruneCache(): void {
@@ -122,8 +122,7 @@ async function request<T = unknown>(endpoint: string, options: RequestOptions =
122
122
  headers.Accept = options.mediaType;
123
123
  }
124
124
 
125
- // ETag conditional request
126
- const cacheKey = getCacheKey(endpoint);
125
+ const cacheKey = getCacheKey(endpoint, options.mediaType);
127
126
  const cached = etagCache.get(cacheKey);
128
127
  if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
129
128
  headers["If-None-Match"] = cached.etag;
@@ -177,11 +176,20 @@ async function request<T = unknown>(endpoint: string, options: RequestOptions =
177
176
  }
178
177
 
179
178
  // Retry on transient errors
180
- if (RETRY_STATUS_CODES.has(response.status) && attempt < MAX_RETRIES) {
179
+ const isRateLimited =
180
+ response.status === 429 ||
181
+ (response.status === 403 && (rateLimit.remaining === 0 || response.headers.has("retry-after")));
182
+ const isTransient = RETRY_STATUS_CODES.has(response.status) || isRateLimited;
183
+
184
+ if (isTransient && attempt < MAX_RETRIES) {
185
+ await response.text().catch(() => {});
181
186
  const retryAfter = response.headers.get("retry-after");
182
- const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.min(1000 * 2 ** attempt, 10_000);
187
+ const retrySeconds = retryAfter ? parseInt(retryAfter, 10) : Number.NaN;
188
+ const waitMs = Number.isFinite(retrySeconds)
189
+ ? retrySeconds * 1000
190
+ : Math.min(1000 * 2 ** attempt, 10_000);
183
191
 
184
- if (response.status === 429 && rateLimit.remaining === 0) {
192
+ if (isRateLimited && rateLimit.remaining === 0) {
185
193
  const resetMs = rateLimit.reset * 1000 - Date.now();
186
194
  if (resetMs > 30_000) {
187
195
  return { data: null as T, ok: false, status: response.status, rateLimit };
@@ -197,8 +205,8 @@ async function request<T = unknown>(endpoint: string, options: RequestOptions =
197
205
  return { data: null as T, ok: false, status: response.status, rateLimit };
198
206
  }
199
207
 
200
- const isRaw = response.headers.get("content-type")?.includes("application/json") === false;
201
- const data = (isRaw ? await response.text() : await response.json()) as T;
208
+ const wantsRaw = options.mediaType !== undefined;
209
+ const data = (wantsRaw ? await response.text() : await response.json()) as T;
202
210
 
203
211
  // Cache with ETag
204
212
  const etag = response.headers.get("etag");