@cortexkit/aft-opencode 0.18.3 → 0.18.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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAgKlD;;;;;;;;;;;;;;;;;;GAkBG;AACH,QAAA,MAAM,MAAM,EAAE,MAkiBb,CAAC;AAEF,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAiKlD;;;;;;;;;;;;;;;;;;GAkBG;AACH,QAAA,MAAM,MAAM,EAAE,MAskBb,CAAC;AAEF,eAAe,MAAM,CAAC"}
package/dist/index.js CHANGED
@@ -25108,6 +25108,9 @@ function isProcessAlive2(pid) {
25108
25108
  }
25109
25109
  }
25110
25110
 
25111
+ // src/pool.ts
25112
+ import { realpathSync as realpathSync3 } from "fs";
25113
+
25111
25114
  // src/bridge.ts
25112
25115
  import { spawn as spawn3 } from "child_process";
25113
25116
  import { homedir as homedir7 } from "os";
@@ -25630,6 +25633,14 @@ class BridgePool {
25630
25633
  }
25631
25634
  return null;
25632
25635
  }
25636
+ getActiveBridgeForRoot(projectRoot) {
25637
+ const key = normalizeKey(projectRoot);
25638
+ const entry = this.bridges.get(key);
25639
+ if (!entry?.bridge.isAlive())
25640
+ return null;
25641
+ entry.lastUsed = Date.now();
25642
+ return entry.bridge;
25643
+ }
25633
25644
  getBridge(projectRoot) {
25634
25645
  const key = normalizeKey(projectRoot);
25635
25646
  const existing = this.bridges.get(key);
@@ -25689,7 +25700,12 @@ class BridgePool {
25689
25700
  }
25690
25701
  }
25691
25702
  function normalizeKey(projectRoot) {
25692
- return projectRoot.replace(/[/\\]+$/, "");
25703
+ const stripped = projectRoot.replace(/[/\\]+$/, "");
25704
+ try {
25705
+ return realpathSync3(stripped);
25706
+ } catch {
25707
+ return stripped;
25708
+ }
25693
25709
  }
25694
25710
 
25695
25711
  // src/resolver.ts
@@ -26179,6 +26195,7 @@ import {
26179
26195
  } from "fs";
26180
26196
  import { isIP } from "net";
26181
26197
  import { join as join17 } from "path";
26198
+ import { Agent, fetch as undiciFetch } from "undici";
26182
26199
  var MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
26183
26200
  var CACHE_TTL_MS2 = 24 * 60 * 60 * 1000;
26184
26201
  var FETCH_TIMEOUT_MS = 30000;
@@ -26270,7 +26287,7 @@ function isPrivateIp(address) {
26270
26287
  }
26271
26288
  return true;
26272
26289
  }
26273
- async function assertPublicUrl(url2, allowPrivate) {
26290
+ async function assertPublicUrl(url2, allowPrivate, dnsLookup = lookup) {
26274
26291
  if (url2.protocol !== "http:" && url2.protocol !== "https:") {
26275
26292
  throw new Error(`Only http:// and https:// URLs are supported, got: ${url2.protocol}`);
26276
26293
  }
@@ -26281,14 +26298,27 @@ async function assertPublicUrl(url2, allowPrivate) {
26281
26298
  if (isPrivateIp(hostname3)) {
26282
26299
  throw new Error(`Blocked private URL host ${url2.hostname} (${hostname3})`);
26283
26300
  }
26284
- return;
26301
+ return hostname3;
26285
26302
  }
26286
- const addresses = await lookup(hostname3, { all: true, verbatim: true });
26303
+ const addresses = await dnsLookup(hostname3, { all: true, verbatim: true });
26287
26304
  for (const { address } of addresses) {
26288
26305
  if (isPrivateIp(address)) {
26289
26306
  throw new Error(`Blocked private URL host ${url2.hostname} (${address})`);
26290
26307
  }
26291
26308
  }
26309
+ if (addresses.length === 0) {
26310
+ throw new Error(`Failed to resolve URL host ${url2.hostname}`);
26311
+ }
26312
+ return addresses[0].address;
26313
+ }
26314
+ function createPinnedDispatcher(validatedIp) {
26315
+ return new Agent({
26316
+ connect: {
26317
+ lookup: (_hostname, _opts, callback) => {
26318
+ callback(null, validatedIp, validatedIp.includes(":") ? 6 : 4);
26319
+ }
26320
+ }
26321
+ });
26292
26322
  }
26293
26323
  function resolveRedirectUrl(currentUrl, location) {
26294
26324
  if (!location) {
@@ -26296,17 +26326,21 @@ function resolveRedirectUrl(currentUrl, location) {
26296
26326
  }
26297
26327
  return new URL(location, currentUrl);
26298
26328
  }
26299
- async function fetchWithRedirects(startUrl, allowPrivate) {
26329
+ async function fetchWithRedirects(startUrl, allowPrivate, deps = {}) {
26300
26330
  let currentUrl = startUrl;
26331
+ const fetchImpl = deps.fetchImpl ?? undiciFetch;
26332
+ const dnsLookup = deps.lookup ?? lookup;
26301
26333
  for (let redirectCount = 0;redirectCount <= MAX_REDIRECTS; redirectCount++) {
26302
- await assertPublicUrl(currentUrl, allowPrivate);
26334
+ const validatedIp = await assertPublicUrl(currentUrl, allowPrivate, dnsLookup);
26335
+ const dispatcher = validatedIp ? (deps.dispatcherFactory ?? createPinnedDispatcher)(validatedIp) : undefined;
26303
26336
  const controller = new AbortController;
26304
26337
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
26305
26338
  let response;
26306
26339
  try {
26307
- response = await fetch(currentUrl.href, {
26340
+ response = await fetchImpl(currentUrl.href, {
26308
26341
  signal: controller.signal,
26309
26342
  redirect: "manual",
26343
+ dispatcher,
26310
26344
  headers: {
26311
26345
  "user-agent": "aft-opencode-plugin",
26312
26346
  accept: "application/vnd.github.raw, text/markdown, text/x-markdown, text/html;q=0.9, text/plain;q=0.5"
@@ -26367,7 +26401,7 @@ async function fetchUrlToTempFile(url2, storageDir, options = {}) {
26367
26401
  } catch {}
26368
26402
  }
26369
26403
  log(`Fetching URL: ${url2}`);
26370
- const response = await fetchWithRedirects(parsed, allowPrivate);
26404
+ const response = await fetchWithRedirects(parsed, allowPrivate, options);
26371
26405
  if (!response.ok) {
26372
26406
  throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url2}`);
26373
26407
  }
@@ -28742,9 +28776,12 @@ Provide exactly ONE of 'filePath' or 'url'. Pass either 'symbol' for a single lo
28742
28776
  const params2 = { file: file2, symbol: sym };
28743
28777
  if (args.contextLines !== undefined)
28744
28778
  params2.context_lines = args.contextLines;
28745
- return callBridge(ctx, context, "zoom", params2);
28779
+ return callBridge(ctx, context, "zoom", params2).catch((err) => ({
28780
+ success: false,
28781
+ message: err instanceof Error ? err.message : String(err)
28782
+ }));
28746
28783
  }));
28747
- return JSON.stringify(results);
28784
+ return JSON.stringify(formatZoomBatchResult(args.symbols, results), null, 2);
28748
28785
  }
28749
28786
  const params = { file: file2 };
28750
28787
  if (typeof args.symbol === "string")
@@ -28760,6 +28797,40 @@ Provide exactly ONE of 'filePath' or 'url'. Pass either 'symbol' for a single lo
28760
28797
  }
28761
28798
  };
28762
28799
  }
28800
+ function formatZoomBatchResult(symbols, responses) {
28801
+ const entries = symbols.map((name, index) => {
28802
+ const response = responses[index] ?? { success: false, message: "missing zoom response" };
28803
+ if (response.success === false) {
28804
+ const message = typeof response.message === "string" && response.message.length > 0 ? response.message : "zoom failed";
28805
+ return { name, success: false, error: message };
28806
+ }
28807
+ return { name, success: true, content: zoomResponseContent(response) };
28808
+ });
28809
+ const complete = entries.every((entry) => entry.success);
28810
+ const lines = [];
28811
+ if (!complete) {
28812
+ lines.push("Incomplete zoom results: one or more symbols failed.");
28813
+ }
28814
+ for (const entry of entries) {
28815
+ if (entry.success) {
28816
+ lines.push(`Symbol "${entry.name}":
28817
+ ${entry.content ?? ""}`.trimEnd());
28818
+ } else {
28819
+ lines.push(`Symbol "${entry.name}" not found: ${entry.error ?? "zoom failed"}`);
28820
+ }
28821
+ }
28822
+ return { complete, symbols: entries, text: lines.join(`
28823
+
28824
+ `) };
28825
+ }
28826
+ function zoomResponseContent(response) {
28827
+ if (typeof response.content === "string")
28828
+ return response.content;
28829
+ if (typeof response.text === "string")
28830
+ return response.text;
28831
+ const { success: _success2, ...rest } = response;
28832
+ return JSON.stringify(rest, null, 2);
28833
+ }
28763
28834
  var SKIP_DIRS = new Set([
28764
28835
  "node_modules",
28765
28836
  ".git",
@@ -29322,6 +29393,58 @@ function structureTools(ctx) {
29322
29393
  };
29323
29394
  }
29324
29395
 
29396
+ // src/workflow-hints.ts
29397
+ var HEADING = "## Prefer AFT tools for token efficiency";
29398
+ function buildWorkflowHints(opts) {
29399
+ const sections = [];
29400
+ const grepName = opts.hoistBuiltins ? "grep" : "aft_grep";
29401
+ const bashName = opts.hoistBuiltins ? "bash" : "aft_bash";
29402
+ const bashStatusName = "bash_status";
29403
+ const hasOutline = !opts.disabledTools.has("aft_outline");
29404
+ const hasZoom = !opts.disabledTools.has("aft_zoom");
29405
+ const hasGrep = opts.toolSurface !== "minimal" && !opts.disabledTools.has(grepName);
29406
+ const hasSearch = opts.toolSurface !== "minimal" && opts.semanticEnabled && !opts.disabledTools.has("aft_search");
29407
+ const hasNavigate = opts.toolSurface === "all" && !opts.disabledTools.has("aft_navigate");
29408
+ const hasBgBash = opts.bashBackgroundEnabled && !opts.disabledTools.has(bashName) && !opts.disabledTools.has(bashStatusName);
29409
+ if (hasOutline && hasZoom) {
29410
+ sections.push(`**Web/URL access**: \`aft_outline({ url })\` first for structure, then \`aft_zoom({ url, symbol: "<heading>" })\` for the specific section.`);
29411
+ }
29412
+ if (hasOutline && hasZoom && (hasGrep || hasSearch)) {
29413
+ const locator = hasGrep && hasSearch ? `\`${grepName}\` or \`aft_search\`` : hasGrep ? `\`${grepName}\`` : "`aft_search`";
29414
+ sections.push(`**Code exploration**: ${locator} to locate \u2192 \`aft_outline\` for structure \u2192 \`aft_zoom\` for symbol(s).`);
29415
+ }
29416
+ if (hasNavigate) {
29417
+ sections.push([
29418
+ "Use `aft_navigate` instead of grep + read chains for relationship questions:",
29419
+ "- `callers` \u2014 find all call sites before changing a function signature",
29420
+ "- `impact` \u2014 blast radius (which functions/files will need updates)",
29421
+ "- `trace_to` \u2014 how execution reaches this code from entry points (routes, exports, main)",
29422
+ "- `trace_data` \u2014 follow a value through assignments and parameters across files"
29423
+ ].join(`
29424
+ `));
29425
+ }
29426
+ if (hasBgBash) {
29427
+ sections.push(`**Long-running commands** (builds, installs, full test suites): \`${bashName}({ background: true })\` returns immediately with a \`taskId\`. Check progress with \`${bashStatusName}({ taskId })\`.`);
29428
+ }
29429
+ if (sections.length === 0) {
29430
+ return null;
29431
+ }
29432
+ return `${HEADING}
29433
+
29434
+ ${sections.join(`
29435
+
29436
+ `)}`;
29437
+ }
29438
+ function buildHintsFromConfig(config2, disabledTools) {
29439
+ return buildWorkflowHints({
29440
+ toolSurface: config2.tool_surface ?? "recommended",
29441
+ hoistBuiltins: config2.hoist_builtin_tools !== false,
29442
+ semanticEnabled: config2.semantic_search === true,
29443
+ bashBackgroundEnabled: config2.experimental?.bash?.background === true,
29444
+ disabledTools
29445
+ });
29446
+ }
29447
+
29325
29448
  // src/index.ts
29326
29449
  var STATUS_COMMAND = "aft-status";
29327
29450
  var SENTINEL_PREFIX = "__AFT_STATUS_";
@@ -29554,7 +29677,7 @@ ${lines}
29554
29677
  });
29555
29678
  rpcServer.handle("status", async (params) => {
29556
29679
  const sessionID = params.sessionID || "rpc";
29557
- const bridge = pool.getAnyActiveBridge(input.directory) ?? pool.getBridge(input.directory);
29680
+ const bridge = pool.getActiveBridgeForRoot(input.directory) ?? pool.getBridge(input.directory);
29558
29681
  return await bridge.send("status", { session_id: sessionID });
29559
29682
  });
29560
29683
  const storageDir = configOverrides.storage_dir;
@@ -29664,8 +29787,34 @@ Install: ${getManualInstallHint()}`).catch(() => {});
29664
29787
  autoUpdate: aftConfig.auto_update ?? true,
29665
29788
  signal: autoUpdateAbort.signal
29666
29789
  });
29790
+ const HINTS_TOOL_NAMES = [
29791
+ "aft_outline",
29792
+ "aft_zoom",
29793
+ "aft_search",
29794
+ "aft_navigate",
29795
+ "grep",
29796
+ "aft_grep",
29797
+ "bash",
29798
+ "aft_bash",
29799
+ "bash_status"
29800
+ ];
29801
+ const registeredTools = new Set(Object.keys(allTools));
29802
+ const hintsAbsentTools = new Set;
29803
+ for (const name of HINTS_TOOL_NAMES) {
29804
+ if (!registeredTools.has(name))
29805
+ hintsAbsentTools.add(name);
29806
+ }
29807
+ const hintsBlock = buildHintsFromConfig(aftConfig, hintsAbsentTools);
29808
+ if (hintsBlock) {
29809
+ log(`Workflow hints injected (${hintsBlock.length} chars)`);
29810
+ }
29667
29811
  return {
29668
29812
  tool: allTools,
29813
+ "experimental.chat.system.transform": async (_input, output) => {
29814
+ if (hintsBlock) {
29815
+ output.system.push(hintsBlock);
29816
+ }
29817
+ },
29669
29818
  event: async (eventInput) => {
29670
29819
  await autoUpdateEventHook(eventInput);
29671
29820
  if (eventInput.event.type !== "session.idle")
@@ -29687,7 +29836,7 @@ Install: ${getManualInstallHint()}`).catch(() => {});
29687
29836
  if (isTuiMode2() || commandInput.command !== STATUS_COMMAND) {
29688
29837
  return;
29689
29838
  }
29690
- const bridge = ctx.pool.getAnyActiveBridge(input.directory) ?? ctx.pool.getBridge(input.directory);
29839
+ const bridge = ctx.pool.getActiveBridgeForRoot(input.directory) ?? ctx.pool.getBridge(input.directory);
29691
29840
  const response = await bridge.send("status", { session_id: commandInput.sessionID });
29692
29841
  if (response.success === false) {
29693
29842
  throw new Error(response.message || "status failed");
package/dist/pool.d.ts CHANGED
@@ -38,6 +38,8 @@ export declare class BridgePool {
38
38
  * cold-start cost on a cheap query.
39
39
  */
40
40
  getAnyActiveBridge(projectRoot: string): BinaryBridge | null;
41
+ /** Get an alive bridge only when it belongs to the requested project root. */
42
+ getActiveBridgeForRoot(projectRoot: string): BinaryBridge | null;
41
43
  /**
42
44
  * Get or create the bridge for `projectRoot`.
43
45
  *
@@ -1 +1 @@
1
- {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../src/pool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAY5D,MAAM,WAAW,WAAY,SAAQ,aAAa;IAChD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,UAAU;IACrB,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgC;IACxD,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA0B;IAC1D,OAAO,CAAC,YAAY,CAA+C;gBAGjE,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,WAAgB,EACzB,eAAe,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAqB/C;;;;;;;OAOG;IACH,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;IAgB5D;;;;;;;OAOG;IACH,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,YAAY;IAmB5C,wEAAwE;IACxE,OAAO,CAAC,OAAO;IAUf,yDAAyD;IACzD,OAAO,CAAC,QAAQ;IAgBhB,wDAAwD;IAClD,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAU/B;;;OAGG;IACG,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcnD,4CAA4C;IAC5C,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
1
+ {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../src/pool.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAY5D,MAAM,WAAW,WAAY,SAAQ,aAAa;IAChD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,UAAU;IACrB,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgC;IACxD,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA0B;IAC1D,OAAO,CAAC,YAAY,CAA+C;gBAGjE,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,WAAgB,EACzB,eAAe,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;IAqB/C;;;;;;;OAOG;IACH,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;IAgB5D,8EAA8E;IAC9E,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;IAQhE;;;;;;;OAOG;IACH,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,YAAY;IAmB5C,wEAAwE;IACxE,OAAO,CAAC,OAAO;IAUf,yDAAyD;IACzD,OAAO,CAAC,QAAQ;IAgBhB,wDAAwD;IAClD,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAU/B;;;OAGG;IACG,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcnD,4CAA4C;IAC5C,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
@@ -6,6 +6,8 @@ export declare class AftRpcClient {
6
6
  constructor(storageDir: string, directory: string);
7
7
  /** Call an RPC method. Retries port resolution if the server isn't ready yet. */
8
8
  call<T = Record<string, unknown>>(method: string, params?: Record<string, unknown>): Promise<T>;
9
+ private callResolved;
10
+ private retryAfterReset;
9
11
  /** Check if the RPC server is reachable. */
10
12
  isAvailable(): Promise<boolean>;
11
13
  private resolvePort;
@@ -1 +1 @@
1
- {"version":3,"file":"rpc-client.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-client.ts"],"names":[],"mappings":"AAQA,qBAAa,YAAY;IACvB,OAAO,CAAC,IAAI,CAAuB;IACnC,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,aAAa,CAAS;gBAElB,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAIjD,iFAAiF;IAC3E,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpC,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACnC,OAAO,CAAC,CAAC,CAAC;IAoBb,4CAA4C;IACtC,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;YASvB,WAAW;YAIX,eAAe;IAoC7B,OAAO,CAAC,YAAY;YAuBN,WAAW;YAWX,gBAAgB;IAU9B,KAAK,IAAI,IAAI;CAKd"}
1
+ {"version":3,"file":"rpc-client.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-client.ts"],"names":[],"mappings":"AAQA,qBAAa,YAAY;IACvB,OAAO,CAAC,IAAI,CAAuB;IACnC,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,aAAa,CAAS;gBAElB,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAIjD,iFAAiF;IAC3E,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpC,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACnC,OAAO,CAAC,CAAC,CAAC;YASC,YAAY;YA6BZ,eAAe;IAe7B,4CAA4C;IACtC,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;YASvB,WAAW;YAIX,eAAe;IAoC7B,OAAO,CAAC,YAAY;YAuBN,WAAW;YAWX,gBAAgB;IAU9B,KAAK,IAAI,IAAI;CAKd"}
@@ -1,6 +1,22 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { type Dispatcher } from "undici";
1
3
  interface FetchUrlOptions {
2
4
  allowPrivate?: boolean;
5
+ /** Test-only injection point. Production fetches use undici with DNS pinning. */
6
+ fetchImpl?: FetchImpl;
7
+ /** Test-only injection point. Production DNS resolution uses node:dns/promises.lookup. */
8
+ lookup?: LookupFn;
9
+ /** Test-only injection point for observing the DNS-pinned dispatcher. */
10
+ dispatcherFactory?: (validatedIp: string) => Dispatcher;
3
11
  }
12
+ type LookupFn = typeof lookup;
13
+ interface FetchInit {
14
+ signal?: AbortSignal;
15
+ redirect?: "manual";
16
+ dispatcher?: Dispatcher;
17
+ headers?: Record<string, string>;
18
+ }
19
+ type FetchImpl = (input: string, init: FetchInit) => Promise<Response>;
4
20
  /** Exported for unit tests. */
5
21
  export declare function _isPrivateIpv4(address: string): boolean;
6
22
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"url-fetch.d.ts","sourceRoot":"","sources":["../../src/shared/url-fetch.ts"],"names":[],"mappings":"AAsBA,UAAU,eAAe;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAyBD,+BAA+B;AAC/B,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAavD;AAoND;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAgGjB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAkCxD"}
1
+ {"version":3,"file":"url-fetch.d.ts","sourceRoot":"","sources":["../../src/shared/url-fetch.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAW3C,OAAO,EAAS,KAAK,UAAU,EAAwB,MAAM,QAAQ,CAAC;AAWtE,UAAU,eAAe;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,iFAAiF;IACjF,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,yEAAyE;IACzE,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,UAAU,CAAC;CACzD;AAED,KAAK,QAAQ,GAAG,OAAO,MAAM,CAAC;AAC9B,UAAU,SAAS;IACjB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,KAAK,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAyBvE,+BAA+B;AAC/B,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAavD;AAgPD;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAgGjB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAkCxD"}
@@ -1,7 +1,21 @@
1
1
  import type { ToolDefinition } from "@opencode-ai/plugin";
2
2
  import type { PluginContext } from "../types.js";
3
+ interface ZoomBatchSymbolResult {
4
+ name: string;
5
+ success: boolean;
6
+ content?: string;
7
+ error?: string;
8
+ }
9
+ interface ZoomBatchResult {
10
+ complete: boolean;
11
+ symbols: ZoomBatchSymbolResult[];
12
+ text: string;
13
+ }
3
14
  /**
4
15
  * Tool definitions for code reading commands: outline + zoom.
5
16
  */
6
17
  export declare function readingTools(ctx: PluginContext): Record<string, ToolDefinition>;
18
+ /** Exported for regression tests. */
19
+ export declare function formatZoomBatchResult(symbols: string[], responses: Record<string, unknown>[]): ZoomBatchResult;
20
+ export {};
7
21
  //# sourceMappingURL=reading.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reading.d.ts","sourceRoot":"","sources":["../../src/tools/reading.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA2CjD;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAuK/E"}
1
+ {"version":3,"file":"reading.d.ts","sourceRoot":"","sources":["../../src/tools/reading.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA2CjD,UAAU,qBAAqB;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,eAAe;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CA0K/E;AAED,qCAAqC;AACrC,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EAAE,EACjB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACnC,eAAe,CA0BjB"}
package/dist/tui.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // package.json
3
3
  var package_default = {
4
4
  name: "@cortexkit/aft-opencode",
5
- version: "0.18.3",
5
+ version: "0.18.4",
6
6
  type: "module",
7
7
  description: "OpenCode plugin for Agent File Tools (AFT) \u2014 tree-sitter and lsp powered code analysis",
8
8
  main: "dist/index.js",
@@ -33,14 +33,15 @@ var package_default = {
33
33
  "@opencode-ai/plugin": "^1.2.26",
34
34
  "@opencode-ai/sdk": "^1.2.26",
35
35
  "comment-json": "^4.6.2",
36
+ undici: "^7.25.0",
36
37
  zod: "^4.1.8"
37
38
  },
38
39
  optionalDependencies: {
39
- "@cortexkit/aft-darwin-arm64": "0.18.3",
40
- "@cortexkit/aft-darwin-x64": "0.18.3",
41
- "@cortexkit/aft-linux-arm64": "0.18.3",
42
- "@cortexkit/aft-linux-x64": "0.18.3",
43
- "@cortexkit/aft-win32-x64": "0.18.3"
40
+ "@cortexkit/aft-darwin-arm64": "0.18.4",
41
+ "@cortexkit/aft-darwin-x64": "0.18.4",
42
+ "@cortexkit/aft-linux-arm64": "0.18.4",
43
+ "@cortexkit/aft-linux-x64": "0.18.4",
44
+ "@cortexkit/aft-win32-x64": "0.18.4"
44
45
  },
45
46
  devDependencies: {
46
47
  "@types/node": "^22.0.0",
@@ -157,17 +158,40 @@ class AftRpcClient {
157
158
  if (!info) {
158
159
  throw new Error("AFT RPC server not available");
159
160
  }
160
- const response = await this.fetchWithTimeout(`http://127.0.0.1:${info.port}/rpc/${method}`, {
161
- method: "POST",
162
- headers: { "Content-Type": "application/json" },
163
- body: JSON.stringify({ ...params, token: info.token })
164
- });
161
+ return this.callResolved(method, params, info, true);
162
+ }
163
+ async callResolved(method, params, info, retryOnConnectionFailure) {
164
+ let response;
165
+ try {
166
+ response = await this.fetchWithTimeout(`http://127.0.0.1:${info.port}/rpc/${method}`, {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({ ...params, token: info.token })
170
+ });
171
+ } catch (err) {
172
+ if (!retryOnConnectionFailure)
173
+ throw err;
174
+ return this.retryAfterReset(method, params, err);
175
+ }
165
176
  if (!response.ok) {
166
177
  const text = await response.text();
178
+ if (response.status >= 500 && retryOnConnectionFailure) {
179
+ return this.retryAfterReset(method, params, `HTTP ${response.status}: ${text}`);
180
+ }
167
181
  throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
168
182
  }
169
183
  return await response.json();
170
184
  }
185
+ async retryAfterReset(method, params, reason) {
186
+ const message = reason instanceof Error ? reason.message : String(reason);
187
+ warn(`RPC ${method} failed on cached port; retrying after port refresh (${message})`);
188
+ this.reset();
189
+ const refreshed = await this.resolvePortInfo();
190
+ if (!refreshed) {
191
+ throw new Error("AFT RPC server not available after port refresh");
192
+ }
193
+ return this.callResolved(method, params, refreshed, false);
194
+ }
171
195
  async isAvailable() {
172
196
  try {
173
197
  const port = await this.resolvePort();
@@ -0,0 +1,25 @@
1
+ import type { AftConfig } from "./config.js";
2
+ export interface WorkflowHintsOpts {
3
+ /** `tool_surface` setting — controls which tools are registered. */
4
+ toolSurface: "minimal" | "recommended" | "all";
5
+ /** `hoist_builtin_tools` setting — affects tool name (read vs aft_read). */
6
+ hoistBuiltins: boolean;
7
+ /** `experimental.semantic_search` — gates `aft_search` mention. */
8
+ semanticEnabled: boolean;
9
+ /** `experimental.bash.background` — gates background-bash paragraph. */
10
+ bashBackgroundEnabled: boolean;
11
+ /** Set of disabled tool names (after surface filtering). */
12
+ disabledTools: Set<string>;
13
+ }
14
+ /**
15
+ * Build the workflow hints block. Returns `null` when no hints are
16
+ * applicable for the configured surface (e.g. `tool_surface: "minimal"`
17
+ * with no aft_outline/aft_zoom available — only safety tool is registered).
18
+ */
19
+ export declare function buildWorkflowHints(opts: WorkflowHintsOpts): string | null;
20
+ /**
21
+ * Resolve workflow-hints opts from a loaded AftConfig and the active
22
+ * disabled-tools set computed at registration time.
23
+ */
24
+ export declare function buildHintsFromConfig(config: AftConfig, disabledTools: Set<string>): string | null;
25
+ //# sourceMappingURL=workflow-hints.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow-hints.d.ts","sourceRoot":"","sources":["../src/workflow-hints.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,WAAW,EAAE,SAAS,GAAG,aAAa,GAAG,KAAK,CAAC;IAC/C,4EAA4E;IAC5E,aAAa,EAAE,OAAO,CAAC;IACvB,mEAAmE;IACnE,eAAe,EAAE,OAAO,CAAC;IACzB,wEAAwE;IACxE,qBAAqB,EAAE,OAAO,CAAC;IAC/B,4DAA4D;IAC5D,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC5B;AAID;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CAoEzE;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,EAAE,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAQjG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/aft-opencode",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Agent File Tools (AFT) — tree-sitter and lsp powered code analysis",
6
6
  "main": "dist/index.js",
@@ -31,14 +31,15 @@
31
31
  "@opencode-ai/plugin": "^1.2.26",
32
32
  "@opencode-ai/sdk": "^1.2.26",
33
33
  "comment-json": "^4.6.2",
34
+ "undici": "^7.25.0",
34
35
  "zod": "^4.1.8"
35
36
  },
36
37
  "optionalDependencies": {
37
- "@cortexkit/aft-darwin-arm64": "0.18.3",
38
- "@cortexkit/aft-darwin-x64": "0.18.3",
39
- "@cortexkit/aft-linux-arm64": "0.18.3",
40
- "@cortexkit/aft-linux-x64": "0.18.3",
41
- "@cortexkit/aft-win32-x64": "0.18.3"
38
+ "@cortexkit/aft-darwin-arm64": "0.18.4",
39
+ "@cortexkit/aft-darwin-x64": "0.18.4",
40
+ "@cortexkit/aft-linux-arm64": "0.18.4",
41
+ "@cortexkit/aft-linux-x64": "0.18.4",
42
+ "@cortexkit/aft-win32-x64": "0.18.4"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/node": "^22.0.0",
@@ -26,20 +26,53 @@ export class AftRpcClient {
26
26
  throw new Error("AFT RPC server not available");
27
27
  }
28
28
 
29
- const response = await this.fetchWithTimeout(`http://127.0.0.1:${info.port}/rpc/${method}`, {
30
- method: "POST",
31
- headers: { "Content-Type": "application/json" },
32
- body: JSON.stringify({ ...params, token: info.token }),
33
- });
29
+ return this.callResolved<T>(method, params, info, true);
30
+ }
31
+
32
+ private async callResolved<T>(
33
+ method: string,
34
+ params: Record<string, unknown>,
35
+ info: { port: number; token: string | null },
36
+ retryOnConnectionFailure: boolean,
37
+ ): Promise<T> {
38
+ let response: Response;
39
+ try {
40
+ response = await this.fetchWithTimeout(`http://127.0.0.1:${info.port}/rpc/${method}`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({ ...params, token: info.token }),
44
+ });
45
+ } catch (err) {
46
+ if (!retryOnConnectionFailure) throw err;
47
+ return this.retryAfterReset<T>(method, params, err);
48
+ }
34
49
 
35
50
  if (!response.ok) {
36
51
  const text = await response.text();
52
+ if (response.status >= 500 && retryOnConnectionFailure) {
53
+ return this.retryAfterReset<T>(method, params, `HTTP ${response.status}: ${text}`);
54
+ }
37
55
  throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
38
56
  }
39
57
 
40
58
  return (await response.json()) as T;
41
59
  }
42
60
 
61
+ private async retryAfterReset<T>(
62
+ method: string,
63
+ params: Record<string, unknown>,
64
+ reason: unknown,
65
+ ): Promise<T> {
66
+ const message = reason instanceof Error ? reason.message : String(reason);
67
+ warn(`RPC ${method} failed on cached port; retrying after port refresh (${message})`);
68
+ this.reset();
69
+ const refreshed = await this.resolvePortInfo();
70
+ if (!refreshed) {
71
+ throw new Error("AFT RPC server not available after port refresh");
72
+ }
73
+ return this.callResolved<T>(method, params, refreshed, false);
74
+ }
75
+
43
76
  /** Check if the RPC server is reachable. */
44
77
  async isAvailable(): Promise<boolean> {
45
78
  try {
@@ -10,6 +10,7 @@ import {
10
10
  } from "node:fs";
11
11
  import { isIP } from "node:net";
12
12
  import { join } from "node:path";
13
+ import { Agent, type Dispatcher, fetch as undiciFetch } from "undici";
13
14
  import { log, warn } from "../logger";
14
15
 
15
16
  /** Max response body size (10 MB) */
@@ -22,8 +23,24 @@ const MAX_REDIRECTS = 5;
22
23
 
23
24
  interface FetchUrlOptions {
24
25
  allowPrivate?: boolean;
26
+ /** Test-only injection point. Production fetches use undici with DNS pinning. */
27
+ fetchImpl?: FetchImpl;
28
+ /** Test-only injection point. Production DNS resolution uses node:dns/promises.lookup. */
29
+ lookup?: LookupFn;
30
+ /** Test-only injection point for observing the DNS-pinned dispatcher. */
31
+ dispatcherFactory?: (validatedIp: string) => Dispatcher;
25
32
  }
26
33
 
34
+ type LookupFn = typeof lookup;
35
+ interface FetchInit {
36
+ signal?: AbortSignal;
37
+ redirect?: "manual";
38
+ dispatcher?: Dispatcher;
39
+ headers?: Record<string, string>;
40
+ }
41
+
42
+ type FetchImpl = (input: string, init: FetchInit) => Promise<Response>;
43
+
27
44
  interface CacheMeta {
28
45
  url: string;
29
46
  contentType: string;
@@ -163,11 +180,15 @@ function isPrivateIp(address: string): boolean {
163
180
  return true;
164
181
  }
165
182
 
166
- async function assertPublicUrl(url: URL, allowPrivate: boolean): Promise<void> {
183
+ async function assertPublicUrl(
184
+ url: URL,
185
+ allowPrivate: boolean,
186
+ dnsLookup: LookupFn = lookup,
187
+ ): Promise<string | undefined> {
167
188
  if (url.protocol !== "http:" && url.protocol !== "https:") {
168
189
  throw new Error(`Only http:// and https:// URLs are supported, got: ${url.protocol}`);
169
190
  }
170
- if (allowPrivate) return;
191
+ if (allowPrivate) return undefined;
171
192
 
172
193
  // URL.hostname returns IPv6 literals wrapped in brackets ("[::1]") per WHATWG.
173
194
  // Strip them before checking the address.
@@ -180,15 +201,29 @@ async function assertPublicUrl(url: URL, allowPrivate: boolean): Promise<void> {
180
201
  if (isPrivateIp(hostname)) {
181
202
  throw new Error(`Blocked private URL host ${url.hostname} (${hostname})`);
182
203
  }
183
- return;
204
+ return hostname;
184
205
  }
185
206
 
186
- const addresses = await lookup(hostname, { all: true, verbatim: true });
207
+ const addresses = await dnsLookup(hostname, { all: true, verbatim: true });
187
208
  for (const { address } of addresses) {
188
209
  if (isPrivateIp(address)) {
189
210
  throw new Error(`Blocked private URL host ${url.hostname} (${address})`);
190
211
  }
191
212
  }
213
+ if (addresses.length === 0) {
214
+ throw new Error(`Failed to resolve URL host ${url.hostname}`);
215
+ }
216
+ return addresses[0].address;
217
+ }
218
+
219
+ function createPinnedDispatcher(validatedIp: string): Dispatcher {
220
+ return new Agent({
221
+ connect: {
222
+ lookup: (_hostname, _opts, callback) => {
223
+ callback(null, validatedIp, validatedIp.includes(":") ? 6 : 4);
224
+ },
225
+ },
226
+ });
192
227
  }
193
228
 
194
229
  function resolveRedirectUrl(currentUrl: URL, location: string | null): URL {
@@ -198,19 +233,29 @@ function resolveRedirectUrl(currentUrl: URL, location: string | null): URL {
198
233
  return new URL(location, currentUrl);
199
234
  }
200
235
 
201
- async function fetchWithRedirects(startUrl: URL, allowPrivate: boolean): Promise<Response> {
236
+ async function fetchWithRedirects(
237
+ startUrl: URL,
238
+ allowPrivate: boolean,
239
+ deps: Pick<FetchUrlOptions, "dispatcherFactory" | "fetchImpl" | "lookup"> = {},
240
+ ): Promise<Response> {
202
241
  let currentUrl = startUrl;
242
+ const fetchImpl = deps.fetchImpl ?? (undiciFetch as FetchImpl);
243
+ const dnsLookup = deps.lookup ?? lookup;
203
244
 
204
245
  for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
205
- await assertPublicUrl(currentUrl, allowPrivate);
246
+ const validatedIp = await assertPublicUrl(currentUrl, allowPrivate, dnsLookup);
247
+ const dispatcher = validatedIp
248
+ ? (deps.dispatcherFactory ?? createPinnedDispatcher)(validatedIp)
249
+ : undefined;
206
250
 
207
251
  const controller = new AbortController();
208
252
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
209
253
  let response: Response;
210
254
  try {
211
- response = await fetch(currentUrl.href, {
255
+ response = await fetchImpl(currentUrl.href, {
212
256
  signal: controller.signal,
213
257
  redirect: "manual",
258
+ dispatcher,
214
259
  headers: {
215
260
  "user-agent": "aft-opencode-plugin",
216
261
  // Prioritize markdown for content-negotiating servers (GitHub API, many docs sites).
@@ -316,7 +361,7 @@ export async function fetchUrlToTempFile(
316
361
  }
317
362
 
318
363
  log(`Fetching URL: ${url}`);
319
- const response = await fetchWithRedirects(parsed, allowPrivate);
364
+ const response = await fetchWithRedirects(parsed, allowPrivate, options);
320
365
 
321
366
  if (!response.ok) {
322
367
  throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`);