kritya 0.8.29-beta → 0.8.31-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
@@ -316,6 +316,8 @@ async function promptApiKeySetup(providerName, envVarName) {
316
316
  async function main() {
317
317
  const config = loadConfig();
318
318
  const privacyMode = args.privacy || privacyModeFor(config);
319
+ // The ASCII banner renders on every launch regardless — this only decides
320
+ // whether to also show the first-run "Try asking:" hints.
319
321
  const firstLaunch = !isBannerSeen(workspace);
320
322
  if (!isAiDisclosureShown(workspace)) {
321
323
  await showAiDisclosureNotice();
@@ -546,182 +548,31 @@ async function main() {
546
548
  ...nonRecursive(ALL_TOOLS),
547
549
  ...mcpTools.filter((t) => !t.requiresPermission),
548
550
  ];
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
- };
551
+ // The runner itself lives in agent/subagents.ts. It used to be inline here,
552
+ // which put it out of reach of both the headless and Electron entry points
553
+ // (neither of which wires subagents at all as a result) and out of reach of
554
+ // the test suite, since dist/index.js is excluded from coverage.
555
+ const { spawnAgents } = createSubagentRunner({
556
+ client,
557
+ model: () => modelRef.current,
558
+ readOnlyTools: readOnlySubTools,
559
+ writeTools: writeSubTools,
560
+ workspace,
561
+ sandboxMode,
562
+ trustWorkspace,
563
+ audit: sessionAudit,
564
+ tracer: sessionTracer,
565
+ meter: sessionMeter,
566
+ // Read lazily: this runner is constructed before `agent` exists below.
567
+ kill: () => agent.kill,
568
+ spanParent: () => agent.turnSpan,
569
+ concurrency: config.subagentConcurrency,
570
+ timeoutMs: config.subagentTimeoutSeconds === undefined
571
+ ? undefined
572
+ : config.subagentTimeoutSeconds * 1000,
573
+ readMaxSteps: config.subagentMaxSteps,
574
+ writeMaxSteps: config.subagentWriteMaxSteps,
575
+ });
725
576
  const agent = new Agent(client, () => modelRef.current, tools, {
726
577
  workspace,
727
578
  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
  },
@@ -4,9 +4,13 @@ import { CONFIG_DIR } from "../config/config.js";
4
4
  import { hardenWindowsDir } from "../config/winAcl.js";
5
5
  import { debugLog } from "../config/debug.js";
6
6
  /**
7
- * Tracks which workspaces have already shown the full ASCII banner once, so
8
- * repeat launches (which cost real vertical space in a terminal) can fall
9
- * back to a compact one-line header instead. Same shape and persistence
7
+ * Tracks which workspaces have already been opened once.
8
+ *
9
+ * The full ASCII banner itself no longer consults this — it renders on every
10
+ * launch, because the compact one-line fallback it used to select made repeat
11
+ * launches look broken. What remains gated on it is the first-run
12
+ * "Try asking:" hint block in App.tsx, which is genuine onboarding text and
13
+ * would be noise on every subsequent launch. Same shape and persistence
10
14
  * pattern as aiDisclosure.ts's ai-disclosure.json.
11
15
  */
12
16
  const BANNER_FILE = path.join(CONFIG_DIR, "banner-seen.json");
@@ -22,11 +26,11 @@ function loadStore(storeFile) {
22
26
  return {};
23
27
  }
24
28
  }
25
- /** Whether the full ASCII banner has already been shown for this workspace. */
29
+ /** Whether this workspace has been opened before (drives the first-run hints). */
26
30
  export function isBannerSeen(workspace, storeFile = BANNER_FILE) {
27
31
  return typeof loadStore(storeFile)[path.resolve(workspace)] === "string";
28
32
  }
29
- /** Record that the full ASCII banner has been shown for this workspace, now. */
33
+ /** Record that this workspace has been opened, now. */
30
34
  export function markBannerSeen(workspace, storeFile = BANNER_FILE) {
31
35
  const store = loadStore(storeFile);
32
36
  store[path.resolve(workspace)] = new Date().toISOString();
package/dist/ui/App.js CHANGED
@@ -123,7 +123,6 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
123
123
  resumeSessions,
124
124
  refreshFileList,
125
125
  onSwitchClient,
126
- firstLaunch,
127
126
  });
128
127
  useEffect(() => {
129
128
  onRequestPermissionReady?.(requestPermission);
package/dist/ui/Banner.js CHANGED
@@ -41,27 +41,37 @@ function letterOffsets(text, pixelLen) {
41
41
  function letterWidth(ch, pixelLen) {
42
42
  return (GLYPHS[ch] ?? GLYPHS["-"])[0].length * pixelLen;
43
43
  }
44
- // Vertical gradient: cyan at the top fading into NVIDIA green.
44
+ // The banner's palette: cyan at the top of the glyphs fading into NVIDIA green.
45
45
  const TOP_RGB = [0x00, 0xd9, 0xff];
46
46
  const BOTTOM_RGB = [0x76, 0xb9, 0x00];
47
- function rowColor(row) {
48
- const t = row / (ROWS - 1);
47
+ /** One step of the cyan→green ramp, where t=0 is the cyan end and t=1 the green end. */
48
+ function ramp(t) {
49
49
  const [r, g, b] = TOP_RGB.map((c, i) => Math.round(c + (BOTTOM_RGB[i] - c) * t));
50
50
  return `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
51
51
  }
52
+ function rowColor(row) {
53
+ return ramp(row / (ROWS - 1));
54
+ }
55
+ /**
56
+ * The tagline walks the glyphs' own cyan→green ramp, but left-to-right instead
57
+ * of top-to-bottom, so it reads as the bottom edge of the same object rather
58
+ * than an unrelated third colour. Ink has no text gradient, so this is applied
59
+ * one character at a time.
60
+ */
61
+ export function sweepColor(index, length) {
62
+ return ramp(length <= 1 ? 0 : index / (length - 1));
63
+ }
52
64
  const BETA_LABEL = "beta";
53
65
  /** Product tagline shown under the banner on launch. */
54
66
  export const TAGLINE = "Superintelligence at your command line";
55
- export function Banner({ tagline, subtitle, compact, }) {
56
- if (compact) {
57
- return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: subtitle }) }));
58
- }
67
+ export function Banner({ tagline, subtitle }) {
59
68
  const columns = terminalColumns(process.stdout);
60
69
  const wide = bannerLines("KRITYA", "░░");
61
70
  const narrow = bannerLines("KRITYA", "░");
62
71
  const pixelLen = wide[0].length <= columns ? 2 : narrow[0].length <= columns ? 1 : 0;
63
72
  const lines = pixelLen === 2 ? wide : pixelLen === 1 ? narrow : null;
64
- const center = (text) => " ".repeat(Math.max(0, Math.floor((columns - text.length) / 2))) + text;
73
+ const pad = (text) => " ".repeat(Math.max(0, Math.floor((columns - text.length) / 2)));
74
+ const center = (text) => pad(text) + text;
65
75
  // Right-align "beta" under the final "A" of KRITYA, one line below the glyph.
66
76
  const betaLine = (() => {
67
77
  if (!lines)
@@ -72,5 +82,5 @@ export function Banner({ tagline, subtitle, compact, }) {
72
82
  const indent = leftPad + aStart + Math.max(0, aWidth - BETA_LABEL.length);
73
83
  return " ".repeat(indent) + BETA_LABEL;
74
84
  })();
75
- return (_jsxs(Box, { flexDirection: "column", marginTop: 2, marginBottom: 1, children: [lines ? (lines.map((line, i) => (_jsx(Text, { color: rowColor(i), children: center(line) }, i)))) : (_jsx(Text, { bold: true, color: "cyan", children: center("KRITYA") })), betaLine ? _jsx(Text, { dimColor: true, children: betaLine }) : null, tagline ? _jsx(Text, { children: center(tagline) }) : null, _jsx(Box, { marginTop: 2, children: subtitle ? _jsx(Text, { dimColor: true, children: center(subtitle) }) : null }), _jsx(Text, { dimColor: true, children: center("AI can make mistakes. Verify important output.") })] }));
85
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 2, marginBottom: 1, children: [lines ? (lines.map((line, i) => (_jsx(Text, { color: rowColor(i), children: center(line) }, i)))) : (_jsx(Text, { bold: true, color: "cyan", children: center("KRITYA") })), betaLine ? _jsx(Text, { dimColor: true, children: betaLine }) : null, tagline ? (_jsxs(Text, { children: [pad(tagline), [...tagline].map((ch, i) => (_jsx(Text, { color: sweepColor(i, tagline.length), children: ch }, i)))] })) : null, _jsx(Box, { marginTop: 2, children: subtitle ? _jsx(Text, { dimColor: true, children: center(subtitle) }) : null }), _jsx(Text, { dimColor: true, children: center("AI can make mistakes. Verify important output.") })] }));
76
86
  }
@@ -10,5 +10,5 @@ export function TranscriptItem({ item, verbose, contentWidth }) {
10
10
  return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text, width: contentWidth }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20, contentWidth - TOOL_INDENT), item.error)
11
11
  .split("\n")
12
12
  .map((l) => ` ${l}`)
13
- .join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && (_jsx(Banner, { tagline: item.tagline, subtitle: item.subtitle, compact: item.compact })), item.kind === "summary" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Completed" }), item.files.length > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Changed:" }), item.files.map((f) => (_jsxs(Text, { children: [" ", f] }, f)))] })) : (_jsx(Text, { dimColor: true, children: "No files changed." })), _jsxs(Text, { dimColor: true, children: ["Next: ", item.nextStep] })] }))] }));
13
+ .join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { tagline: item.tagline, subtitle: item.subtitle }), item.kind === "summary" && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Completed" }), item.files.length > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Changed:" }), item.files.map((f) => (_jsxs(Text, { children: [" ", f] }, f)))] })) : (_jsx(Text, { dimColor: true, children: "No files changed." })), _jsxs(Text, { dimColor: true, children: ["Next: ", item.nextStep] })] }))] }));
14
14
  }
@@ -45,7 +45,7 @@ function initialToolStatus(name) {
45
45
  * permission state, usage/cost tracking, and session resume. Input handling
46
46
  * (text box, slash-command suggestions, @mention autocomplete) stays in App.
47
47
  */
48
- export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, initialTasks, resumeSessions, refreshFileList, onSwitchClient, firstLaunch, }) {
48
+ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, initialTasks, resumeSessions, refreshFileList, onSwitchClient, }) {
49
49
  const nextId = useRef(0);
50
50
  const [items, setItems] = useState(() => {
51
51
  const initial = [
@@ -54,7 +54,6 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
54
54
  kind: "banner",
55
55
  tagline: TAGLINE,
56
56
  subtitle: `${path.basename(workspace)} · ${modelRef.current} · type a request, or /help for commands`,
57
- compact: !firstLaunch,
58
57
  },
59
58
  ];
60
59
  if (resumedCount > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.29-beta",
3
+ "version": "0.8.31-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {