kritya 0.8.29-beta → 0.8.30-beta

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.
@@ -28,6 +28,19 @@ export class Agent {
28
28
  contextWindow = 120_000;
29
29
  /** Max model round-trips per request before stopping to ask the user. */
30
30
  maxSteps = DEFAULT_MAX_STEPS;
31
+ /**
32
+ * 1-based index of the step the loop is currently on, 0 before the first
33
+ * model call. Read by the subagent runner to report "step 4/15" as it works.
34
+ */
35
+ stepIndex = 0;
36
+ /**
37
+ * True when the last run stopped because it reached `maxSteps` rather than
38
+ * finishing. That is the "wants to keep going" case, and it is the only
39
+ * signal that distinguishes it — the loop just ends either way, and the
40
+ * message it appends afterwards is indistinguishable from model output to
41
+ * anything that doesn't parse it.
42
+ */
43
+ hitStepLimit = false;
31
44
  /**
32
45
  * How long one tool call may run before it's abandoned (config
33
46
  * `toolTimeoutSeconds`). A tool with its own deadline opts out via
@@ -446,7 +459,13 @@ export class Agent {
446
459
  }
447
460
  }
448
461
  async runLoop(systemMsg, handlers, signal) {
462
+ // Reset per run, not per Agent: the main agent serves many turns in one
463
+ // process, so a previous turn that hit the cap must not leave the flag set
464
+ // for the next one.
465
+ this.stepIndex = 0;
466
+ this.hitStepLimit = false;
449
467
  for (let i = 0; i < this.maxSteps; i++) {
468
+ this.stepIndex = i + 1;
450
469
  // Checked ahead of the generic abort so a kill mid-turn is reported as
451
470
  // a kill rather than an ordinary cancellation.
452
471
  this.kill.assertLive();
@@ -519,6 +538,7 @@ export class Agent {
519
538
  await this.tryCompact("Auto-compacted context", handlers, signal);
520
539
  }
521
540
  }
541
+ this.hitStepLimit = true;
522
542
  handlers.onAssistantText(`[Stopped after ${this.maxSteps} steps — the safety limit for one request. ` +
523
543
  `Send "continue" to keep going, or raise "maxSteps" in ~/.kritya/config.json.]`);
524
544
  }
@@ -0,0 +1,354 @@
1
+ import { Agent } from "./loop.js";
2
+ import { commitWorktree, createWorktree, isGitRepo, removeWorktree, worktreeDiffStat, } from "./worktree.js";
3
+ import { PermissionManager } from "../permissions/permissions.js";
4
+ import { SessionStore } from "../session/store.js";
5
+ /**
6
+ * Batches are capped at six tasks by spawn_agent's own schema and at four by
7
+ * spawn_write_agent's, so allowing more concurrency than the larger of the two
8
+ * buys nothing — the extra slots can never be filled.
9
+ */
10
+ export const MAX_SUBAGENT_CONCURRENCY = 6;
11
+ export const DEFAULT_SUBAGENT_CONCURRENCY = 3;
12
+ export const DEFAULT_SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000;
13
+ /** Read-only agents get half the budget of write agents: they search, they don't build. */
14
+ export const DEFAULT_READ_SUBAGENT_STEPS = 15;
15
+ export const DEFAULT_WRITE_SUBAGENT_STEPS = 30;
16
+ /** Longest headline kept for the one-line progress report. */
17
+ const HEADLINE_MAX = 72;
18
+ /** A finite value floors to itself, bounded to 1..MAX_SUBAGENT_CONCURRENCY; anything else takes the default. */
19
+ export function clampConcurrency(value) {
20
+ const wanted = typeof value === "number" && Number.isFinite(value)
21
+ ? Math.floor(value)
22
+ : DEFAULT_SUBAGENT_CONCURRENCY;
23
+ return Math.min(Math.max(wanted, 1), MAX_SUBAGENT_CONCURRENCY);
24
+ }
25
+ /**
26
+ * Why an aborted subagent stopped, or undefined if it wasn't aborted.
27
+ *
28
+ * The raw abort message is identical for both cases ("this operation was
29
+ * aborted"), so without this a ten-minute wall-clock cap reads exactly like the
30
+ * user pressing Ctrl-C.
31
+ */
32
+ export function abortOutcome(opts) {
33
+ if (opts.timedOut)
34
+ return { error: "timed out before finishing", stoppedEarly: "timeout" };
35
+ if (opts.cancelled)
36
+ return { error: "cancelled", stoppedEarly: "cancelled" };
37
+ return undefined;
38
+ }
39
+ /** Joins the non-empty reasons a subagent's work could not be used, or undefined if there are none. */
40
+ export function mergeErrors(...messages) {
41
+ const kept = messages.filter((m) => typeof m === "string" && m.trim() !== "");
42
+ return kept.length > 0 ? kept.join(" ") : undefined;
43
+ }
44
+ /** One collapsed line from a finished result, for the progress line. */
45
+ export function headline(result) {
46
+ const text = (result.error ?? result.summary).replace(/\s+/g, " ").trim();
47
+ if (text === "")
48
+ return "no findings";
49
+ return text.length > HEADLINE_MAX ? `${text.slice(0, HEADLINE_MAX - 1)}…` : text;
50
+ }
51
+ /**
52
+ * The bracketed note the spawn_* tools append to a result, or undefined for a
53
+ * clean run.
54
+ *
55
+ * Shared by both tools so a failure reads the same either way, and so the
56
+ * single-task path can't lose it: that path returns the summary bare, which
57
+ * used to mean a lone failing subagent came back as an empty string with the
58
+ * reason nowhere in sight.
59
+ */
60
+ export function subagentStatusNote(result) {
61
+ const notes = [];
62
+ if (result.error)
63
+ notes.push(`[error: ${result.error}]`);
64
+ if (result.stoppedEarly === "max-steps") {
65
+ notes.push("[stopped early: it hit its step limit, so the findings above are incomplete — " +
66
+ "re-dispatch a narrower task, or raise subagentMaxSteps]");
67
+ }
68
+ else if (result.stoppedEarly) {
69
+ notes.push(`[stopped early: ${result.stoppedEarly}]`);
70
+ }
71
+ return notes.length > 0 ? notes.join("\n") : undefined;
72
+ }
73
+ /**
74
+ * Handlers for a subagent's own agent loop: no streaming to a UI, but two
75
+ * things this file has to get right.
76
+ *
77
+ * Assistant text is accumulated rather than replaced. The loop emits one
78
+ * message per step, so the old `onAssistantText: (t) => (finalText = t)` kept
79
+ * only the last one — a subagent that reported findings and then carried on
80
+ * working silently lost them, and the summary came back as whatever it said
81
+ * last.
82
+ *
83
+ * And progress is reported from onToolStart rather than onTextDelta: deltas
84
+ * arrive per token, which would churn the parent's status line for no gain,
85
+ * while tool starts are the natural "here's what it's doing now" beat.
86
+ */
87
+ export function createSubagentHandlers(opts) {
88
+ const parts = [];
89
+ return {
90
+ onTextDelta: () => { },
91
+ onReasoningDelta: () => { },
92
+ onAssistantText: (text) => {
93
+ const trimmed = text.trim();
94
+ if (trimmed === "")
95
+ return;
96
+ parts.push(trimmed);
97
+ opts.onFinalText(parts.join("\n\n"));
98
+ },
99
+ onToolStart: (_id, name, summary) => {
100
+ if (!opts.onStep)
101
+ return;
102
+ const label = summary.trim() || name;
103
+ const info = opts.stepInfo?.();
104
+ opts.onStep(info && info.max > 0 ? `step ${info.step}/${info.max} · ${label}` : label);
105
+ },
106
+ onToolEnd: () => { },
107
+ requestPermission: opts.requestPermission,
108
+ onUsage: () => { },
109
+ };
110
+ }
111
+ /**
112
+ * Builds the `spawnAgents` implementation the spawn_agent / spawn_write_agent
113
+ * tools call through `ToolContext`.
114
+ *
115
+ * Lives here rather than inline in the CLI entry point so the headless and
116
+ * Electron entry points can wire the same runner instead of going without
117
+ * subagents entirely, and so the logic is reachable from tests — `dist/index.js`
118
+ * is excluded from coverage, which is how the swallowed-failure and
119
+ * timeout-looks-like-cancel bugs survived in it.
120
+ */
121
+ export function createSubagentRunner(deps) {
122
+ const concurrency = clampConcurrency(deps.concurrency);
123
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS;
124
+ const readMaxSteps = deps.readMaxSteps ?? DEFAULT_READ_SUBAGENT_STEPS;
125
+ const writeMaxSteps = deps.writeMaxSteps ?? DEFAULT_WRITE_SUBAGENT_STEPS;
126
+ /** Fields every subagent Agent shares, so the two runners can't drift apart. */
127
+ function baseAgent(tools, maxSteps, task, spanAttributes) {
128
+ const sub = new Agent(deps.client, deps.model, tools, {
129
+ workspace: deps.workspace,
130
+ sandboxMode: deps.sandboxMode,
131
+ trustWorkspace: deps.trustWorkspace,
132
+ }, new PermissionManager([], deps.workspace), new SessionStore(deps.workspace, true), []);
133
+ sub.maxSteps = maxSteps;
134
+ sub.audit = deps.audit;
135
+ // Both have non-optional NOOP defaults, so only overwrite when supplied.
136
+ if (deps.tracer)
137
+ sub.tracer = deps.tracer;
138
+ if (deps.meter)
139
+ sub.meter = deps.meter;
140
+ sub.kill = deps.kill();
141
+ sub.spanParent = deps.spanParent?.();
142
+ sub.spanAttributes = { ...spanAttributes, "kritya.subagent_task": task.slice(0, 120) };
143
+ return sub;
144
+ }
145
+ async function runReadOnlyAgent(task, signal, onProgress) {
146
+ const sub = baseAgent(deps.readOnlyTools, readMaxSteps, task, { "kritya.subagent": true });
147
+ let finalText = "";
148
+ await sub.runTurn(task, createSubagentHandlers({
149
+ onFinalText: (t) => (finalText = t),
150
+ // read-only tools never require permission, so this is never invoked
151
+ requestPermission: async () => "no",
152
+ onStep: onProgress,
153
+ stepInfo: () => ({ step: sub.stepIndex, max: sub.maxSteps }),
154
+ }), signal);
155
+ return {
156
+ task,
157
+ write: false,
158
+ summary: finalText.trim() || "(subagent returned no findings)",
159
+ // Hitting the cap is the "wants to keep going" case: the findings are
160
+ // real but incomplete, unlike a timeout, which is infrastructure.
161
+ ...(sub.hitStepLimit ? { stoppedEarly: "max-steps" } : {}),
162
+ };
163
+ }
164
+ async function runWriteAgent(task, signal, onProgress) {
165
+ if (!isGitRepo(deps.workspace)) {
166
+ return {
167
+ task,
168
+ write: true,
169
+ summary: "",
170
+ error: "the workspace is not a git repository, so an isolated worktree could not be created",
171
+ };
172
+ }
173
+ const wt = createWorktree(deps.workspace);
174
+ if (!wt) {
175
+ deps.audit?.logTool({
176
+ tool: "subagent_worktree",
177
+ summary: `worktree creation failed for task: ${task.slice(0, 72)}`,
178
+ outcome: "error",
179
+ });
180
+ return { task, write: true, summary: "", error: "failed to create an isolated git worktree" };
181
+ }
182
+ deps.audit?.logTool({
183
+ tool: "subagent_worktree",
184
+ summary: `created branch "${wt.branch}" for task: ${task.slice(0, 72)}`,
185
+ outcome: "ok",
186
+ });
187
+ let finalText = "";
188
+ // A subagent that died mid-run must not come back looking like a success:
189
+ // the tool renders `[error: …]` only when this is set, so without it a
190
+ // crash was reported as an ordinary summary that merely happened to begin
191
+ // with "(subagent stopped: …)" — and its partial edits were still committed.
192
+ let runError;
193
+ let stoppedEarly;
194
+ try {
195
+ // Auto-allow ordinary writes/edits/shell (no human is watching this run),
196
+ // but a destructive command still forces `warning` via classifyDanger in
197
+ // the agent loop regardless of the allowlist — fail-safe deny it there,
198
+ // since there's no one to confirm it and letting it run unattended would
199
+ // be unsafe even inside an isolated worktree (it still has real shell/
200
+ // network access).
201
+ const sub = new Agent(deps.client, deps.model, deps.writeTools, {
202
+ workspace: wt.dir,
203
+ sandboxMode: deps.sandboxMode,
204
+ trustWorkspace: deps.trustWorkspace,
205
+ }, new PermissionManager({ allow: ["write_file", "edit_file", "shell(*)"], deny: [] }, wt.dir), new SessionStore(wt.dir, true), []);
206
+ sub.maxSteps = writeMaxSteps;
207
+ // No human is watching this run (see the auto-allow comment above) —
208
+ // the forced unsandboxed-fallback warning must resolve on its own via
209
+ // the handler below rather than be raised at all.
210
+ sub.interactive = false;
211
+ sub.audit = deps.audit;
212
+ if (deps.tracer)
213
+ sub.tracer = deps.tracer;
214
+ if (deps.meter)
215
+ sub.meter = deps.meter;
216
+ sub.kill = deps.kill(); // see runReadOnlyAgent
217
+ sub.spanParent = deps.spanParent?.();
218
+ sub.spanAttributes = {
219
+ "kritya.subagent": true,
220
+ "kritya.subagent_write": true,
221
+ "kritya.subagent_task": task.slice(0, 120),
222
+ "kritya.subagent_branch": wt.branch,
223
+ };
224
+ await sub.runTurn(task, createSubagentHandlers({
225
+ onFinalText: (t) => (finalText = t),
226
+ requestPermission: async (_name, _summary, _diff, warning) => (warning ? "no" : "yes"),
227
+ onStep: onProgress,
228
+ stepInfo: () => ({ step: sub.stepIndex, max: sub.maxSteps }),
229
+ }), signal);
230
+ if (sub.hitStepLimit)
231
+ stoppedEarly = "max-steps";
232
+ }
233
+ catch (err) {
234
+ const message = err instanceof Error ? err.message : String(err);
235
+ runError = `the subagent stopped before finishing: ${message}`;
236
+ if (!finalText)
237
+ finalText = `(subagent stopped: ${message})`;
238
+ }
239
+ const commitState = commitWorktree(wt, `kritya subagent: ${task.slice(0, 72)}`);
240
+ deps.audit?.logTool({
241
+ tool: "subagent_worktree",
242
+ summary: `branch "${wt.branch}": commit ${commitState}`,
243
+ outcome: commitState === "failed" ? "error" : "ok",
244
+ });
245
+ if (commitState === "clean") {
246
+ const cleaned = removeWorktree(deps.workspace, wt, true);
247
+ const summary = finalText.trim() || "(no changes made)";
248
+ // Surface this rather than silently leave an empty orphaned branch: a
249
+ // failed `git branch -D` (e.g. a transient ref lock) shouldn't look
250
+ // identical to a subagent that genuinely made no changes.
251
+ return {
252
+ task,
253
+ write: true,
254
+ summary,
255
+ error: mergeErrors(runError, cleaned
256
+ ? undefined
257
+ : `made no changes, but its empty scratch branch "${wt.branch}" could not be ` +
258
+ `auto-deleted (a transient git lock) — safe to remove manually with ` +
259
+ `\`git branch -D ${wt.branch}\``),
260
+ ...(stoppedEarly ? { stoppedEarly } : {}),
261
+ };
262
+ }
263
+ if (commitState === "failed") {
264
+ // Don't discard the worktree: the subagent's edits are real work, even
265
+ // if a commit hook rejected them. Leave it on disk for manual recovery.
266
+ return {
267
+ task,
268
+ write: true,
269
+ summary: finalText.trim(),
270
+ error: mergeErrors(runError, "changes could not be committed (a commit hook may have rejected them) — " +
271
+ `left uncommitted at ${wt.dir}`),
272
+ ...(stoppedEarly ? { stoppedEarly } : {}),
273
+ };
274
+ }
275
+ const diffstat = worktreeDiffStat(deps.workspace, wt);
276
+ removeWorktree(deps.workspace, wt, false);
277
+ return {
278
+ task,
279
+ write: true,
280
+ branch: wt.branch,
281
+ summary: `${finalText.trim() || "(no summary)"}${diffstat ? `\n\n${diffstat}` : ""}`,
282
+ error: runError,
283
+ ...(stoppedEarly ? { stoppedEarly } : {}),
284
+ };
285
+ }
286
+ async function runOneAgent(spec, parentSignal, onProgress) {
287
+ const controller = new AbortController();
288
+ const onParentAbort = () => controller.abort();
289
+ parentSignal?.addEventListener("abort", onParentAbort);
290
+ let timedOut = false;
291
+ const timer = setTimeout(() => {
292
+ timedOut = true;
293
+ controller.abort();
294
+ }, timeoutMs);
295
+ try {
296
+ const result = spec.write
297
+ ? await runWriteAgent(spec.task, controller.signal, onProgress)
298
+ : await runReadOnlyAgent(spec.task, controller.signal, onProgress);
299
+ if (result.error === undefined && result.stoppedEarly === undefined)
300
+ return result;
301
+ const aborted = abortOutcome({ timedOut, cancelled: parentSignal?.aborted === true });
302
+ // A step-cap stop is the agent asking for more room, not an abort, so
303
+ // only overwrite when this run was actually cut short.
304
+ if (aborted && (timedOut || parentSignal?.aborted === true))
305
+ return { ...result, ...aborted };
306
+ return result;
307
+ }
308
+ catch (err) {
309
+ const message = err instanceof Error ? err.message : String(err);
310
+ const aborted = abortOutcome({ timedOut, cancelled: parentSignal?.aborted === true });
311
+ return {
312
+ task: spec.task,
313
+ write: Boolean(spec.write),
314
+ summary: "",
315
+ error: aborted?.error ?? message,
316
+ ...(aborted ? { stoppedEarly: aborted.stoppedEarly } : {}),
317
+ };
318
+ }
319
+ finally {
320
+ clearTimeout(timer);
321
+ parentSignal?.removeEventListener("abort", onParentAbort);
322
+ }
323
+ }
324
+ return {
325
+ // Runs subagents concurrently, capped at `concurrency` at a time, so a
326
+ // burst of parallel tasks can't exhaust API rate limits or system resources.
327
+ async spawnAgents(specs, signal, onProgress) {
328
+ // Don't stand up worktrees and API calls for work that the shared kill
329
+ // switch would abort on its first step anyway.
330
+ if (deps.kill().active) {
331
+ return specs.map((s) => ({
332
+ task: s.task,
333
+ write: Boolean(s.write),
334
+ summary: "",
335
+ error: "not started — the kill switch is active",
336
+ }));
337
+ }
338
+ const results = new Array(specs.length);
339
+ let next = 0;
340
+ const workers = Array.from({ length: Math.min(concurrency, specs.length) }, async () => {
341
+ while (next < specs.length) {
342
+ const i = next++;
343
+ results[i] = await runOneAgent(specs[i], signal, (text) => onProgress?.(i, text));
344
+ // Reported the moment it lands rather than only once the slowest
345
+ // sibling finishes: a six-way batch is otherwise invisible for as
346
+ // long as its slowest member takes.
347
+ onProgress?.(i, `done — ${headline(results[i])}`);
348
+ }
349
+ });
350
+ await Promise.all(workers);
351
+ return results;
352
+ },
353
+ };
354
+ }
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ import { isBannerSeen, markBannerSeen } from "./trust/bannerSeen.js";
40
40
  import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
41
41
  import { runHeadless } from "./headless.js";
42
42
  import { installCrashHandlers } from "./crash.js";
43
- import { createWorktree, commitWorktree, worktreeDiffStat, removeWorktree, isGitRepo, } from "./agent/worktree.js";
43
+ import { createSubagentRunner } from "./agent/subagents.js";
44
44
  import { VERSION } from "./version.js";
45
45
  import { updateNoticeForUser } from "./update/check.js";
46
46
  const USAGE = `kritya — a lean, provider-agnostic terminal coding agent
@@ -546,182 +546,31 @@ async function main() {
546
546
  ...nonRecursive(ALL_TOOLS),
547
547
  ...mcpTools.filter((t) => !t.requiresPermission),
548
548
  ];
549
- // Hard caps so a runaway or hung subagent can't stall the session or burn
550
- // unbounded API/compute: a wall-clock timeout per subagent, and a bound on
551
- // how many run at once.
552
- const SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000;
553
- const SUBAGENT_CONCURRENCY = 3;
554
- function silentHandlers(onFinalText, requestPermission) {
555
- return {
556
- onTextDelta: () => { },
557
- onReasoningDelta: () => { },
558
- onAssistantText: onFinalText,
559
- onToolStart: () => { },
560
- onToolEnd: () => { },
561
- requestPermission,
562
- onUsage: () => { },
563
- };
564
- }
565
- async function runReadOnlyAgent(task, signal) {
566
- const sub = new Agent(client, () => modelRef.current, readOnlySubTools, { workspace, sandboxMode, trustWorkspace }, new PermissionManager([], workspace), new SessionStore(workspace, true), []);
567
- sub.maxSteps = 15;
568
- sub.audit = sessionAudit;
569
- sub.tracer = sessionTracer;
570
- sub.meter = sessionMeter;
571
- // Share the parent's kill switch: a subagent with its own would keep
572
- // running after the user stopped the session.
573
- sub.kill = agent.kill;
574
- sub.spanParent = agent.turnSpan;
575
- sub.spanAttributes = { "kritya.subagent": true, "kritya.subagent_task": task.slice(0, 120) };
576
- let finalText = "";
577
- await sub.runTurn(task,
578
- // read-only tools never require permission, so this is never invoked
579
- silentHandlers((t) => (finalText = t), async () => "no"), signal);
580
- return { task, write: false, summary: finalText.trim() || "(subagent returned no findings)" };
581
- }
582
- async function runWriteAgent(task, signal) {
583
- if (!isGitRepo(workspace)) {
584
- return {
585
- task,
586
- write: true,
587
- summary: "",
588
- error: "the workspace is not a git repository, so an isolated worktree could not be created",
589
- };
590
- }
591
- const wt = createWorktree(workspace);
592
- if (!wt) {
593
- sessionAudit?.logTool({
594
- tool: "subagent_worktree",
595
- summary: `worktree creation failed for task: ${task.slice(0, 72)}`,
596
- outcome: "error",
597
- });
598
- return { task, write: true, summary: "", error: "failed to create an isolated git worktree" };
599
- }
600
- sessionAudit?.logTool({
601
- tool: "subagent_worktree",
602
- summary: `created branch "${wt.branch}" for task: ${task.slice(0, 72)}`,
603
- outcome: "ok",
604
- });
605
- let finalText = "";
606
- try {
607
- // Auto-allow ordinary writes/edits/shell (no human is watching this run),
608
- // but a destructive command still forces `warning` via classifyDanger in
609
- // the agent loop regardless of the allowlist — fail-safe deny it there,
610
- // since there's no one to confirm it and letting it run unattended would
611
- // be unsafe even inside an isolated worktree (it still has real shell/
612
- // network access).
613
- const sub = new Agent(client, () => modelRef.current, writeSubTools, { workspace: wt.dir, sandboxMode, trustWorkspace }, new PermissionManager({ allow: ["write_file", "edit_file", "shell(*)"], deny: [] }, wt.dir), new SessionStore(wt.dir, true), []);
614
- sub.maxSteps = 30;
615
- // No human is watching this run (see the auto-allow comment above) —
616
- // the forced unsandboxed-fallback warning must resolve on its own via
617
- // the handler below rather than be raised at all.
618
- sub.interactive = false;
619
- sub.audit = sessionAudit;
620
- sub.tracer = sessionTracer;
621
- sub.meter = sessionMeter;
622
- sub.kill = agent.kill; // see runReadOnlyAgent
623
- sub.spanParent = agent.turnSpan;
624
- sub.spanAttributes = {
625
- "kritya.subagent": true,
626
- "kritya.subagent_write": true,
627
- "kritya.subagent_task": task.slice(0, 120),
628
- "kritya.subagent_branch": wt.branch,
629
- };
630
- await sub.runTurn(task, silentHandlers((t) => (finalText = t), async (_name, _summary, _diff, warning) => (warning ? "no" : "yes")), signal);
631
- }
632
- catch (err) {
633
- if (!finalText)
634
- finalText = `(subagent stopped: ${err instanceof Error ? err.message : String(err)})`;
635
- }
636
- const commitState = commitWorktree(wt, `kritya subagent: ${task.slice(0, 72)}`);
637
- sessionAudit?.logTool({
638
- tool: "subagent_worktree",
639
- summary: `branch "${wt.branch}": commit ${commitState}`,
640
- outcome: commitState === "failed" ? "error" : "ok",
641
- });
642
- if (commitState === "clean") {
643
- const cleaned = removeWorktree(workspace, wt, true);
644
- const summary = finalText.trim() || "(no changes made)";
645
- // Surface this rather than silently leave an empty orphaned branch: a
646
- // failed `git branch -D` (e.g. a transient ref lock) shouldn't look
647
- // identical to a subagent that genuinely made no changes.
648
- return cleaned
649
- ? { task, write: true, summary }
650
- : {
651
- task,
652
- write: true,
653
- summary,
654
- error: `made no changes, but its empty scratch branch "${wt.branch}" could not be ` +
655
- `auto-deleted (a transient git lock) — safe to remove manually with ` +
656
- `\`git branch -D ${wt.branch}\``,
657
- };
658
- }
659
- if (commitState === "failed") {
660
- // Don't discard the worktree: the subagent's edits are real work, even
661
- // if a commit hook rejected them. Leave it on disk for manual recovery.
662
- return {
663
- task,
664
- write: true,
665
- summary: finalText.trim(),
666
- error: `changes could not be committed (a commit hook may have rejected them) — left uncommitted at ${wt.dir}`,
667
- };
668
- }
669
- const diffstat = worktreeDiffStat(workspace, wt);
670
- removeWorktree(workspace, wt, false);
671
- return {
672
- task,
673
- write: true,
674
- branch: wt.branch,
675
- summary: `${finalText.trim() || "(no summary)"}${diffstat ? `\n\n${diffstat}` : ""}`,
676
- };
677
- }
678
- async function runOneAgent(spec, parentSignal) {
679
- const controller = new AbortController();
680
- const onParentAbort = () => controller.abort();
681
- parentSignal?.addEventListener("abort", onParentAbort);
682
- const timer = setTimeout(() => controller.abort(), SUBAGENT_TIMEOUT_MS);
683
- try {
684
- return spec.write
685
- ? await runWriteAgent(spec.task, controller.signal)
686
- : await runReadOnlyAgent(spec.task, controller.signal);
687
- }
688
- catch (err) {
689
- return {
690
- task: spec.task,
691
- write: Boolean(spec.write),
692
- summary: "",
693
- error: err instanceof Error ? err.message : String(err),
694
- };
695
- }
696
- finally {
697
- clearTimeout(timer);
698
- parentSignal?.removeEventListener("abort", onParentAbort);
699
- }
700
- }
701
- // Runs subagents concurrently, capped at SUBAGENT_CONCURRENCY at a time, so
702
- // a burst of parallel tasks can't exhaust API rate limits or system resources.
703
- const spawnAgents = async (specs, signal) => {
704
- // Don't stand up worktrees and API calls for work that the shared kill
705
- // switch would abort on its first step anyway.
706
- if (agent.kill.active) {
707
- return specs.map((s) => ({
708
- task: s.task,
709
- write: Boolean(s.write),
710
- summary: "",
711
- error: "not started — the kill switch is active",
712
- }));
713
- }
714
- const results = new Array(specs.length);
715
- let next = 0;
716
- const workers = Array.from({ length: Math.min(SUBAGENT_CONCURRENCY, specs.length) }, async () => {
717
- while (next < specs.length) {
718
- const i = next++;
719
- results[i] = await runOneAgent(specs[i], signal);
720
- }
721
- });
722
- await Promise.all(workers);
723
- return results;
724
- };
549
+ // The runner itself lives in agent/subagents.ts. It used to be inline here,
550
+ // which put it out of reach of both the headless and Electron entry points
551
+ // (neither of which wires subagents at all as a result) and out of reach of
552
+ // the test suite, since dist/index.js is excluded from coverage.
553
+ const { spawnAgents } = createSubagentRunner({
554
+ client,
555
+ model: () => modelRef.current,
556
+ readOnlyTools: readOnlySubTools,
557
+ writeTools: writeSubTools,
558
+ workspace,
559
+ sandboxMode,
560
+ trustWorkspace,
561
+ audit: sessionAudit,
562
+ tracer: sessionTracer,
563
+ meter: sessionMeter,
564
+ // Read lazily: this runner is constructed before `agent` exists below.
565
+ kill: () => agent.kill,
566
+ spanParent: () => agent.turnSpan,
567
+ concurrency: config.subagentConcurrency,
568
+ timeoutMs: config.subagentTimeoutSeconds === undefined
569
+ ? undefined
570
+ : config.subagentTimeoutSeconds * 1000,
571
+ readMaxSteps: config.subagentMaxSteps,
572
+ writeMaxSteps: config.subagentWriteMaxSteps,
573
+ });
725
574
  const agent = new Agent(client, () => modelRef.current, tools, {
726
575
  workspace,
727
576
  sandboxMode,
@@ -1,3 +1,4 @@
1
+ import { subagentStatusNote } from "../agent/subagents.js";
1
2
  const MAX_AGENTS = 6;
2
3
  /**
3
4
  * Dispatches one or more focused, read-only subtasks to fresh agents that each
@@ -31,7 +32,7 @@ export const spawnAgentTool = {
31
32
  // Self-managed: each subagent has its own wall-clock cap and the batch runs
32
33
  // several in sequence, so the total legitimately exceeds any per-tool limit.
33
34
  timeoutMs: 0,
34
- async execute(args, ctx, signal) {
35
+ async execute(args, ctx, signal, onProgress) {
35
36
  const tasks = Array.isArray(args.tasks)
36
37
  ? args.tasks.map((t) => String(t).trim()).filter(Boolean)
37
38
  : [];
@@ -41,11 +42,20 @@ export const spawnAgentTool = {
41
42
  return `Error: at most ${MAX_AGENTS} tasks per call.`;
42
43
  if (!ctx.spawnAgents)
43
44
  return "Error: subagents are not available in this session.";
44
- const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: false })), signal);
45
- if (results.length === 1)
46
- return results[0].summary;
45
+ const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: false })), signal,
46
+ // The runner reports per-subagent status; the batch is what knows how
47
+ // many there are, so the "2/6" framing is added here.
48
+ (index, text) => onProgress?.(`subagent ${index + 1}/${tasks.length} · ${text}`));
49
+ if (results.length === 1) {
50
+ const note = subagentStatusNote(results[0]);
51
+ return note ? `${results[0].summary}\n\n${note}` : results[0].summary;
52
+ }
47
53
  return results
48
- .map((r, i) => `--- Subagent ${i + 1}: ${r.task.slice(0, 60)} ---\n${r.summary}`)
54
+ .map((r, i) => {
55
+ const note = subagentStatusNote(r);
56
+ const header = `--- Subagent ${i + 1}: ${r.task.slice(0, 60)} ---`;
57
+ return note ? `${header}\n${r.summary}\n${note}` : `${header}\n${r.summary}`;
58
+ })
49
59
  .join("\n\n");
50
60
  },
51
61
  summarize: (args) => {
@@ -1,3 +1,4 @@
1
+ import { subagentStatusNote } from "../agent/subagents.js";
1
2
  const MAX_AGENTS = 4;
2
3
  /**
3
4
  * Dispatches one or more subagents that can write files, edit files, and run
@@ -45,7 +46,7 @@ export const spawnWriteAgentTool = {
45
46
  tasks.map((t, i) => ` ${i + 1}. ${String(t).slice(0, 100)}`).join("\n") +
46
47
  `\n\nNone of this touches your working tree directly — review each branch's diff before merging.`);
47
48
  },
48
- async execute(args, ctx, signal) {
49
+ async execute(args, ctx, signal, onProgress) {
49
50
  const tasks = Array.isArray(args.tasks)
50
51
  ? args.tasks.map((t) => String(t).trim()).filter(Boolean)
51
52
  : [];
@@ -55,17 +56,23 @@ export const spawnWriteAgentTool = {
55
56
  return `Error: at most ${MAX_AGENTS} tasks per call.`;
56
57
  if (!ctx.spawnAgents)
57
58
  return "Error: subagents are not available in this session.";
58
- const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: true })), signal);
59
+ const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: true })), signal,
60
+ // See subagent.ts — the batch owns the "2/4" framing, the runner reports status.
61
+ (index, text) => onProgress?.(`subagent ${index + 1}/${tasks.length} · ${text}`));
59
62
  return results
60
63
  .map((r, i) => {
61
64
  const header = `--- Write subagent ${i + 1}: ${r.task.slice(0, 60)} ---`;
62
- if (r.error)
63
- return `${header}\n${r.summary}\n[error: ${r.error}]`;
64
- const branchNote = r.branch
65
- ? `\n\nChanges committed to branch "${r.branch}". Review with ` +
66
- `\`git diff main...${r.branch}\` (or your base branch), merge with \`git merge ${r.branch}\`.`
67
- : "\n\n(no file changes were made)";
68
- return `${header}\n${r.summary}${branchNote}`;
65
+ const note = subagentStatusNote(r);
66
+ // A problem replaces the branch note rather than sitting next to it:
67
+ // telling someone to review a branch is wrong advice when the commit
68
+ // failed, or when the subagent never finished what it was doing.
69
+ const tail = note
70
+ ? `\n${note}`
71
+ : r.branch
72
+ ? `\n\nChanges committed to branch "${r.branch}". Review with ` +
73
+ `\`git diff main...${r.branch}\` (or your base branch), merge with \`git merge ${r.branch}\`.`
74
+ : "\n\n(no file changes were made)";
75
+ return `${header}\n${r.summary}${tail}`;
69
76
  })
70
77
  .join("\n\n");
71
78
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.29-beta",
3
+ "version": "0.8.30-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {