@humain/terminal 0.0.15 → 0.0.17

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.
@@ -23,6 +23,7 @@ import { getMarkdownTheme } from "../../modes/interactive/theme/theme.js";
23
23
  import { parseFrontmatter } from "../../utils/frontmatter.js";
24
24
  import { withFileMutationQueue } from "./file-mutation-queue.js";
25
25
  import { shortenPath } from "./render-utils.js";
26
+ import { createSubagentWorktree, isGitWorkTree, } from "./subagent-worktree.js";
26
27
  import { wrapToolDefinition } from "./tool-definition-wrapper.js";
27
28
  const MAX_PARALLEL_TASKS = 8;
28
29
  const MAX_CONCURRENCY = 4;
@@ -223,7 +224,10 @@ function getFinalOutput(messages) {
223
224
  return "";
224
225
  }
225
226
  function isFailedResult(result) {
226
- return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
227
+ return (Boolean(result.errorMessage) ||
228
+ result.exitCode !== 0 ||
229
+ result.stopReason === "error" ||
230
+ result.stopReason === "aborted");
227
231
  }
228
232
  function getResultOutput(result) {
229
233
  if (isFailedResult(result)) {
@@ -255,7 +259,7 @@ function getDisplayItems(messages) {
255
259
  }
256
260
  return items;
257
261
  }
258
- async function mapWithConcurrencyLimit(items, concurrency, fn, signal) {
262
+ async function mapWithConcurrencyLimit(items, concurrency, fn, signal, onAbort) {
259
263
  if (items.length === 0)
260
264
  return [];
261
265
  const limit = Math.max(1, Math.min(concurrency, items.length));
@@ -263,12 +267,14 @@ async function mapWithConcurrencyLimit(items, concurrency, fn, signal) {
263
267
  let nextIndex = 0;
264
268
  const workers = new Array(limit).fill(null).map(async () => {
265
269
  while (true) {
266
- // Don't spawn doomed children for tasks still queued after an abort.
267
- if (signal?.aborted)
268
- throw new Error("Subagent was aborted");
269
270
  const current = nextIndex++;
270
271
  if (current >= items.length)
271
272
  return;
273
+ if (signal?.aborted) {
274
+ if (onAbort)
275
+ results[current] = await onAbort(items[current], current);
276
+ continue;
277
+ }
272
278
  results[current] = await fn(items[current], current);
273
279
  }
274
280
  });
@@ -297,7 +303,7 @@ function getCliInvocation(args) {
297
303
  }
298
304
  return { command: "humain-terminal", args };
299
305
  }
300
- async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, model, parentModel, signal, onUpdate, makeDetails) {
306
+ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, isolation, step, model, parentModel, signal, onUpdate, makeDetails, toolCallId, childIndex) {
301
307
  const agent = agents.find((a) => a.name === agentName);
302
308
  if (!agent) {
303
309
  const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
@@ -310,8 +316,13 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, mo
310
316
  stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
311
317
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
312
318
  step,
319
+ isolation: "shared",
313
320
  };
314
321
  }
322
+ let worktree = null;
323
+ let isolationDegradedReason;
324
+ const parentCwd = cwd ?? defaultCwd;
325
+ let childCwd = parentCwd;
315
326
  const args = ["--mode", "json", "-p", "--no-session"];
316
327
  if (model) {
317
328
  // Canonical "provider/modelId" handle: split on the FIRST slash so
@@ -343,6 +354,7 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, mo
343
354
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
344
355
  model: model ?? `${parentModel.provider}/${parentModel.id}`,
345
356
  step,
357
+ isolation: "shared",
346
358
  };
347
359
  const emitUpdate = () => {
348
360
  if (onUpdate) {
@@ -352,7 +364,22 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, mo
352
364
  });
353
365
  }
354
366
  };
367
+ let wasAborted = false;
355
368
  try {
369
+ if (isolation === "worktree") {
370
+ if (await isGitWorkTree(parentCwd)) {
371
+ worktree = await createSubagentWorktree({ parentCwd, label: `${toolCallId}-${childIndex}` });
372
+ childCwd = worktree.path;
373
+ currentResult.isolation = "worktree";
374
+ }
375
+ else {
376
+ isolationDegradedReason = "not-a-git-repo";
377
+ }
378
+ }
379
+ if (isolationDegradedReason)
380
+ currentResult.isolationDegradedReason = isolationDegradedReason;
381
+ if (signal?.aborted)
382
+ throw new Error("Subagent was aborted");
356
383
  if (agent.systemPrompt.trim()) {
357
384
  const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
358
385
  tmpPromptDir = tmp.dir;
@@ -360,16 +387,16 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, mo
360
387
  args.push("--append-system-prompt", tmpPromptPath);
361
388
  }
362
389
  args.push(`Task: ${task}`);
363
- let wasAborted = false;
364
390
  const exitCode = await new Promise((resolve) => {
365
391
  const invocation = getCliInvocation(args);
366
392
  const proc = spawn(invocation.command, invocation.args, {
367
- cwd: cwd ?? defaultCwd,
393
+ cwd: childCwd,
368
394
  shell: false,
369
395
  stdio: ["ignore", "pipe", "pipe"],
370
396
  env: { ...process.env, [SUBAGENT_DEPTH_ENV]: String(currentSubagentDepth() + 1) },
371
397
  });
372
398
  let buffer = "";
399
+ let settled = false;
373
400
  const processLine = (line) => {
374
401
  if (!line.trim())
375
402
  return;
@@ -418,23 +445,39 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, mo
418
445
  proc.stderr.on("data", (data) => {
419
446
  currentResult.stderr += data.toString();
420
447
  });
448
+ let abortListener;
449
+ let killTimer;
450
+ const cleanupAbort = () => {
451
+ if (signal && abortListener)
452
+ signal.removeEventListener("abort", abortListener);
453
+ if (killTimer)
454
+ clearTimeout(killTimer);
455
+ };
421
456
  proc.on("close", (code) => {
457
+ settled = true;
458
+ cleanupAbort();
422
459
  if (buffer.trim())
423
460
  processLine(buffer);
424
461
  resolve(code ?? 0);
425
462
  });
426
463
  proc.on("error", () => {
464
+ settled = true;
465
+ cleanupAbort();
427
466
  resolve(1);
428
467
  });
429
468
  if (signal) {
430
469
  const killProc = () => {
470
+ if (settled)
471
+ return;
431
472
  wasAborted = true;
432
- proc.kill("SIGTERM");
433
- setTimeout(() => {
434
- if (!proc.killed)
473
+ killTimer = setTimeout(() => {
474
+ if (!settled)
435
475
  proc.kill("SIGKILL");
436
476
  }, 5000);
477
+ killTimer.unref?.();
478
+ proc.kill("SIGTERM");
437
479
  };
480
+ abortListener = killProc;
438
481
  if (signal.aborted)
439
482
  killProc();
440
483
  else
@@ -442,11 +485,50 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, mo
442
485
  }
443
486
  });
444
487
  currentResult.exitCode = exitCode;
445
- if (wasAborted)
446
- throw new Error("Subagent was aborted");
447
- return currentResult;
488
+ if (wasAborted || signal?.aborted) {
489
+ currentResult.exitCode = 1;
490
+ currentResult.stopReason = "aborted";
491
+ currentResult.errorMessage = "Subagent was aborted";
492
+ }
493
+ }
494
+ catch (error) {
495
+ currentResult.exitCode = 1;
496
+ currentResult.errorMessage = error instanceof Error ? error.message : String(error);
497
+ if (wasAborted || signal?.aborted)
498
+ currentResult.stopReason = "aborted";
448
499
  }
449
500
  finally {
501
+ if (worktree) {
502
+ try {
503
+ if (isFailedResult(currentResult) || signal?.aborted) {
504
+ currentResult.mergeOutcome = "skipped";
505
+ }
506
+ else {
507
+ const merged = await worktree.mergeBack();
508
+ currentResult.mergeOutcome = merged.outcome;
509
+ currentResult.changedFiles = merged.changedFiles;
510
+ if (merged.patchPath)
511
+ currentResult.patchPath = merged.patchPath;
512
+ if (merged.outcome === "conflict") {
513
+ currentResult.errorMessage = `subagent merge conflict: ${merged.changedFiles.join(", ")} could not be applied to the parent workspace; patch kept at ${merged.patchPath}`;
514
+ }
515
+ }
516
+ }
517
+ catch (error) {
518
+ currentResult.exitCode = 1;
519
+ currentResult.mergeOutcome = "skipped";
520
+ currentResult.errorMessage = `subagent merge failed: ${error instanceof Error ? error.message : String(error)}`;
521
+ }
522
+ finally {
523
+ try {
524
+ await worktree.dispose();
525
+ }
526
+ catch (error) {
527
+ currentResult.exitCode = 1;
528
+ currentResult.errorMessage = `subagent worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
529
+ }
530
+ }
531
+ }
450
532
  if (tmpPromptPath)
451
533
  try {
452
534
  fs.unlinkSync(tmpPromptPath);
@@ -462,6 +544,7 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, mo
462
544
  /* ignore */
463
545
  }
464
546
  }
547
+ return currentResult;
465
548
  }
466
549
  const TaskItem = Type.Object({
467
550
  agent: Type.String({ description: "Name of the agent to invoke" }),
@@ -470,6 +553,9 @@ const TaskItem = Type.Object({
470
553
  description: "Optional canonical provider/model for this child. Defaults to the parent provider/model.",
471
554
  })),
472
555
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
556
+ isolation: Type.Optional(StringEnum(["shared", "worktree"], {
557
+ description: "Filesystem isolation for this child. 'worktree' runs it in a disposable git worktree and merges its changes back; the caller's policy sets this, not the model.",
558
+ })),
473
559
  });
474
560
  const ChainItem = Type.Object({
475
561
  agent: Type.String({ description: "Name of the agent to invoke" }),
@@ -494,6 +580,9 @@ const SubagentParams = Type.Object({
494
580
  agentScope: Type.Optional(AgentScopeSchema),
495
581
  confirmProjectAgents: Type.Optional(Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true })),
496
582
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
583
+ isolation: Type.Optional(StringEnum(["shared", "worktree"], {
584
+ description: "Filesystem isolation for this child. 'worktree' runs it in a disposable git worktree and merges its changes back; the caller's policy sets this, not the model.",
585
+ })),
497
586
  });
498
587
  export function createSubagentToolDefinition(cwd) {
499
588
  return {
@@ -502,6 +591,7 @@ export function createSubagentToolDefinition(cwd) {
502
591
  description: [
503
592
  "Delegate tasks to specialized subagents with isolated context.",
504
593
  "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
594
+ "The optional isolation field is policy-set for each child.",
505
595
  `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
506
596
  `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" (or "project").`,
507
597
  ].join(" "),
@@ -510,7 +600,7 @@ export function createSubagentToolDefinition(cwd) {
510
600
  "Use subagent to fan out independent work in parallel (tasks array) or run multi-step pipelines (chain). Subagents run with isolated context and cannot delegate further.",
511
601
  ],
512
602
  parameters: SubagentParams,
513
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
603
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
514
604
  const agentScope = params.agentScope ?? "user";
515
605
  const defaultCwd = ctx?.cwd ?? cwd;
516
606
  const parentModel = ctx?.model;
@@ -539,6 +629,7 @@ export function createSubagentToolDefinition(cwd) {
539
629
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
540
630
  errorMessage,
541
631
  step: child.step,
632
+ isolation: "shared",
542
633
  });
543
634
  const stubAllChildren = (errorMessage) => requestedChildren().map((child) => stubResult(child, errorMessage));
544
635
  const makeDetails = (mode) => (results) => ({
@@ -631,7 +722,7 @@ export function createSubagentToolDefinition(cwd) {
631
722
  }
632
723
  }
633
724
  : undefined;
634
- const result = await runSingleAgent(defaultCwd, agents, step.agent, taskWithContext, step.cwd, i + 1, step.model, parentModel, signal, chainUpdate, makeDetails("chain"));
725
+ const result = await runSingleAgent(defaultCwd, agents, step.agent, taskWithContext, step.cwd, undefined, i + 1, step.model, parentModel, signal, chainUpdate, makeDetails("chain"), toolCallId, i);
635
726
  results.push(result);
636
727
  const isError = isFailedResult(result);
637
728
  if (isError) {
@@ -676,6 +767,7 @@ export function createSubagentToolDefinition(cwd) {
676
767
  messages: [],
677
768
  stderr: "",
678
769
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
770
+ isolation: "shared",
679
771
  };
680
772
  }
681
773
  const emitParallelUpdate = () => {
@@ -691,18 +783,18 @@ export function createSubagentToolDefinition(cwd) {
691
783
  }
692
784
  };
693
785
  const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
694
- const result = await runSingleAgent(defaultCwd, agents, t.agent, t.task, t.cwd, undefined, t.model, parentModel, signal,
786
+ const result = await runSingleAgent(defaultCwd, agents, t.agent, t.task, t.cwd, t.isolation, undefined, t.model, parentModel, signal,
695
787
  // Per-task update callback
696
788
  (partial) => {
697
789
  if (partial.details?.results[0]) {
698
790
  allResults[index] = partial.details.results[0];
699
791
  emitParallelUpdate();
700
792
  }
701
- }, makeDetails("parallel"));
793
+ }, makeDetails("parallel"), toolCallId, index);
702
794
  allResults[index] = result;
703
795
  emitParallelUpdate();
704
796
  return result;
705
- }, signal);
797
+ }, signal, (t) => stubResult({ agent: t.agent, task: t.task }, "not run: subagent was aborted"));
706
798
  const successCount = results.filter((r) => !isFailedResult(r)).length;
707
799
  const summaries = results.map((r) => {
708
800
  const output = truncateParallelOutput(getResultOutput(r));
@@ -719,10 +811,11 @@ export function createSubagentToolDefinition(cwd) {
719
811
  },
720
812
  ],
721
813
  details: makeDetails("parallel")(results),
814
+ isError: successCount !== results.length,
722
815
  };
723
816
  }
724
817
  if (params.agent && params.task) {
725
- const result = await runSingleAgent(defaultCwd, agents, params.agent, params.task, params.cwd, undefined, params.model, parentModel, signal, onUpdate, makeDetails("single"));
818
+ const result = await runSingleAgent(defaultCwd, agents, params.agent, params.task, params.cwd, params.isolation, undefined, params.model, parentModel, signal, onUpdate, makeDetails("single"), toolCallId, 0);
726
819
  const isError = isFailedResult(result);
727
820
  if (isError) {
728
821
  const errorMsg = getResultOutput(result);