@gaberrb/polypus 0.4.10 → 0.4.12

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/dist/index.js CHANGED
@@ -143,6 +143,7 @@ var en = {
143
143
  "run.autocorrect": "\u21BB tool failed \u2014 auto-correcting with extra context",
144
144
  "run.cancelled": "\u25A0 cancelled",
145
145
  "compaction.done": "context compacted: ~{before} \u2192 ~{after} tokens",
146
+ "tools.customLoaded": "loaded custom tool(s): {names}",
146
147
  "run.jsonNeedsTask": "--json requires a task argument (headless mode has no interactive REPL).",
147
148
  "review.approveAll": "approve all",
148
149
  "review.reject": "reject",
@@ -204,6 +205,7 @@ var en = {
204
205
  "swarm.needsAgents": "Swarm mode needs at least {min} configured agents (you have {have}). Add more with `polypus add-agent`, or use `polypus run` for a single agent.",
205
206
  "swarm.status": "swarm agents=[{agents}] workspace={workspace}",
206
207
  "swarm.bypassNote": "Workers run in bypass mode inside isolated git worktrees; branches are merged at the end.",
208
+ "swarm.cancelling": "cancelling swarm \u2014 finishing in-flight workers, then merging what committed\u2026",
207
209
  "swarm.decomposed": "Decomposed into {n} subtask(s):",
208
210
  "swarm.workerStart": "\u25B6 {id} started by {agent}",
209
211
  "swarm.workerDone": "\u2713 {id} done",
@@ -403,6 +405,7 @@ var ptBR = {
403
405
  "run.autocorrect": "\u21BB tool falhou \u2014 autocorrigindo com contexto extra",
404
406
  "run.cancelled": "\u25A0 cancelado",
405
407
  "compaction.done": "contexto compactado: ~{before} \u2192 ~{after} tokens",
408
+ "tools.customLoaded": "tool(s) customizada(s) carregada(s): {names}",
406
409
  "run.jsonNeedsTask": "--json exige um argumento de tarefa (o modo headless n\xE3o tem REPL interativo).",
407
410
  "review.approveAll": "aprovar tudo",
408
411
  "review.reject": "rejeitar",
@@ -462,6 +465,7 @@ var ptBR = {
462
465
  "swarm.needsAgents": "O modo swarm precisa de pelo menos {min} agentes configurados (voc\xEA tem {have}). Adicione mais com `polypus add-agent`, ou use `polypus run` para um agente s\xF3.",
463
466
  "swarm.status": "swarm agentes=[{agents}] workspace={workspace}",
464
467
  "swarm.bypassNote": "Os workers rodam em modo bypass dentro de git worktrees isoladas; os branches s\xE3o mesclados no final.",
468
+ "swarm.cancelling": "cancelando o swarm \u2014 encerrando os workers em andamento e mesclando o que commitou\u2026",
465
469
  "swarm.decomposed": "Dividido em {n} subtarefa(s):",
466
470
  "swarm.workerStart": "\u25B6 {id} iniciada por {agent}",
467
471
  "swarm.workerDone": "\u2713 {id} conclu\xEDda",
@@ -2144,6 +2148,66 @@ ${summary.content.trim()}`
2144
2148
  return system ? [system, summaryMessage, ...tail] : [summaryMessage, ...tail];
2145
2149
  }
2146
2150
 
2151
+ // src/core/agent/hooks.ts
2152
+ import { exec as exec2 } from "child_process";
2153
+ import { readFile as readFile8 } from "fs/promises";
2154
+ import { join as join4 } from "path";
2155
+ import { promisify as promisify2 } from "util";
2156
+ import { z as z8 } from "zod";
2157
+ var execAsync2 = promisify2(exec2);
2158
+ var HOOK_TIMEOUT = 12e4;
2159
+ var HooksSchema = z8.object({
2160
+ /** Shell command run after a successful write_file. `{path}` is substituted. */
2161
+ afterWrite: z8.string().optional(),
2162
+ /** Shell command run after a successful edit_file. `{path}` is substituted. */
2163
+ afterEdit: z8.string().optional(),
2164
+ /** Shell command run after any successful mutating tool. `{tool}`/`{path}` substituted. */
2165
+ afterTool: z8.string().optional(),
2166
+ /** Block run_command when the command contains any of these substrings. */
2167
+ beforeCommand: z8.object({ deny: z8.array(z8.string()).default([]) }).optional()
2168
+ });
2169
+ async function loadHooks(workspace) {
2170
+ try {
2171
+ const raw = await readFile8(join4(workspace, ".poly", "hooks.json"), "utf8");
2172
+ const parsed = HooksSchema.safeParse(JSON.parse(raw));
2173
+ return parsed.success ? parsed.data : void 0;
2174
+ } catch {
2175
+ return void 0;
2176
+ }
2177
+ }
2178
+ function screenCommandHook(hooks, command) {
2179
+ const deny = hooks?.beforeCommand?.deny ?? [];
2180
+ for (const needle of deny) {
2181
+ if (needle && command.includes(needle)) {
2182
+ return { blocked: true, reason: `matches deny rule "${needle}"` };
2183
+ }
2184
+ }
2185
+ return { blocked: false };
2186
+ }
2187
+ function substitute(template, call) {
2188
+ const path = typeof call.arguments.path === "string" ? call.arguments.path : "";
2189
+ return template.replace(/\{path\}/g, path).replace(/\{tool\}/g, call.name);
2190
+ }
2191
+ async function runAfterHook(hooks, call, workspace) {
2192
+ if (!hooks) return void 0;
2193
+ const commands = [];
2194
+ if (call.name === "write_file" && hooks.afterWrite) commands.push({ label: "afterWrite", cmd: hooks.afterWrite });
2195
+ if (call.name === "edit_file" && hooks.afterEdit) commands.push({ label: "afterEdit", cmd: hooks.afterEdit });
2196
+ if (hooks.afterTool) commands.push({ label: "afterTool", cmd: hooks.afterTool });
2197
+ if (commands.length === 0) return void 0;
2198
+ const notes = [];
2199
+ for (const { label, cmd } of commands) {
2200
+ const resolved = substitute(cmd, call);
2201
+ try {
2202
+ await execAsync2(resolved, { cwd: workspace, timeout: HOOK_TIMEOUT, windowsHide: true });
2203
+ notes.push(`\u21AA hook ${label} ok`);
2204
+ } catch (err) {
2205
+ notes.push(`\u21AA hook ${label} failed: ${err.message.split("\n")[0]}`);
2206
+ }
2207
+ }
2208
+ return notes.join("\n");
2209
+ }
2210
+
2147
2211
  // src/core/agent/loop.ts
2148
2212
  function looksLikeStall(text2) {
2149
2213
  const lc = text2.toLowerCase();
@@ -2184,7 +2248,13 @@ async function runAgent(opts) {
2184
2248
  const { agent, permissions, events } = opts;
2185
2249
  const maxSteps = opts.maxSteps ?? 30;
2186
2250
  const maxReprompts = opts.maxReprompts ?? 3;
2187
- const driver = makeDriver(agent.toolMode, toolSpecs());
2251
+ const extra = opts.extraTools ?? [];
2252
+ const extraByName = new Map(extra.map((tl) => [tl.spec.name, tl]));
2253
+ const baseSpecs = toolSpecs();
2254
+ const finishSpec = baseSpecs[baseSpecs.length - 1];
2255
+ const allSpecs = [...baseSpecs.slice(0, -1), ...extra.map((tl) => tl.spec), finishSpec];
2256
+ const resolveTool = (name) => extraByName.get(name) ?? getTool(name);
2257
+ const driver = makeDriver(agent.toolMode, allSpecs);
2188
2258
  const ctx = { workspace: opts.workspace, permissions };
2189
2259
  const seeding = !(opts.history && opts.history.length > 0);
2190
2260
  const promptContext = seeding && opts.promptContext.projectInstructions === void 0 ? { ...opts.promptContext, projectInstructions: await loadProjectInstructions(opts.workspace) } : opts.promptContext;
@@ -2265,8 +2335,21 @@ async function runAgent(opts) {
2265
2335
  const summary = String(call.arguments.summary ?? "").trim();
2266
2336
  return { finished: true, reason: "finished", summary, steps: step, messages, usage: usage2 };
2267
2337
  }
2268
- const tool = getTool(call.name);
2269
- const result = tool ? await tool.run(call.arguments, ctx) : { ok: false, output: `Unknown tool "${call.name}". Available: ${toolSpecs().map((t2) => t2.name).join(", ")}` };
2338
+ const tool = resolveTool(call.name);
2339
+ const hookScreen = call.name === "run_command" ? screenCommandHook(opts.hooks, String(call.arguments.command ?? "")) : { blocked: false };
2340
+ let result;
2341
+ if (hookScreen.blocked) {
2342
+ result = { ok: false, output: `Command blocked by hook: ${hookScreen.reason}` };
2343
+ } else if (tool) {
2344
+ result = await tool.run(call.arguments, ctx);
2345
+ if (result.ok) {
2346
+ const note2 = await runAfterHook(opts.hooks, call, opts.workspace);
2347
+ if (note2) result = { ...result, output: `${result.output}
2348
+ ${note2}` };
2349
+ }
2350
+ } else {
2351
+ result = { ok: false, output: `Unknown tool "${call.name}". Available: ${allSpecs.map((t2) => t2.name).join(", ")}` };
2352
+ }
2270
2353
  events?.onToolResult?.(call, result);
2271
2354
  const sig = `${call.name}:${JSON.stringify(call.arguments)}`;
2272
2355
  let resultText = result.output;
@@ -2310,7 +2393,7 @@ ${guidance}`;
2310
2393
  }
2311
2394
 
2312
2395
  // src/core/context/mentions.ts
2313
- import { readdir as readdir4, readFile as readFile8, stat as stat2 } from "fs/promises";
2396
+ import { readdir as readdir4, readFile as readFile9, stat as stat2 } from "fs/promises";
2314
2397
  import { resolve as resolve9 } from "path";
2315
2398
  var MAX_FILE_CHARS = 1e4;
2316
2399
  var MENTION_RE = /(?:^|\s)@([\w./-]+)/g;
@@ -2337,7 +2420,7 @@ ${t("mentions.notFound", { path: token })}`);
2337
2420
  ${listing || "(empty)"}`);
2338
2421
  injected.push(decision.rel);
2339
2422
  } else {
2340
- const raw = await readFile8(abs, "utf8");
2423
+ const raw = await readFile9(abs, "utf8");
2341
2424
  const content = raw.length > MAX_FILE_CHARS ? raw.slice(0, MAX_FILE_CHARS) + "\n\u2026[truncated]" : raw;
2342
2425
  blocks.push(`## @${decision.rel}
2343
2426
  \`\`\`
@@ -2360,16 +2443,16 @@ ${blocks.join("\n\n")}`;
2360
2443
  }
2361
2444
 
2362
2445
  // src/core/agent/verify.ts
2363
- import { exec as exec2 } from "child_process";
2364
- import { readFile as readFile9 } from "fs/promises";
2446
+ import { exec as exec3 } from "child_process";
2447
+ import { readFile as readFile10 } from "fs/promises";
2365
2448
  import { resolve as resolve10 } from "path";
2366
- import { promisify as promisify2 } from "util";
2367
- var execAsync2 = promisify2(exec2);
2449
+ import { promisify as promisify3 } from "util";
2450
+ var execAsync3 = promisify3(exec3);
2368
2451
  var MAX_OUTPUT3 = 8e3;
2369
2452
  var CHECK_SCRIPTS = ["typecheck", "build", "test"];
2370
2453
  async function detectChecks(workspace) {
2371
2454
  try {
2372
- const raw = await readFile9(resolve10(workspace, "package.json"), "utf8");
2455
+ const raw = await readFile10(resolve10(workspace, "package.json"), "utf8");
2373
2456
  const scripts = JSON.parse(raw).scripts ?? {};
2374
2457
  return CHECK_SCRIPTS.filter((s) => typeof scripts[s] === "string").map((s) => `npm run ${s}`);
2375
2458
  } catch {
@@ -2380,7 +2463,7 @@ async function runChecks(workspace, commands) {
2380
2463
  const results = [];
2381
2464
  for (const command of commands) {
2382
2465
  try {
2383
- const { stdout: stdout2, stderr } = await execAsync2(command, {
2466
+ const { stdout: stdout2, stderr } = await execAsync3(command, {
2384
2467
  cwd: workspace,
2385
2468
  timeout: 3e5,
2386
2469
  maxBuffer: 10 * 1024 * 1024,
@@ -2412,8 +2495,8 @@ function clamp2(s) {
2412
2495
  }
2413
2496
 
2414
2497
  // src/core/agent/usage.ts
2415
- import { appendFile, mkdir as mkdir3, readFile as readFile10 } from "fs/promises";
2416
- import { join as join4 } from "path";
2498
+ import { appendFile, mkdir as mkdir3, readFile as readFile11 } from "fs/promises";
2499
+ import { join as join5 } from "path";
2417
2500
 
2418
2501
  // src/core/providers/openrouter.ts
2419
2502
  var MODELS_URL = "https://openrouter.ai/api/v1/models";
@@ -2513,7 +2596,7 @@ function fmtUsd(n) {
2513
2596
  return `US$${n.toFixed(2)}`;
2514
2597
  }
2515
2598
  function usagePath() {
2516
- return join4(configDir(), "usage.jsonl");
2599
+ return join5(configDir(), "usage.jsonl");
2517
2600
  }
2518
2601
  async function recordUsage(entry) {
2519
2602
  try {
@@ -2525,7 +2608,7 @@ async function recordUsage(entry) {
2525
2608
  async function aggregateUsage() {
2526
2609
  let text2 = "";
2527
2610
  try {
2528
- text2 = await readFile10(usagePath(), "utf8");
2611
+ text2 = await readFile11(usagePath(), "utf8");
2529
2612
  } catch {
2530
2613
  return { days: [], total: emptyBucket("total") };
2531
2614
  }
@@ -2559,10 +2642,10 @@ function accumulate(bucket, e) {
2559
2642
  }
2560
2643
 
2561
2644
  // src/core/agent/session-store.ts
2562
- import { mkdir as mkdir4, readFile as readFile11, readdir as readdir5, writeFile as writeFile4 } from "fs/promises";
2563
- import { join as join5 } from "path";
2645
+ import { mkdir as mkdir4, readFile as readFile12, readdir as readdir5, writeFile as writeFile4 } from "fs/promises";
2646
+ import { join as join6 } from "path";
2564
2647
  function sessionsDir() {
2565
- return join5(configDir(), "sessions");
2648
+ return join6(configDir(), "sessions");
2566
2649
  }
2567
2650
  function newSessionId() {
2568
2651
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -2570,7 +2653,7 @@ function newSessionId() {
2570
2653
  return `${stamp}-${rand}`;
2571
2654
  }
2572
2655
  function sessionPath(id) {
2573
- return join5(sessionsDir(), `${id}.json`);
2656
+ return join6(sessionsDir(), `${id}.json`);
2574
2657
  }
2575
2658
  async function saveSession(record) {
2576
2659
  await mkdir4(sessionsDir(), { recursive: true });
@@ -2582,7 +2665,7 @@ async function saveSession(record) {
2582
2665
  }
2583
2666
  async function loadSession(id) {
2584
2667
  try {
2585
- return JSON.parse(await readFile11(sessionPath(id), "utf8"));
2668
+ return JSON.parse(await readFile12(sessionPath(id), "utf8"));
2586
2669
  } catch {
2587
2670
  return void 0;
2588
2671
  }
@@ -2597,7 +2680,7 @@ async function listSessions() {
2597
2680
  const summaries = [];
2598
2681
  for (const f of files) {
2599
2682
  try {
2600
- const r = JSON.parse(await readFile11(join5(sessionsDir(), f), "utf8"));
2683
+ const r = JSON.parse(await readFile12(join6(sessionsDir(), f), "utf8"));
2601
2684
  summaries.push({
2602
2685
  id: r.id,
2603
2686
  updatedAt: r.updatedAt,
@@ -2621,6 +2704,83 @@ function deriveTitle(messages) {
2621
2704
  return text2.length > 60 ? text2.slice(0, 60) + "\u2026" : text2 || "(untitled)";
2622
2705
  }
2623
2706
 
2707
+ // src/core/tools/custom.ts
2708
+ import { exec as exec4 } from "child_process";
2709
+ import { readFile as readFile13, readdir as readdir6 } from "fs/promises";
2710
+ import { join as join7 } from "path";
2711
+ import { promisify as promisify4 } from "util";
2712
+ import { z as z9 } from "zod";
2713
+ var execAsync4 = promisify4(exec4);
2714
+ var MAX_OUTPUT4 = 2e4;
2715
+ var CustomToolSchema = z9.object({
2716
+ name: z9.string().min(1).regex(/^[a-z][a-z0-9_]*$/i, "tool name must be alphanumeric/underscore"),
2717
+ description: z9.string().min(1),
2718
+ /** JSON-schema object for the tool parameters (advertised to the model). */
2719
+ parameters: z9.record(z9.unknown()).optional(),
2720
+ /** Shell command template; `{argName}` placeholders are filled from the call arguments. */
2721
+ command: z9.string().min(1)
2722
+ });
2723
+ function makeCommandTool(def) {
2724
+ return {
2725
+ mutating: true,
2726
+ spec: {
2727
+ name: def.name,
2728
+ description: def.description,
2729
+ parameters: def.parameters ?? { type: "object", properties: {} }
2730
+ },
2731
+ async run(rawArgs, ctx) {
2732
+ const command = fillTemplate(def.command, rawArgs);
2733
+ const decision = await ctx.permissions.authorizeCommand(command);
2734
+ if (!decision.allowed) return { ok: false, output: `Command denied: ${decision.reason}` };
2735
+ try {
2736
+ const { stdout: stdout2, stderr } = await execAsync4(command, {
2737
+ cwd: ctx.workspace,
2738
+ timeout: 12e4,
2739
+ maxBuffer: 10 * 1024 * 1024,
2740
+ windowsHide: true
2741
+ });
2742
+ return { ok: true, output: clamp3(`${stdout2}${stderr ? `
2743
+ [stderr]
2744
+ ${stderr}` : ""}`.trim() || "(no output)") };
2745
+ } catch (err) {
2746
+ const e = err;
2747
+ return {
2748
+ ok: false,
2749
+ output: clamp3(`Command failed (exit ${e.code ?? "?"}): ${e.message}
2750
+ ${e.stdout ?? ""}${e.stderr ?? ""}`)
2751
+ };
2752
+ }
2753
+ }
2754
+ };
2755
+ }
2756
+ async function loadCustomTools(workspace) {
2757
+ let files;
2758
+ try {
2759
+ files = (await readdir6(join7(workspace, ".poly", "tools"))).filter((f) => f.endsWith(".json"));
2760
+ } catch {
2761
+ return [];
2762
+ }
2763
+ const tools = [];
2764
+ for (const f of files) {
2765
+ try {
2766
+ const raw = await readFile13(join7(workspace, ".poly", "tools", f), "utf8");
2767
+ const parsed = CustomToolSchema.safeParse(JSON.parse(raw));
2768
+ if (parsed.success) tools.push(makeCommandTool(parsed.data));
2769
+ } catch {
2770
+ }
2771
+ }
2772
+ return tools;
2773
+ }
2774
+ function fillTemplate(template, args) {
2775
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
2776
+ const v = args[key];
2777
+ return v === void 0 || v === null ? "" : String(v);
2778
+ });
2779
+ }
2780
+ function clamp3(s) {
2781
+ return s.length > MAX_OUTPUT4 ? s.slice(0, MAX_OUTPUT4) + "\n\u2026[truncated]" : s;
2782
+ }
2783
+
2624
2784
  // src/cli/commands/json-output.ts
2625
2785
  var OUTPUT_PREVIEW = 500;
2626
2786
  function createJsonCollector() {
@@ -3419,7 +3579,7 @@ import pc7 from "picocolors";
3419
3579
  // src/core/git/worktree.ts
3420
3580
  import { mkdtemp } from "fs/promises";
3421
3581
  import { tmpdir } from "os";
3422
- import { join as join6 } from "path";
3582
+ import { join as join8 } from "path";
3423
3583
  import { simpleGit } from "simple-git";
3424
3584
  async function ensureRepo(workspace) {
3425
3585
  const git = simpleGit(workspace);
@@ -3440,7 +3600,7 @@ async function identityArgs(git) {
3440
3600
  }
3441
3601
  async function createWorktree(git, label) {
3442
3602
  const branch = `polypus/${label}-${Date.now().toString(36)}`;
3443
- const path = await mkdtemp(join6(tmpdir(), "polypus-wt-"));
3603
+ const path = await mkdtemp(join8(tmpdir(), "polypus-wt-"));
3444
3604
  await git.raw(["worktree", "add", "-b", branch, path, "HEAD"]);
3445
3605
  return { path, branch };
3446
3606
  }
@@ -3474,7 +3634,7 @@ async function removeWorktree(git, wt) {
3474
3634
  }
3475
3635
 
3476
3636
  // src/core/agent/worker.ts
3477
- async function runWorker(subtask, agent, wt, allow, deny, events) {
3637
+ async function runWorker(subtask, agent, wt, allow, deny, events, signal) {
3478
3638
  const permissions = new PermissionEngine({
3479
3639
  mode: "bypass",
3480
3640
  policy: { workspace: wt.path, allow, deny },
@@ -3486,9 +3646,10 @@ async function runWorker(subtask, agent, wt, allow, deny, events) {
3486
3646
  agent,
3487
3647
  permissions,
3488
3648
  promptContext: { workspace: wt.path, mode: "bypass", allow, briefing: subtask.brief },
3489
- events
3649
+ events,
3650
+ signal
3490
3651
  });
3491
- const committed = await commitWorktree(wt, `polypus(${subtask.id}): ${subtask.title}`);
3652
+ const committed = result.reason === "cancelled" ? false : await commitWorktree(wt, `polypus(${subtask.id}): ${subtask.title}`);
3492
3653
  return {
3493
3654
  subtask,
3494
3655
  agentName: agent.config.name,
@@ -3505,30 +3666,23 @@ async function runSwarm(opts) {
3505
3666
  const lead = opts.agents[0];
3506
3667
  if (!lead) throw new Error("Swarm requires at least one agent.");
3507
3668
  const maxSubtasks = opts.maxSubtasks ?? Math.max(opts.agents.length, 2);
3669
+ const concurrency = Math.max(1, opts.concurrency ?? opts.agents.length);
3670
+ const idleTimeoutMs2 = opts.idleTimeoutMs ?? 0;
3508
3671
  const git = await ensureRepo(opts.workspace);
3509
- const subtasks = await decompose(lead, opts.task, maxSubtasks);
3672
+ const subtasks = await decompose(lead, opts.task, maxSubtasks, opts.signal);
3510
3673
  opts.events?.onDecomposed?.(subtasks);
3511
3674
  const worktrees = [];
3512
3675
  for (const subtask of subtasks) {
3513
3676
  worktrees.push(await createWorktree(git, subtask.id));
3514
3677
  }
3515
- const outcomes = await Promise.all(
3516
- subtasks.map(async (subtask, i) => {
3517
- const agent = opts.agents[i % opts.agents.length];
3518
- const wt = worktrees[i];
3519
- opts.events?.onWorkerStart?.(subtask, agent.config.name);
3520
- const outcome = await runWorker(
3521
- subtask,
3522
- agent,
3523
- wt,
3524
- opts.allow,
3525
- opts.deny,
3526
- opts.events?.workerEvents?.(subtask)
3527
- );
3528
- opts.events?.onWorkerDone?.(outcome);
3529
- return outcome;
3530
- })
3531
- );
3678
+ const runOne = (subtask, i) => guardedWorker(subtask, opts.agents[i % opts.agents.length], worktrees[i], {
3679
+ allow: opts.allow,
3680
+ deny: opts.deny,
3681
+ idleTimeoutMs: idleTimeoutMs2,
3682
+ signal: opts.signal,
3683
+ events: opts.events
3684
+ });
3685
+ const outcomes = await runPool(subtasks, concurrency, runOne);
3532
3686
  const merges = [];
3533
3687
  for (const outcome of outcomes) {
3534
3688
  if (!outcome.committed) continue;
@@ -3546,13 +3700,69 @@ async function runSwarm(opts) {
3546
3700
  }
3547
3701
  return { subtasks, outcomes, merges };
3548
3702
  }
3703
+ async function guardedWorker(subtask, agent, wt, opts) {
3704
+ const ac = new AbortController();
3705
+ const onAbort = () => ac.abort();
3706
+ if (opts.signal) {
3707
+ if (opts.signal.aborted) ac.abort();
3708
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
3709
+ }
3710
+ let idleTimer;
3711
+ const resetIdle = () => {
3712
+ if (opts.idleTimeoutMs <= 0) return;
3713
+ if (idleTimer) clearTimeout(idleTimer);
3714
+ idleTimer = setTimeout(() => ac.abort(), opts.idleTimeoutMs);
3715
+ idleTimer.unref?.();
3716
+ };
3717
+ const base = opts.events?.workerEvents?.(subtask);
3718
+ const events = {
3719
+ ...base,
3720
+ onStep: (n) => {
3721
+ resetIdle();
3722
+ base?.onStep?.(n);
3723
+ }
3724
+ };
3725
+ opts.events?.onWorkerStart?.(subtask, agent.config.name);
3726
+ resetIdle();
3727
+ let outcome;
3728
+ try {
3729
+ outcome = await runWorker(subtask, agent, wt, opts.allow, opts.deny, events, ac.signal);
3730
+ } catch {
3731
+ outcome = {
3732
+ subtask,
3733
+ agentName: agent.config.name,
3734
+ branch: wt.branch,
3735
+ finished: false,
3736
+ committed: false,
3737
+ steps: 0
3738
+ };
3739
+ } finally {
3740
+ if (idleTimer) clearTimeout(idleTimer);
3741
+ opts.signal?.removeEventListener("abort", onAbort);
3742
+ }
3743
+ opts.events?.onWorkerDone?.(outcome);
3744
+ return outcome;
3745
+ }
3746
+ async function runPool(items, limit, fn) {
3747
+ const results = new Array(items.length);
3748
+ let next = 0;
3749
+ const worker = async () => {
3750
+ for (; ; ) {
3751
+ const i = next++;
3752
+ if (i >= items.length) return;
3753
+ results[i] = await fn(items[i], i);
3754
+ }
3755
+ };
3756
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
3757
+ return results;
3758
+ }
3549
3759
  var DECOMPOSE_SYSTEM = [
3550
3760
  "You are a tech lead splitting a coding task into independent subtasks that can be done in parallel.",
3551
3761
  'Return ONLY a JSON array. Each item: {"title": string, "brief": string}.',
3552
3762
  "Make subtasks touch DIFFERENT files/areas to minimize merge conflicts.",
3553
3763
  "Keep the list small (prefer 2-4 items). Each brief must be self-contained and actionable."
3554
3764
  ].join("\n");
3555
- async function decompose(lead, task, maxSubtasks) {
3765
+ async function decompose(lead, task, maxSubtasks, signal) {
3556
3766
  try {
3557
3767
  const res = await lead.provider.chat({
3558
3768
  messages: [
@@ -3562,7 +3772,8 @@ ${task}
3562
3772
 
3563
3773
  Return at most ${maxSubtasks} subtasks as a JSON array.` }
3564
3774
  ],
3565
- params: { temperature: 0 }
3775
+ params: { temperature: 0 },
3776
+ signal
3566
3777
  });
3567
3778
  const parsed = extractJsonArray(res.content);
3568
3779
  if (parsed && parsed.length > 0) {
@@ -3588,6 +3799,28 @@ function extractJsonArray(text2) {
3588
3799
  }
3589
3800
  }
3590
3801
 
3802
+ // src/core/agent/concurrency.ts
3803
+ var OLLAMA_ENDPOINT_CONCURRENCY = 2;
3804
+ var DEFAULT_IDLE_TIMEOUT_MS = 3e5;
3805
+ function endpointKey(agent) {
3806
+ return `${agent.config.provider}:${agent.config.baseUrl ?? "default"}`;
3807
+ }
3808
+ function recommendConcurrency(agents) {
3809
+ const perEndpoint = /* @__PURE__ */ new Map();
3810
+ for (const a of agents) {
3811
+ perEndpoint.set(endpointKey(a), (perEndpoint.get(endpointKey(a)) ?? 0) + 1);
3812
+ }
3813
+ let total = 0;
3814
+ for (const [key, count] of perEndpoint) {
3815
+ total += key.startsWith("ollama:") ? Math.min(count, OLLAMA_ENDPOINT_CONCURRENCY) : count;
3816
+ }
3817
+ return Math.max(1, total);
3818
+ }
3819
+ function idleTimeoutMs() {
3820
+ const raw = Number(process.env.POLYPUS_SWARM_IDLE_TIMEOUT_MS);
3821
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_IDLE_TIMEOUT_MS;
3822
+ }
3823
+
3591
3824
  // src/ui/swarm-view.ts
3592
3825
  var RESET2 = "\x1B[0m";
3593
3826
  var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
@@ -3751,6 +3984,35 @@ function pad(s, n) {
3751
3984
  return s.length >= n ? s : s + " ".repeat(n - s.length);
3752
3985
  }
3753
3986
 
3987
+ // src/ui/cancel.ts
3988
+ function listenForCancel(controller) {
3989
+ const stdin2 = process.stdin;
3990
+ if (!stdin2.isTTY) return { pause() {
3991
+ }, resume() {
3992
+ }, dispose() {
3993
+ } };
3994
+ const onData = (buf) => {
3995
+ if (buf.length === 1 && (buf[0] === 27 || buf[0] === 3)) controller.abort();
3996
+ };
3997
+ let active = false;
3998
+ const attach = () => {
3999
+ if (active) return;
4000
+ stdin2.setRawMode(true);
4001
+ stdin2.resume();
4002
+ stdin2.on("data", onData);
4003
+ active = true;
4004
+ };
4005
+ const detach = () => {
4006
+ if (!active) return;
4007
+ stdin2.off("data", onData);
4008
+ stdin2.setRawMode(false);
4009
+ stdin2.pause();
4010
+ active = false;
4011
+ };
4012
+ attach();
4013
+ return { pause: detach, resume: attach, dispose: detach };
4014
+ }
4015
+
3754
4016
  // src/cli/commands/swarm.ts
3755
4017
  var MIN_SWARM_AGENTS = 3;
3756
4018
  function canSwarm(agentCount) {
@@ -3774,6 +4036,11 @@ async function runSwarmSession(task, config, opts = {}) {
3774
4036
  pc7.dim(t("swarm.status", { agents: resolved.map((a) => a.config.name).join(", "), workspace }))
3775
4037
  );
3776
4038
  console.log(pc7.yellow(t("swarm.bypassNote") + "\n"));
4039
+ const controller = new AbortController();
4040
+ const cancel2 = listenForCancel(controller);
4041
+ controller.signal.addEventListener("abort", () => console.log(pc7.dim("\n" + t("swarm.cancelling"))), {
4042
+ once: true
4043
+ });
3777
4044
  const view = new SwarmView(resolved[0].config.name);
3778
4045
  view.start();
3779
4046
  let result;
@@ -3785,6 +4052,9 @@ async function runSwarmSession(task, config, opts = {}) {
3785
4052
  allow: config.permissions.allow,
3786
4053
  deny: config.permissions.deny,
3787
4054
  maxSubtasks: opts.maxSubtasks,
4055
+ concurrency: recommendConcurrency(resolved),
4056
+ idleTimeoutMs: idleTimeoutMs(),
4057
+ signal: controller.signal,
3788
4058
  events: {
3789
4059
  onDecomposed: (subtasks) => view.setSubtasks(subtasks),
3790
4060
  onWorkerStart: (subtask, agentName) => view.workerStart(subtask.id, agentName),
@@ -3798,6 +4068,7 @@ async function runSwarmSession(task, config, opts = {}) {
3798
4068
  });
3799
4069
  } finally {
3800
4070
  view.stop();
4071
+ cancel2.dispose();
3801
4072
  }
3802
4073
  console.log("");
3803
4074
  console.log(pc7.bold("\n" + t("swarm.summary")));
@@ -3991,6 +4262,10 @@ async function executeTask(task, resolved, workspace, session, json = false, ver
3991
4262
  return ok;
3992
4263
  }
3993
4264
  });
4265
+ const [extraTools, hooks] = await Promise.all([loadCustomTools(workspace), loadHooks(workspace)]);
4266
+ if (!json && extraTools.length > 0) {
4267
+ console.log(pc8.dim(t("tools.customLoaded", { names: extraTools.map((tl) => tl.spec.name).join(", ") })));
4268
+ }
3994
4269
  const runOnce = (taskText) => runAgent({
3995
4270
  task: taskText,
3996
4271
  workspace,
@@ -4000,6 +4275,8 @@ async function executeTask(task, resolved, workspace, session, json = false, ver
4000
4275
  history: session.history,
4001
4276
  maxSteps: session.maxSteps,
4002
4277
  compactThresholdTokens: compactionThreshold(),
4278
+ extraTools,
4279
+ hooks,
4003
4280
  signal: controller.signal,
4004
4281
  events
4005
4282
  });
@@ -4064,33 +4341,6 @@ async function executeTask(task, resolved, workspace, session, json = false, ver
4064
4341
  function fmtTokens(n) {
4065
4342
  return n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
4066
4343
  }
4067
- function listenForCancel(controller) {
4068
- const stdin2 = process.stdin;
4069
- if (!stdin2.isTTY) return { pause() {
4070
- }, resume() {
4071
- }, dispose() {
4072
- } };
4073
- const onData = (buf) => {
4074
- if (buf.length === 1 && (buf[0] === 27 || buf[0] === 3)) controller.abort();
4075
- };
4076
- let active = false;
4077
- const attach = () => {
4078
- if (active) return;
4079
- stdin2.setRawMode(true);
4080
- stdin2.resume();
4081
- stdin2.on("data", onData);
4082
- active = true;
4083
- };
4084
- const detach = () => {
4085
- if (!active) return;
4086
- stdin2.off("data", onData);
4087
- stdin2.setRawMode(false);
4088
- stdin2.pause();
4089
- active = false;
4090
- };
4091
- attach();
4092
- return { pause: detach, resume: attach, dispose: detach };
4093
- }
4094
4344
  async function confirmAction(req) {
4095
4345
  if (req.kind === "write" && req.hunks && req.hunks.length > 0) {
4096
4346
  renderDiff(req.hunks);
@@ -4204,7 +4454,7 @@ import pc9 from "picocolors";
4204
4454
 
4205
4455
  // src/core/scaffold/init.ts
4206
4456
  import { mkdir as mkdir5, writeFile as writeFile5, access } from "fs/promises";
4207
- import { dirname as dirname3, join as join7 } from "path";
4457
+ import { dirname as dirname3, join as join9 } from "path";
4208
4458
 
4209
4459
  // src/core/scaffold/templates.ts
4210
4460
  function polyTemplates(locale) {
@@ -4447,7 +4697,7 @@ async function scaffoldPoly(workspace, opts) {
4447
4697
  const skipped = [];
4448
4698
  for (const [rel, content] of Object.entries(templates)) {
4449
4699
  const display = `.poly/${rel}`;
4450
- const abs = join7(workspace, ".poly", ...rel.split("/"));
4700
+ const abs = join9(workspace, ".poly", ...rel.split("/"));
4451
4701
  if (!opts.force && await exists(abs)) {
4452
4702
  skipped.push(display);
4453
4703
  continue;
@@ -4587,9 +4837,9 @@ async function sessions() {
4587
4837
  }
4588
4838
 
4589
4839
  // src/cli/commands/prd.ts
4590
- import { writeFile as writeFile6, readFile as readFile12 } from "fs/promises";
4840
+ import { writeFile as writeFile6, readFile as readFile14 } from "fs/promises";
4591
4841
  import { execFile } from "child_process";
4592
- import { promisify as promisify3 } from "util";
4842
+ import { promisify as promisify5 } from "util";
4593
4843
  import pc13 from "picocolors";
4594
4844
 
4595
4845
  // src/core/agent/prd.ts
@@ -4711,7 +4961,7 @@ function stripBom(s) {
4711
4961
  }
4712
4962
 
4713
4963
  // src/cli/commands/prd.ts
4714
- var exec3 = promisify3(execFile);
4964
+ var exec5 = promisify5(execFile);
4715
4965
  async function prd(issueRef, opts) {
4716
4966
  const issue = await loadIssue(issueRef, opts.input);
4717
4967
  const { provider } = resolveFreeProvider(opts.model ?? DEFAULT_PRD_MODEL);
@@ -4726,11 +4976,11 @@ async function prd(issueRef, opts) {
4726
4976
  }
4727
4977
  async function loadIssue(issueRef, input) {
4728
4978
  if (input) {
4729
- const raw = input === "-" ? await readStdin() : await readFile12(input, "utf8");
4979
+ const raw = input === "-" ? await readStdin() : await readFile14(input, "utf8");
4730
4980
  return normalize2(JSON.parse(stripBom(raw)));
4731
4981
  }
4732
4982
  const num = numericRef(issueRef);
4733
- const { stdout: stdout2 } = await exec3("gh", ["issue", "view", num, "--json", "number,title,body,comments"]);
4983
+ const { stdout: stdout2 } = await exec5("gh", ["issue", "view", num, "--json", "number,title,body,comments"]);
4734
4984
  const data = normalize2(JSON.parse(stdout2));
4735
4985
  data.number ??= Number(num);
4736
4986
  return data;
@@ -4745,9 +4995,9 @@ function normalize2(raw) {
4745
4995
  }
4746
4996
 
4747
4997
  // src/cli/commands/review.ts
4748
- import { writeFile as writeFile7, readFile as readFile13 } from "fs/promises";
4998
+ import { writeFile as writeFile7, readFile as readFile15 } from "fs/promises";
4749
4999
  import { execFile as execFile2 } from "child_process";
4750
- import { promisify as promisify4 } from "util";
5000
+ import { promisify as promisify6 } from "util";
4751
5001
  import pc14 from "picocolors";
4752
5002
 
4753
5003
  // src/core/agent/review.ts
@@ -4805,7 +5055,7 @@ ${projectGuide}`
4805
5055
  }
4806
5056
 
4807
5057
  // src/cli/commands/review.ts
4808
- var exec4 = promisify4(execFile2);
5058
+ var exec6 = promisify6(execFile2);
4809
5059
  async function review(prRef, opts) {
4810
5060
  const num = opts.input ? prRef.replace(/^#/, "") : numericRef(prRef);
4811
5061
  const diff = await loadDiff(num, opts.input);
@@ -4821,19 +5071,19 @@ async function review(prRef, opts) {
4821
5071
  }
4822
5072
  }
4823
5073
  async function loadDiff(num, input) {
4824
- if (input) return input === "-" ? readStdin() : readFile13(input, "utf8");
4825
- const { stdout: stdout2 } = await exec4("gh", ["pr", "diff", num]);
5074
+ if (input) return input === "-" ? readStdin() : readFile15(input, "utf8");
5075
+ const { stdout: stdout2 } = await exec6("gh", ["pr", "diff", num]);
4826
5076
  return stdout2;
4827
5077
  }
4828
5078
  async function loadMeta(num, input) {
4829
5079
  if (input) return { number: Number(num) || void 0, title: `PR ${num}`, body: "" };
4830
- const { stdout: stdout2 } = await exec4("gh", ["pr", "view", num, "--json", "number,title,body"]);
5080
+ const { stdout: stdout2 } = await exec6("gh", ["pr", "view", num, "--json", "number,title,body"]);
4831
5081
  const raw = JSON.parse(stdout2);
4832
5082
  return { number: raw.number, title: raw.title ?? "", body: raw.body ?? "" };
4833
5083
  }
4834
5084
 
4835
5085
  // src/cli/index.ts
4836
- import { join as join8 } from "path";
5086
+ import { join as join10 } from "path";
4837
5087
 
4838
5088
  // src/core/config/dotenv.ts
4839
5089
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
@@ -4904,7 +5154,7 @@ function buildProgram() {
4904
5154
  }
4905
5155
  async function main() {
4906
5156
  try {
4907
- loadDotenv([join8(configDir(), ".env"), join8(process.cwd(), ".env")]);
5157
+ loadDotenv([join10(configDir(), ".env"), join10(process.cwd(), ".env")]);
4908
5158
  await resolveLocale();
4909
5159
  await buildProgram().parseAsync(process.argv);
4910
5160
  } catch (err) {