@gaberrb/polypus 0.4.11 → 0.4.13

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
@@ -205,6 +205,7 @@ var en = {
205
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.",
206
206
  "swarm.status": "swarm agents=[{agents}] workspace={workspace}",
207
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",
208
209
  "swarm.decomposed": "Decomposed into {n} subtask(s):",
209
210
  "swarm.workerStart": "\u25B6 {id} started by {agent}",
210
211
  "swarm.workerDone": "\u2713 {id} done",
@@ -464,6 +465,7 @@ var ptBR = {
464
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.",
465
466
  "swarm.status": "swarm agentes=[{agents}] workspace={workspace}",
466
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",
467
469
  "swarm.decomposed": "Dividido em {n} subtarefa(s):",
468
470
  "swarm.workerStart": "\u25B6 {id} iniciada por {agent}",
469
471
  "swarm.workerDone": "\u2713 {id} conclu\xEDda",
@@ -908,15 +910,33 @@ var OpenAICompatibleProvider = class {
908
910
  parameters: t2.parameters
909
911
  }
910
912
  }));
913
+ const base = {
914
+ model: this.model,
915
+ messages,
916
+ ...tools && tools.length > 0 ? { tools } : {},
917
+ temperature: req.params?.temperature,
918
+ // Generous default so large files aren't truncated mid tool-call.
919
+ max_tokens: req.params?.maxTokens ?? 8192
920
+ };
921
+ if (req.onDelta) {
922
+ const stream = await this.client.chat.completions.create(
923
+ { ...base, stream: true, stream_options: { include_usage: true } },
924
+ { signal: req.signal }
925
+ );
926
+ const agg = await aggregateStream(stream, req.onDelta);
927
+ return {
928
+ content: agg.content,
929
+ toolCalls: agg.toolCalls.map((tc, i) => ({
930
+ id: tc.id || `call_${i}`,
931
+ name: tc.name,
932
+ arguments: safeParseArgs(tc.arguments)
933
+ })),
934
+ finishReason: agg.finishReason || "stop",
935
+ usage: agg.usage
936
+ };
937
+ }
911
938
  const completion = await this.client.chat.completions.create(
912
- {
913
- model: this.model,
914
- messages,
915
- ...tools && tools.length > 0 ? { tools } : {},
916
- temperature: req.params?.temperature,
917
- // Generous default so large files aren't truncated mid tool-call.
918
- max_tokens: req.params?.maxTokens ?? 8192
919
- },
939
+ { ...base },
920
940
  { signal: req.signal }
921
941
  );
922
942
  const choice = completion.choices[0];
@@ -937,6 +957,34 @@ var OpenAICompatibleProvider = class {
937
957
  };
938
958
  }
939
959
  };
960
+ async function aggregateStream(stream, onDelta) {
961
+ let content = "";
962
+ let finishReason = "";
963
+ let usage2;
964
+ const toolAcc = [];
965
+ for await (const chunk of stream) {
966
+ const choice = chunk.choices?.[0];
967
+ const delta = choice?.delta;
968
+ if (delta?.content) {
969
+ content += delta.content;
970
+ onDelta?.(delta.content);
971
+ }
972
+ for (const tc of delta?.tool_calls ?? []) {
973
+ const slot = toolAcc[tc.index] ??= { id: "", name: "", arguments: "" };
974
+ if (tc.id) slot.id = tc.id;
975
+ if (tc.function?.name) slot.name = tc.function.name;
976
+ if (tc.function?.arguments) slot.arguments += tc.function.arguments;
977
+ }
978
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
979
+ if (chunk.usage) {
980
+ usage2 = {
981
+ promptTokens: chunk.usage.prompt_tokens,
982
+ completionTokens: chunk.usage.completion_tokens
983
+ };
984
+ }
985
+ }
986
+ return { content, finishReason, usage: usage2, toolCalls: toolAcc.filter(Boolean) };
987
+ }
940
988
  function toOpenAIMessage(m) {
941
989
  switch (m.role) {
942
990
  case "tool":
@@ -2282,13 +2330,15 @@ async function runAgent(opts) {
2282
2330
  }
2283
2331
  }
2284
2332
  }
2333
+ const wantDelta = driver.kind === "native" && typeof events?.onAssistantDelta === "function";
2285
2334
  let response;
2286
2335
  try {
2287
2336
  response = await agent.provider.chat({
2288
2337
  messages,
2289
2338
  tools: driver.providerTools(),
2290
2339
  params: opts.params,
2291
- signal: opts.signal
2340
+ signal: opts.signal,
2341
+ onDelta: wantDelta ? (chunk) => events.onAssistantDelta(chunk) : void 0
2292
2342
  });
2293
2343
  } catch (err) {
2294
2344
  if (opts.signal?.aborted) return { finished: false, reason: "cancelled", steps: step, messages, usage: usage2 };
@@ -3632,7 +3682,7 @@ async function removeWorktree(git, wt) {
3632
3682
  }
3633
3683
 
3634
3684
  // src/core/agent/worker.ts
3635
- async function runWorker(subtask, agent, wt, allow, deny, events) {
3685
+ async function runWorker(subtask, agent, wt, allow, deny, events, signal) {
3636
3686
  const permissions = new PermissionEngine({
3637
3687
  mode: "bypass",
3638
3688
  policy: { workspace: wt.path, allow, deny },
@@ -3644,9 +3694,10 @@ async function runWorker(subtask, agent, wt, allow, deny, events) {
3644
3694
  agent,
3645
3695
  permissions,
3646
3696
  promptContext: { workspace: wt.path, mode: "bypass", allow, briefing: subtask.brief },
3647
- events
3697
+ events,
3698
+ signal
3648
3699
  });
3649
- const committed = await commitWorktree(wt, `polypus(${subtask.id}): ${subtask.title}`);
3700
+ const committed = result.reason === "cancelled" ? false : await commitWorktree(wt, `polypus(${subtask.id}): ${subtask.title}`);
3650
3701
  return {
3651
3702
  subtask,
3652
3703
  agentName: agent.config.name,
@@ -3663,30 +3714,23 @@ async function runSwarm(opts) {
3663
3714
  const lead = opts.agents[0];
3664
3715
  if (!lead) throw new Error("Swarm requires at least one agent.");
3665
3716
  const maxSubtasks = opts.maxSubtasks ?? Math.max(opts.agents.length, 2);
3717
+ const concurrency = Math.max(1, opts.concurrency ?? opts.agents.length);
3718
+ const idleTimeoutMs2 = opts.idleTimeoutMs ?? 0;
3666
3719
  const git = await ensureRepo(opts.workspace);
3667
- const subtasks = await decompose(lead, opts.task, maxSubtasks);
3720
+ const subtasks = await decompose(lead, opts.task, maxSubtasks, opts.signal);
3668
3721
  opts.events?.onDecomposed?.(subtasks);
3669
3722
  const worktrees = [];
3670
3723
  for (const subtask of subtasks) {
3671
3724
  worktrees.push(await createWorktree(git, subtask.id));
3672
3725
  }
3673
- const outcomes = await Promise.all(
3674
- subtasks.map(async (subtask, i) => {
3675
- const agent = opts.agents[i % opts.agents.length];
3676
- const wt = worktrees[i];
3677
- opts.events?.onWorkerStart?.(subtask, agent.config.name);
3678
- const outcome = await runWorker(
3679
- subtask,
3680
- agent,
3681
- wt,
3682
- opts.allow,
3683
- opts.deny,
3684
- opts.events?.workerEvents?.(subtask)
3685
- );
3686
- opts.events?.onWorkerDone?.(outcome);
3687
- return outcome;
3688
- })
3689
- );
3726
+ const runOne = (subtask, i) => guardedWorker(subtask, opts.agents[i % opts.agents.length], worktrees[i], {
3727
+ allow: opts.allow,
3728
+ deny: opts.deny,
3729
+ idleTimeoutMs: idleTimeoutMs2,
3730
+ signal: opts.signal,
3731
+ events: opts.events
3732
+ });
3733
+ const outcomes = await runPool(subtasks, concurrency, runOne);
3690
3734
  const merges = [];
3691
3735
  for (const outcome of outcomes) {
3692
3736
  if (!outcome.committed) continue;
@@ -3704,13 +3748,69 @@ async function runSwarm(opts) {
3704
3748
  }
3705
3749
  return { subtasks, outcomes, merges };
3706
3750
  }
3751
+ async function guardedWorker(subtask, agent, wt, opts) {
3752
+ const ac = new AbortController();
3753
+ const onAbort = () => ac.abort();
3754
+ if (opts.signal) {
3755
+ if (opts.signal.aborted) ac.abort();
3756
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
3757
+ }
3758
+ let idleTimer;
3759
+ const resetIdle = () => {
3760
+ if (opts.idleTimeoutMs <= 0) return;
3761
+ if (idleTimer) clearTimeout(idleTimer);
3762
+ idleTimer = setTimeout(() => ac.abort(), opts.idleTimeoutMs);
3763
+ idleTimer.unref?.();
3764
+ };
3765
+ const base = opts.events?.workerEvents?.(subtask);
3766
+ const events = {
3767
+ ...base,
3768
+ onStep: (n) => {
3769
+ resetIdle();
3770
+ base?.onStep?.(n);
3771
+ }
3772
+ };
3773
+ opts.events?.onWorkerStart?.(subtask, agent.config.name);
3774
+ resetIdle();
3775
+ let outcome;
3776
+ try {
3777
+ outcome = await runWorker(subtask, agent, wt, opts.allow, opts.deny, events, ac.signal);
3778
+ } catch {
3779
+ outcome = {
3780
+ subtask,
3781
+ agentName: agent.config.name,
3782
+ branch: wt.branch,
3783
+ finished: false,
3784
+ committed: false,
3785
+ steps: 0
3786
+ };
3787
+ } finally {
3788
+ if (idleTimer) clearTimeout(idleTimer);
3789
+ opts.signal?.removeEventListener("abort", onAbort);
3790
+ }
3791
+ opts.events?.onWorkerDone?.(outcome);
3792
+ return outcome;
3793
+ }
3794
+ async function runPool(items, limit, fn) {
3795
+ const results = new Array(items.length);
3796
+ let next = 0;
3797
+ const worker = async () => {
3798
+ for (; ; ) {
3799
+ const i = next++;
3800
+ if (i >= items.length) return;
3801
+ results[i] = await fn(items[i], i);
3802
+ }
3803
+ };
3804
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
3805
+ return results;
3806
+ }
3707
3807
  var DECOMPOSE_SYSTEM = [
3708
3808
  "You are a tech lead splitting a coding task into independent subtasks that can be done in parallel.",
3709
3809
  'Return ONLY a JSON array. Each item: {"title": string, "brief": string}.',
3710
3810
  "Make subtasks touch DIFFERENT files/areas to minimize merge conflicts.",
3711
3811
  "Keep the list small (prefer 2-4 items). Each brief must be self-contained and actionable."
3712
3812
  ].join("\n");
3713
- async function decompose(lead, task, maxSubtasks) {
3813
+ async function decompose(lead, task, maxSubtasks, signal) {
3714
3814
  try {
3715
3815
  const res = await lead.provider.chat({
3716
3816
  messages: [
@@ -3720,7 +3820,8 @@ ${task}
3720
3820
 
3721
3821
  Return at most ${maxSubtasks} subtasks as a JSON array.` }
3722
3822
  ],
3723
- params: { temperature: 0 }
3823
+ params: { temperature: 0 },
3824
+ signal
3724
3825
  });
3725
3826
  const parsed = extractJsonArray(res.content);
3726
3827
  if (parsed && parsed.length > 0) {
@@ -3746,6 +3847,28 @@ function extractJsonArray(text2) {
3746
3847
  }
3747
3848
  }
3748
3849
 
3850
+ // src/core/agent/concurrency.ts
3851
+ var OLLAMA_ENDPOINT_CONCURRENCY = 2;
3852
+ var DEFAULT_IDLE_TIMEOUT_MS = 3e5;
3853
+ function endpointKey(agent) {
3854
+ return `${agent.config.provider}:${agent.config.baseUrl ?? "default"}`;
3855
+ }
3856
+ function recommendConcurrency(agents) {
3857
+ const perEndpoint = /* @__PURE__ */ new Map();
3858
+ for (const a of agents) {
3859
+ perEndpoint.set(endpointKey(a), (perEndpoint.get(endpointKey(a)) ?? 0) + 1);
3860
+ }
3861
+ let total = 0;
3862
+ for (const [key, count] of perEndpoint) {
3863
+ total += key.startsWith("ollama:") ? Math.min(count, OLLAMA_ENDPOINT_CONCURRENCY) : count;
3864
+ }
3865
+ return Math.max(1, total);
3866
+ }
3867
+ function idleTimeoutMs() {
3868
+ const raw = Number(process.env.POLYPUS_SWARM_IDLE_TIMEOUT_MS);
3869
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_IDLE_TIMEOUT_MS;
3870
+ }
3871
+
3749
3872
  // src/ui/swarm-view.ts
3750
3873
  var RESET2 = "\x1B[0m";
3751
3874
  var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
@@ -3909,6 +4032,35 @@ function pad(s, n) {
3909
4032
  return s.length >= n ? s : s + " ".repeat(n - s.length);
3910
4033
  }
3911
4034
 
4035
+ // src/ui/cancel.ts
4036
+ function listenForCancel(controller) {
4037
+ const stdin2 = process.stdin;
4038
+ if (!stdin2.isTTY) return { pause() {
4039
+ }, resume() {
4040
+ }, dispose() {
4041
+ } };
4042
+ const onData = (buf) => {
4043
+ if (buf.length === 1 && (buf[0] === 27 || buf[0] === 3)) controller.abort();
4044
+ };
4045
+ let active = false;
4046
+ const attach = () => {
4047
+ if (active) return;
4048
+ stdin2.setRawMode(true);
4049
+ stdin2.resume();
4050
+ stdin2.on("data", onData);
4051
+ active = true;
4052
+ };
4053
+ const detach = () => {
4054
+ if (!active) return;
4055
+ stdin2.off("data", onData);
4056
+ stdin2.setRawMode(false);
4057
+ stdin2.pause();
4058
+ active = false;
4059
+ };
4060
+ attach();
4061
+ return { pause: detach, resume: attach, dispose: detach };
4062
+ }
4063
+
3912
4064
  // src/cli/commands/swarm.ts
3913
4065
  var MIN_SWARM_AGENTS = 3;
3914
4066
  function canSwarm(agentCount) {
@@ -3932,6 +4084,11 @@ async function runSwarmSession(task, config, opts = {}) {
3932
4084
  pc7.dim(t("swarm.status", { agents: resolved.map((a) => a.config.name).join(", "), workspace }))
3933
4085
  );
3934
4086
  console.log(pc7.yellow(t("swarm.bypassNote") + "\n"));
4087
+ const controller = new AbortController();
4088
+ const cancel2 = listenForCancel(controller);
4089
+ controller.signal.addEventListener("abort", () => console.log(pc7.dim("\n" + t("swarm.cancelling"))), {
4090
+ once: true
4091
+ });
3935
4092
  const view = new SwarmView(resolved[0].config.name);
3936
4093
  view.start();
3937
4094
  let result;
@@ -3943,6 +4100,9 @@ async function runSwarmSession(task, config, opts = {}) {
3943
4100
  allow: config.permissions.allow,
3944
4101
  deny: config.permissions.deny,
3945
4102
  maxSubtasks: opts.maxSubtasks,
4103
+ concurrency: recommendConcurrency(resolved),
4104
+ idleTimeoutMs: idleTimeoutMs(),
4105
+ signal: controller.signal,
3946
4106
  events: {
3947
4107
  onDecomposed: (subtasks) => view.setSubtasks(subtasks),
3948
4108
  onWorkerStart: (subtask, agentName) => view.workerStart(subtask.id, agentName),
@@ -3956,6 +4116,7 @@ async function runSwarmSession(task, config, opts = {}) {
3956
4116
  });
3957
4117
  } finally {
3958
4118
  view.stop();
4119
+ cancel2.dispose();
3959
4120
  }
3960
4121
  console.log("");
3961
4122
  console.log(pc7.bold("\n" + t("swarm.summary")));
@@ -4228,33 +4389,6 @@ async function executeTask(task, resolved, workspace, session, json = false, ver
4228
4389
  function fmtTokens(n) {
4229
4390
  return n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
4230
4391
  }
4231
- function listenForCancel(controller) {
4232
- const stdin2 = process.stdin;
4233
- if (!stdin2.isTTY) return { pause() {
4234
- }, resume() {
4235
- }, dispose() {
4236
- } };
4237
- const onData = (buf) => {
4238
- if (buf.length === 1 && (buf[0] === 27 || buf[0] === 3)) controller.abort();
4239
- };
4240
- let active = false;
4241
- const attach = () => {
4242
- if (active) return;
4243
- stdin2.setRawMode(true);
4244
- stdin2.resume();
4245
- stdin2.on("data", onData);
4246
- active = true;
4247
- };
4248
- const detach = () => {
4249
- if (!active) return;
4250
- stdin2.off("data", onData);
4251
- stdin2.setRawMode(false);
4252
- stdin2.pause();
4253
- active = false;
4254
- };
4255
- attach();
4256
- return { pause: detach, resume: attach, dispose: detach };
4257
- }
4258
4392
  async function confirmAction(req) {
4259
4393
  if (req.kind === "write" && req.hunks && req.hunks.length > 0) {
4260
4394
  renderDiff(req.hunks);
@@ -4320,16 +4454,27 @@ function renderDiff(hunks) {
4320
4454
  }
4321
4455
  }
4322
4456
  function renderEvents(spinner3) {
4457
+ let streamed = false;
4323
4458
  return {
4324
4459
  onStep() {
4460
+ streamed = false;
4325
4461
  spinner3.start(t("ui.thinking"));
4326
4462
  },
4327
4463
  onUsage(usage2) {
4328
4464
  const total = usage2.promptTokens + usage2.completionTokens;
4329
4465
  if (total > 0) spinner3.setSuffix(t("ui.tokensShort", { total: fmtTokens(total) }));
4330
4466
  },
4467
+ onAssistantDelta(chunk) {
4468
+ spinner3.stop();
4469
+ process.stdout.write(pc8.cyan(chunk));
4470
+ streamed = true;
4471
+ },
4331
4472
  onAssistantText(text2) {
4332
4473
  spinner3.stop();
4474
+ if (streamed) {
4475
+ process.stdout.write("\n");
4476
+ return;
4477
+ }
4333
4478
  if (text2.trim()) console.log(pc8.cyan(text2.trim()));
4334
4479
  },
4335
4480
  onToolCall(call) {