@wrongstack/cli 0.1.10 → 0.2.0

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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { color, DefaultPathResolver, Container, DefaultConfigStore, TOKENS, DefaultSecretScrubber, DefaultRetryPolicy, DefaultErrorHandler, DefaultTokenCounter, DefaultModeStore, DefaultSessionStore, DefaultMemoryStore, DefaultSkillLoader, DefaultSystemPromptBuilder, DefaultPermissionPolicy, HybridCompactor, ProviderRegistry, ToolRegistry, createContextManagerTool, EventBus, InMemoryMetricsSink, wireMetricsToEvents, DefaultHealthRegistry, startMetricsServer, RecoveryLock, DefaultAttachmentStore, QueueStore, Context, createDefaultPipelines, AutoCompactionMiddleware, Agent, SlashCommandRegistry, loadPlugins, FLEET_ROSTER, DefaultLogger, DefaultModelsRegistry, makeDirectorSessionFactory, makeAgentSubagentRunner, Director, DefaultMultiAgentCoordinator, resolveWstackPaths, DefaultSecretVault, migratePlaintextSecrets, DefaultConfigLoader, DefaultSessionReader, atomicWrite, formatTodosList, InputBuilder, decryptConfigSecrets, encryptConfigSecrets, DefaultPluginAPI } from '@wrongstack/core';
2
+ import { color, DefaultPathResolver, Container, DefaultConfigStore, TOKENS, DefaultSecretScrubber, DefaultRetryPolicy, DefaultErrorHandler, DefaultTokenCounter, DefaultModeStore, DefaultSessionStore, DefaultMemoryStore, DefaultSkillLoader, DefaultSystemPromptBuilder, DefaultPermissionPolicy, HybridCompactor, ProviderRegistry, ToolRegistry, createContextManagerTool, EventBus, InMemoryMetricsSink, wireMetricsToEvents, DefaultHealthRegistry, startMetricsServer, RecoveryLock, DefaultAttachmentStore, QueueStore, Context, loadTodosCheckpoint, attachTodosCheckpoint, loadDirectorState, loadPlan, createDefaultPipelines, AutoCompactionMiddleware, Agent, SlashCommandRegistry, loadPlugins, createDelegateTool, FLEET_ROSTER, DefaultLogger, DefaultModelsRegistry, makeDirectorSessionFactory, Director, DefaultMultiAgentCoordinator, makeAgentSubagentRunner, resolveWstackPaths, DefaultSecretVault, migratePlaintextSecrets, DefaultConfigLoader, DefaultSessionReader, atomicWrite, AutoApprovePermissionPolicy, formatTodosList, emptyPlan, clearPlan, savePlan, removePlanItem, formatPlan, setPlanItemStatus, addPlanItem, InputBuilder, decryptConfigSecrets, encryptConfigSecrets, DefaultPluginAPI } from '@wrongstack/core';
3
3
  import * as fs3 from 'fs/promises';
4
4
  import { WebSocketServer, WebSocket } from 'ws';
5
5
  import { writeFileSync } from 'fs';
@@ -1470,7 +1470,7 @@ function buildStatsCommand(opts) {
1470
1470
  function buildFleetCommand(opts) {
1471
1471
  return {
1472
1472
  name: "fleet",
1473
- description: "Inspect or control the subagent fleet: /fleet [status|usage|kill <id>|manifest|help]",
1473
+ description: "Inspect or control the subagent fleet: /fleet [status|usage|kill <id>|manifest|retry [taskId]|log <id>|stream on|off|help]",
1474
1474
  help: [
1475
1475
  "Usage:",
1476
1476
  " /fleet Show fleet status (alias for /fleet status).",
@@ -1478,6 +1478,13 @@ function buildFleetCommand(opts) {
1478
1478
  " /fleet usage Per-subagent runtime cost.",
1479
1479
  " /fleet kill <id> Terminate a running subagent.",
1480
1480
  " /fleet manifest Print the director manifest.",
1481
+ " /fleet retry List interrupted tasks from the last run.",
1482
+ " /fleet retry <taskId> Re-spawn the matching subagent and re-assign the task.",
1483
+ " /fleet retry all Re-assign every interrupted task at once.",
1484
+ " /fleet log List subagent transcripts available on disk.",
1485
+ " /fleet log <id> Print a compact summary of a subagent transcript.",
1486
+ " /fleet log <id> raw Dump the full per-subagent JSONL.",
1487
+ " /fleet stream on|off Show/hide subagent activity in the main history.",
1481
1488
  " /fleet help Show this help."
1482
1489
  ].join("\n"),
1483
1490
  async run(args) {
@@ -1496,6 +1503,38 @@ function buildFleetCommand(opts) {
1496
1503
  if (!target) return { message: "Usage: /fleet kill <subagent-id>" };
1497
1504
  return { message: await opts.onFleet("kill", target) };
1498
1505
  }
1506
+ case "retry": {
1507
+ if (!opts.onFleetRetry) {
1508
+ return { message: "Retry is only available when director mode is active." };
1509
+ }
1510
+ const msg = await opts.onFleetRetry(target);
1511
+ return { message: msg };
1512
+ }
1513
+ case "log": {
1514
+ if (!opts.onFleetLog) {
1515
+ return { message: "Log inspection is only available when a fleet root is configured." };
1516
+ }
1517
+ const [id, ...modeRest] = rest;
1518
+ const mode = modeRest.join(" ").trim() === "raw" ? "raw" : "summary";
1519
+ return { message: await opts.onFleetLog(id, mode) };
1520
+ }
1521
+ case "stream": {
1522
+ const ctrl = opts.fleetStreamController;
1523
+ if (!ctrl) {
1524
+ return { message: "Stream toggle is only available in the TUI." };
1525
+ }
1526
+ const arg = (target ?? "").toLowerCase();
1527
+ if (arg === "" || arg === "status") {
1528
+ return { message: `Fleet streaming is ${ctrl.enabled ? "on" : "off"}.` };
1529
+ }
1530
+ if (arg !== "on" && arg !== "off") {
1531
+ return { message: "Usage: /fleet stream on|off" };
1532
+ }
1533
+ const enabled = arg === "on";
1534
+ ctrl.setEnabled(enabled);
1535
+ ctrl.enabled = enabled;
1536
+ return { message: `Fleet streaming ${enabled ? "enabled" : "disabled"}.` };
1537
+ }
1499
1538
  case "help":
1500
1539
  case "?":
1501
1540
  return {
@@ -1713,6 +1752,66 @@ function buildMetricsCommand(opts) {
1713
1752
  }
1714
1753
  };
1715
1754
  }
1755
+ function buildPlanCommand(opts) {
1756
+ return {
1757
+ name: "plan",
1758
+ description: "Strategic plan board: /plan [show|add <title>|done <id|#>|remove <id|#>|clear]",
1759
+ async run(args) {
1760
+ const planPath = opts.planPath;
1761
+ if (!planPath) return { message: "Plan storage is not configured for this session." };
1762
+ const ctx = opts.context;
1763
+ const sessionId = ctx?.session.id ?? "unknown";
1764
+ const [verb, ...rest] = args.trim().split(/\s+/);
1765
+ const restJoined = rest.join(" ").trim();
1766
+ const plan = await loadPlan(planPath) ?? emptyPlan(sessionId);
1767
+ switch (verb) {
1768
+ case "":
1769
+ case "show":
1770
+ case "list": {
1771
+ return { message: formatPlan(plan) };
1772
+ }
1773
+ case "add": {
1774
+ if (!restJoined) return { message: "Usage: /plan add <title>" };
1775
+ const { plan: updated, item } = addPlanItem(plan, restJoined);
1776
+ await savePlan(planPath, updated);
1777
+ return { message: `Added: ${item.title}
1778
+ ${formatPlan(updated)}` };
1779
+ }
1780
+ case "start":
1781
+ case "progress": {
1782
+ if (!restJoined) return { message: "Usage: /plan start <id|index>" };
1783
+ const updated = setPlanItemStatus(plan, restJoined, "in_progress");
1784
+ await savePlan(planPath, updated);
1785
+ return { message: formatPlan(updated) };
1786
+ }
1787
+ case "done":
1788
+ case "complete": {
1789
+ if (!restJoined) return { message: "Usage: /plan done <id|index>" };
1790
+ const updated = setPlanItemStatus(plan, restJoined, "done");
1791
+ await savePlan(planPath, updated);
1792
+ return { message: formatPlan(updated) };
1793
+ }
1794
+ case "remove":
1795
+ case "delete":
1796
+ case "rm": {
1797
+ if (!restJoined) return { message: "Usage: /plan remove <id|index>" };
1798
+ const updated = removePlanItem(plan, restJoined);
1799
+ await savePlan(planPath, updated);
1800
+ return { message: formatPlan(updated) };
1801
+ }
1802
+ case "clear": {
1803
+ const updated = clearPlan(plan);
1804
+ await savePlan(planPath, updated);
1805
+ return { message: "Plan cleared." };
1806
+ }
1807
+ default:
1808
+ return {
1809
+ message: `Unknown subcommand "${verb}". Try: show | add <title> | start <id|#> | done <id|#> | remove <id|#> | clear`
1810
+ };
1811
+ }
1812
+ }
1813
+ };
1814
+ }
1716
1815
  function buildSaveCommand(opts) {
1717
1816
  return {
1718
1817
  name: "save",
@@ -1922,6 +2021,7 @@ function buildBuiltinSlashCommands(opts) {
1922
2021
  buildHealthCommand(opts),
1923
2022
  buildMemoryCommand(opts),
1924
2023
  buildTodosCommand(opts),
2024
+ buildPlanCommand(opts),
1925
2025
  buildSaveCommand(opts),
1926
2026
  buildLoadCommand(opts),
1927
2027
  buildExitCommand(opts)
@@ -3704,6 +3804,35 @@ function fmtTok(n) {
3704
3804
  function patchConfig(base, patch) {
3705
3805
  return Object.freeze({ ...base, ...patch });
3706
3806
  }
3807
+ function fmtDuration(ms) {
3808
+ if (ms < 1e3) return `${ms}ms`;
3809
+ const s = ms / 1e3;
3810
+ if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
3811
+ const m = Math.floor(s / 60);
3812
+ const remSec = Math.round(s - m * 60);
3813
+ if (m < 60) return remSec === 0 ? `${m}m` : `${m}m${remSec}s`;
3814
+ const h = Math.floor(m / 60);
3815
+ const remMin = m - h * 60;
3816
+ return `${h}h${remMin}m`;
3817
+ }
3818
+ function fmtTaskResultLine(r, color28) {
3819
+ const stats = `${r.iterations}it ${r.toolCalls}tc ${fmtDuration(r.durationMs)}`;
3820
+ const errMsg = typeof r.error === "string" ? r.error : r.error?.message;
3821
+ const errKind = typeof r.error === "object" ? r.error?.kind : void 0;
3822
+ const errTail = errMsg ? ` \u2014 ${errMsg.replace(/\s+/g, " ").slice(0, 80)}${errMsg.length > 80 ? "\u2026" : ""}` : "";
3823
+ const errKindChip = errKind ? color28.dim(` [${errKind}]`) : "";
3824
+ const errSnip = errMsg || errKind ? `${errKindChip}${color28.dim(errTail)}` : "";
3825
+ switch (r.status) {
3826
+ case "success":
3827
+ return { mark: color28.green("\u2713"), stats, tail: "" };
3828
+ case "timeout":
3829
+ return { mark: color28.yellow("\u23F1"), stats: `${color28.yellow("timeout")} ${stats}`, tail: errSnip };
3830
+ case "stopped":
3831
+ return { mark: color28.dim("\u2298"), stats: `${color28.dim("stopped")} ${stats}`, tail: errSnip };
3832
+ case "failed":
3833
+ return { mark: color28.red("\u2717"), stats: `${color28.red("failed")} ${stats}`, tail: errSnip };
3834
+ }
3835
+ }
3707
3836
 
3708
3837
  // src/boot.ts
3709
3838
  function resolveBundledSkillsDir() {
@@ -4013,7 +4142,10 @@ async function execute(deps) {
4013
4142
  savedProviderCfg,
4014
4143
  resolvedProvider,
4015
4144
  getPickableProviders,
4016
- switchProviderAndModel
4145
+ switchProviderAndModel,
4146
+ director,
4147
+ fleetRoster,
4148
+ fleetStreamController
4017
4149
  } = deps;
4018
4150
  let code = 0;
4019
4151
  try {
@@ -4021,6 +4153,11 @@ async function execute(deps) {
4021
4153
  if (promptFlag) {
4022
4154
  positional.unshift(promptFlag);
4023
4155
  }
4156
+ const goalFlag = typeof flags["goal"] === "string" ? flags["goal"] : void 0;
4157
+ const askFlag = typeof flags["ask"] === "string" ? flags["ask"] : void 0;
4158
+ if ((goalFlag || askFlag) && positional.length === 0 && !promptFlag) {
4159
+ flags.tui = true;
4160
+ }
4024
4161
  if (positional.length > 0 || promptFlag) {
4025
4162
  const query = positional.join(" ");
4026
4163
  const ctrl = new AbortController();
@@ -4107,7 +4244,14 @@ async function execute(deps) {
4107
4244
  getPickableProviders,
4108
4245
  switchProviderAndModel,
4109
4246
  effectiveMaxContext,
4110
- altScreen: flags["alt-screen"] === true,
4247
+ // Default OFF so the terminal's native scrollback works for chat
4248
+ // history out of the box (mouse wheel / Shift+PgUp). Users who hit
4249
+ // resize/overlay-leak artifacts can opt back into alt-screen with
4250
+ // `--alt-screen` or `/altscreen on`. `--no-alt-screen` still wins
4251
+ // when both are passed.
4252
+ altScreen: flags["alt-screen"] === true && flags["no-alt-screen"] !== true,
4253
+ director,
4254
+ fleetRoster,
4111
4255
  onAfterExit: () => {
4112
4256
  process.stdout.write(
4113
4257
  color.dim(`Session saved: ${session.id} \u2014 resume with `) + color.cyan(`wstack resume ${session.id}`) + "\n"
@@ -4116,7 +4260,10 @@ async function execute(deps) {
4116
4260
  onClearHistory: (dispatch) => {
4117
4261
  dispatch({ type: "clearHistory" });
4118
4262
  dispatch({ type: "resetContextChip" });
4119
- }
4263
+ },
4264
+ fleetStreamController,
4265
+ initialGoal: goalFlag,
4266
+ initialAsk: askFlag
4120
4267
  });
4121
4268
  } finally {
4122
4269
  renderer.setSilent(false);
@@ -4185,6 +4332,13 @@ var MultiAgentHost = class {
4185
4332
  pending = /* @__PURE__ */ new Map();
4186
4333
  results = [];
4187
4334
  opts;
4335
+ /**
4336
+ * Populated by `promoteToDirector` when it refuses to promote (typically
4337
+ * because a non-director coordinator is already running). The delegate
4338
+ * tool reads this through `getPromotionBlockReason` to render an
4339
+ * actionable error instead of a generic "could not activate director".
4340
+ */
4341
+ promotionBlockReason = null;
4188
4342
  /**
4189
4343
  * Force the lazy build path to run *now* and return the live Director,
4190
4344
  * or null when director mode is off. Used by the CLI to register the
@@ -4208,8 +4362,64 @@ var MultiAgentHost = class {
4208
4362
  directorRunId: this.opts.directorRunId
4209
4363
  });
4210
4364
  }
4365
+ const coordinatorConfig = {
4366
+ coordinatorId: randomUUID(),
4367
+ doneCondition: { type: "all_tasks_done" },
4368
+ maxConcurrent: 8
4369
+ // No defaultBudget. Caps land on a subagent ONLY when the
4370
+ // orchestrator (delegate-tool / spawn_subagent) or the user
4371
+ // (CLI flag) sets them explicitly. The prior defaults
4372
+ // (1000 tools / 200 iter / 4h) silently killed long autonomous
4373
+ // runs; for a "work until done" director we want no implicit
4374
+ // ceilings. The orchestrator can still cap a single subagent
4375
+ // by passing maxToolCalls/maxIterations through the spawn tool.
4376
+ };
4377
+ if (this.opts.directorMode) {
4378
+ this.director = new Director({
4379
+ config: coordinatorConfig,
4380
+ manifestPath: this.opts.manifestPath,
4381
+ sharedScratchpadPath: this.opts.sharedScratchpadPath,
4382
+ stateCheckpointPath: this.opts.stateCheckpointPath,
4383
+ sessionWriter: this.opts.sessionWriter,
4384
+ // Autonomy: allow nested directors a few levels deep. Default
4385
+ // is 2 (root + one tier of workers), which trips the moment a
4386
+ // worker tries to recurse into "let me delegate the parser
4387
+ // analysis to a tighter specialist". 5 lets the director
4388
+ // structure work as deeply as the task requires without us
4389
+ // having to pass a flag every time.
4390
+ maxSpawnDepth: 5
4391
+ });
4392
+ this.director.on("task.completed", ({ task, result }) => {
4393
+ this.results.push(result);
4394
+ this.pending.delete(task.id);
4395
+ this.emitLifecycleCompleted(task.id, result);
4396
+ });
4397
+ this.coordinator = this.director.coordinator;
4398
+ } else {
4399
+ this.coordinator = new DefaultMultiAgentCoordinator(coordinatorConfig, {});
4400
+ this.coordinator.on(
4401
+ "task.completed",
4402
+ ({ task, result }) => {
4403
+ this.results.push(result);
4404
+ this.pending.delete(task.id);
4405
+ this.emitLifecycleCompleted(task.id, result);
4406
+ }
4407
+ );
4408
+ }
4409
+ this.coordinator.on(
4410
+ "task.assigned",
4411
+ ({ task, subagentId }) => {
4412
+ this.deps.events.emit("subagent.task_started", {
4413
+ subagentId,
4414
+ taskId: task.id,
4415
+ description: task.description
4416
+ });
4417
+ }
4418
+ );
4211
4419
  const runner = this.buildSubagentRunner(config);
4212
- return this.buildCoordinator(runner);
4420
+ const innerCoord = this.opts.directorMode ? this.director.coordinator : this.coordinator;
4421
+ innerCoord.setRunner(runner);
4422
+ return this.coordinator;
4213
4423
  }
4214
4424
  /**
4215
4425
  * Build the per-subagent runner: agent factory → runner. Extracted so
@@ -4224,7 +4434,12 @@ var MultiAgentHost = class {
4224
4434
  projectRoot: this.deps.projectRoot,
4225
4435
  tools: this.filterTools(subCfg.tools),
4226
4436
  model: subCfg.model ?? config.model,
4227
- provider: subCfg.provider ?? config.provider
4437
+ provider: subCfg.provider ?? config.provider,
4438
+ // Tell the builder this is a subagent build — skips the host's
4439
+ // plan injection so each subagent gets a clean, task-scoped
4440
+ // prompt instead of inheriting strategic context that's
4441
+ // meaningless to a single delegated subtask.
4442
+ subagent: true
4228
4443
  });
4229
4444
  let subSession;
4230
4445
  if (this.sessionFactory) {
@@ -4259,46 +4474,34 @@ var MultiAgentHost = class {
4259
4474
  providers: this.deps.providerRegistry,
4260
4475
  events,
4261
4476
  pipelines: createDefaultPipelines(),
4262
- context: ctx
4263
- });
4264
- return { agent, events };
4265
- };
4266
- return makeAgentSubagentRunner({ factory });
4267
- }
4268
- /**
4269
- * Build the coordinator (or Director wrapper) with task-completion
4270
- * drain wired into the host's result buffer.
4271
- */
4272
- buildCoordinator(runner) {
4273
- const coordinatorConfig = {
4274
- coordinatorId: randomUUID(),
4275
- doneCondition: { type: "all_tasks_done" },
4276
- maxConcurrent: 2,
4277
- defaultBudget: { maxToolCalls: 20, maxIterations: 20, timeoutMs: 12e4 }
4278
- };
4279
- if (this.opts.directorMode) {
4280
- this.director = new Director({
4281
- config: coordinatorConfig,
4282
- runner,
4283
- manifestPath: this.opts.manifestPath,
4284
- sharedScratchpadPath: this.opts.sharedScratchpadPath
4477
+ context: ctx,
4478
+ // Subagents cannot answer interactive permission prompts — they
4479
+ // run under a director, not the user. Auto-approve everything
4480
+ // (except tool-level hard denies); the user already authorized
4481
+ // the work when they invoked the leader.
4482
+ permissionPolicy: new AutoApprovePermissionPolicy()
4285
4483
  });
4286
- this.director.on("task.completed", ({ task, result }) => {
4287
- this.results.push(result);
4288
- this.pending.delete(task.id);
4484
+ const hostEvents = this.deps.events;
4485
+ const offToolBridge = events.on("tool.executed", (e) => {
4486
+ hostEvents.emit("subagent.tool_executed", {
4487
+ subagentId: subCfg.id ?? subCfg.name ?? "subagent",
4488
+ name: e.name,
4489
+ durationMs: e.durationMs,
4490
+ ok: e.ok,
4491
+ input: e.input,
4492
+ outputBytes: e.outputBytes
4493
+ });
4289
4494
  });
4290
- this.coordinator = this.director.coordinator;
4291
- return this.coordinator;
4292
- }
4293
- this.coordinator = new DefaultMultiAgentCoordinator(coordinatorConfig, { runner });
4294
- this.coordinator.on(
4295
- "task.completed",
4296
- ({ task, result }) => {
4297
- this.results.push(result);
4298
- this.pending.delete(task.id);
4299
- }
4300
- );
4301
- return this.coordinator;
4495
+ const dispose = async () => {
4496
+ offToolBridge();
4497
+ try {
4498
+ await subSession.close?.();
4499
+ } catch {
4500
+ }
4501
+ };
4502
+ return { agent, events, dispose };
4503
+ };
4504
+ return makeAgentSubagentRunner({ factory, fleetBus: this.director?.fleet });
4302
4505
  }
4303
4506
  /**
4304
4507
  * Build a Provider for a subagent. When `overrideId` is supplied (from
@@ -4348,21 +4551,30 @@ var MultiAgentHost = class {
4348
4551
  const subagentConfig = {
4349
4552
  name: opts?.name ?? "adhoc",
4350
4553
  role: "general",
4351
- maxToolCalls: 20,
4352
- maxIterations: 20,
4353
4554
  provider: opts?.provider,
4354
4555
  model: opts?.model,
4355
4556
  tools: opts?.tools
4356
4557
  };
4558
+ const transcriptPath = this.sessionFactory ? path13.join(this.sessionFactory.dir, `${subagentConfig.name}.jsonl`) : void 0;
4357
4559
  if (this.director) {
4358
4560
  const subagentId = await this.director.spawn(subagentConfig);
4359
4561
  const taskId2 = randomUUID();
4360
4562
  this.pending.set(taskId2, { description, subagentId });
4563
+ this.deps.events.emit("subagent.spawned", {
4564
+ subagentId,
4565
+ taskId: taskId2,
4566
+ name: subagentConfig.name,
4567
+ provider: opts?.provider,
4568
+ model: opts?.model,
4569
+ description,
4570
+ transcriptPath
4571
+ });
4361
4572
  await this.director.assign({
4362
4573
  id: taskId2,
4363
4574
  description,
4364
- subagentId,
4365
- maxToolCalls: 20
4575
+ subagentId
4576
+ // No maxToolCalls — same reasoning as the spawn config above.
4577
+ // The director / orchestrator owns the budget decision.
4366
4578
  });
4367
4579
  return { subagentId, taskId: taskId2 };
4368
4580
  }
@@ -4370,14 +4582,42 @@ var MultiAgentHost = class {
4370
4582
  const spawned = await coord.spawn(subagentConfig);
4371
4583
  const taskId = randomUUID();
4372
4584
  this.pending.set(taskId, { description, subagentId: spawned.subagentId });
4585
+ this.deps.events.emit("subagent.spawned", {
4586
+ subagentId: spawned.subagentId,
4587
+ taskId,
4588
+ name: subagentConfig.name,
4589
+ provider: opts?.provider,
4590
+ model: opts?.model,
4591
+ description,
4592
+ transcriptPath
4593
+ });
4373
4594
  await coord.assign({
4374
4595
  id: taskId,
4375
4596
  description,
4376
- subagentId: spawned.subagentId,
4377
- maxToolCalls: 20
4597
+ subagentId: spawned.subagentId
4598
+ // No maxToolCalls — see comment on the director branch above.
4378
4599
  });
4379
4600
  return { subagentId: spawned.subagentId, taskId };
4380
4601
  }
4602
+ /**
4603
+ * Relay a `task.completed` notification (from either the Director or
4604
+ * the raw coordinator) to the EventBus so non-director TUIs and any
4605
+ * other observer can react. We forward the full result shape rather
4606
+ * than mutating the existing `task.completed` schema — coordination
4607
+ * code already binds to that event, and adding subscribers there
4608
+ * would change ordering semantics for those listeners.
4609
+ */
4610
+ emitLifecycleCompleted(taskId, result) {
4611
+ this.deps.events.emit("subagent.task_completed", {
4612
+ subagentId: result.subagentId,
4613
+ taskId,
4614
+ status: result.status,
4615
+ iterations: result.iterations,
4616
+ toolCalls: result.toolCalls,
4617
+ durationMs: result.durationMs,
4618
+ error: result.error
4619
+ });
4620
+ }
4381
4621
  status() {
4382
4622
  const pending = Array.from(this.pending.entries()).map(([taskId, v]) => ({
4383
4623
  taskId,
@@ -4459,6 +4699,10 @@ var MultiAgentHost = class {
4459
4699
  async promoteToDirector() {
4460
4700
  if (this.director) return this.director;
4461
4701
  if (this.coordinator) {
4702
+ const status = this.coordinator.getStatus();
4703
+ const running = status.subagents.filter((s) => s.status === "running").length;
4704
+ const idle = status.subagents.filter((s) => s.status === "idle").length;
4705
+ this.promotionBlockReason = `Cannot promote to director: a non-director coordinator is already in use (${running} running, ${idle} idle, ${status.pendingTasks} pending tasks). Stop the existing subagents with /fleet kill <id> or wait for them to finish, then retry \u2014 or restart wstack with --director to start in director mode.`;
4462
4706
  return null;
4463
4707
  }
4464
4708
  this.opts.directorMode = true;
@@ -4471,6 +4715,9 @@ var MultiAgentHost = class {
4471
4715
  if (this.opts.fleetRoot && !this.opts.sessionsRoot) {
4472
4716
  this.opts.sessionsRoot = path13.join(this.opts.fleetRoot, "subagents");
4473
4717
  }
4718
+ if (this.opts.fleetRoot && !this.opts.stateCheckpointPath) {
4719
+ this.opts.stateCheckpointPath = path13.join(this.opts.fleetRoot, "director-state.json");
4720
+ }
4474
4721
  await this.ensureDirector();
4475
4722
  return this.director ?? null;
4476
4723
  }
@@ -4482,6 +4729,16 @@ var MultiAgentHost = class {
4482
4729
  isDirectorMode() {
4483
4730
  return !!this.director;
4484
4731
  }
4732
+ /**
4733
+ * Why the most recent `promoteToDirector` call returned null. Cleared
4734
+ * implicitly on the next successful promotion. The delegate tool reads
4735
+ * this so the LLM sees the actual blocker (e.g. "3 running subagents,
4736
+ * wait or /fleet kill") instead of a generic "Director could not be
4737
+ * activated" message that gives no path forward.
4738
+ */
4739
+ getPromotionBlockReason() {
4740
+ return this.promotionBlockReason;
4741
+ }
4485
4742
  /**
4486
4743
  * Terminate a single subagent. Returns true when the subagent existed
4487
4744
  * (regardless of whether stop() succeeded or it was already idle),
@@ -4844,6 +5101,7 @@ async function main(argv) {
4844
5101
  supportsVision: resolvedModel.capabilities.vision,
4845
5102
  supportsReasoning: resolvedModel.capabilities.reasoning
4846
5103
  } : void 0;
5104
+ const sessionRef = {};
4847
5105
  container.bind(
4848
5106
  TOKENS.SystemPromptBuilder,
4849
5107
  () => new DefaultSystemPromptBuilder({
@@ -4852,7 +5110,10 @@ async function main(argv) {
4852
5110
  modeStore,
4853
5111
  modeId,
4854
5112
  modePrompt,
4855
- modelCapabilities
5113
+ modelCapabilities,
5114
+ // Reads the ref each time — returns undefined until the session
5115
+ // is created, then resolves to the per-session plan JSON path.
5116
+ planPath: () => sessionRef.current ? path13.join(wpaths.projectSessions, `${sessionRef.current.id}.plan.json`) : void 0
4856
5117
  })
4857
5118
  );
4858
5119
  container.bind(TOKENS.Renderer, () => renderer);
@@ -5091,6 +5352,7 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5091
5352
  provider: config.provider
5092
5353
  });
5093
5354
  }
5355
+ sessionRef.current = session;
5094
5356
  await recoveryLock.write(session.id).catch(() => void 0);
5095
5357
  const attachments = new DefaultAttachmentStore({
5096
5358
  spoolDir: path13.join(wpaths.projectSessions, session.id, "attachments")
@@ -5122,6 +5384,54 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5122
5384
  if (restoredMessages.length > 0) {
5123
5385
  context.state.replaceMessages(restoredMessages);
5124
5386
  }
5387
+ const todosCheckpointPath = path13.join(wpaths.projectSessions, `${session.id}.todos.json`);
5388
+ if (resumeId) {
5389
+ try {
5390
+ const restoredTodos = await loadTodosCheckpoint(todosCheckpointPath);
5391
+ if (restoredTodos && restoredTodos.length > 0) {
5392
+ context.state.replaceTodos(restoredTodos);
5393
+ renderer.writeInfo(
5394
+ `Restored ${restoredTodos.length} todo${restoredTodos.length === 1 ? "" : "s"} from previous run.`
5395
+ );
5396
+ }
5397
+ } catch {
5398
+ }
5399
+ }
5400
+ attachTodosCheckpoint(
5401
+ context.state,
5402
+ todosCheckpointPath,
5403
+ session.id
5404
+ );
5405
+ const planPath = path13.join(wpaths.projectSessions, `${session.id}.plan.json`);
5406
+ context.state.setMeta("plan.path", planPath);
5407
+ if (resumeId) {
5408
+ try {
5409
+ const fleetRoot2 = path13.join(wpaths.projectSessions, session.id);
5410
+ const dirState = await loadDirectorState(path13.join(fleetRoot2, "director-state.json"));
5411
+ if (dirState) {
5412
+ const tCounts = {};
5413
+ for (const t of dirState.tasks) {
5414
+ tCounts[t.status] = (tCounts[t.status] ?? 0) + 1;
5415
+ }
5416
+ const summary = Object.entries(tCounts).map(([k, v]) => `${v} ${k}`).join(", ");
5417
+ renderer.writeInfo(
5418
+ `Prior fleet state: ${dirState.subagents.length} subagent${dirState.subagents.length === 1 ? "" : "s"}, tasks ${summary || "(none)"}.`
5419
+ );
5420
+ }
5421
+ } catch {
5422
+ }
5423
+ try {
5424
+ const plan = await loadPlan(planPath);
5425
+ if (plan && plan.items.length > 0) {
5426
+ const open = plan.items.filter((p) => p.status !== "done").length;
5427
+ const done = plan.items.length - open;
5428
+ renderer.writeInfo(
5429
+ `Plan: ${plan.items.length} item${plan.items.length === 1 ? "" : "s"} (${open} open, ${done} done). Use /plan to review.`
5430
+ );
5431
+ }
5432
+ } catch {
5433
+ }
5434
+ }
5125
5435
  const pipelines = createDefaultPipelines();
5126
5436
  const installBoundary = (p) => {
5127
5437
  p.setErrorHandler((ev) => {
@@ -5268,10 +5578,12 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5268
5578
  }
5269
5579
  };
5270
5580
  const directorMode = flags["director"] === true;
5581
+ let director = null;
5271
5582
  const fleetRoot = directorMode ? path13.join(wpaths.projectSessions, session.id) : void 0;
5272
5583
  const manifestPath = directorMode ? typeof process.env["WRONGSTACK_FLEET_MANIFEST"] === "string" ? process.env["WRONGSTACK_FLEET_MANIFEST"] : path13.join(fleetRoot, "fleet.json") : void 0;
5273
5584
  const sharedScratchpadPath = directorMode ? path13.join(fleetRoot, "shared") : void 0;
5274
5585
  const subagentSessionsRoot = directorMode ? path13.join(fleetRoot, "subagents") : void 0;
5586
+ const stateCheckpointPath = directorMode ? path13.join(fleetRoot, "director-state.json") : void 0;
5275
5587
  const fleetRootForPromotion = path13.join(wpaths.projectSessions, session.id);
5276
5588
  const multiAgentHost = new MultiAgentHost(
5277
5589
  {
@@ -5292,11 +5604,25 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5292
5604
  sharedScratchpadPath,
5293
5605
  sessionsRoot: subagentSessionsRoot,
5294
5606
  directorRunId: session.id,
5295
- fleetRoot: fleetRootForPromotion
5607
+ fleetRoot: fleetRootForPromotion,
5608
+ stateCheckpointPath,
5609
+ sessionWriter: session
5296
5610
  }
5297
5611
  );
5612
+ toolRegistry.register(
5613
+ createDelegateTool({
5614
+ host: multiAgentHost,
5615
+ roster: FLEET_ROSTER,
5616
+ // Wire the per-subagent transcript location so the tool can
5617
+ // extract partial output on timeout / budget exhaustion. Without
5618
+ // this, a subagent that hit its iteration cap returns an empty
5619
+ // result and the host LLM has no idea what work was done.
5620
+ sessionsRoot: subagentSessionsRoot,
5621
+ directorRunId: session.id
5622
+ })
5623
+ );
5298
5624
  if (directorMode) {
5299
- const director = await multiAgentHost.ensureDirector();
5625
+ director = await multiAgentHost.ensureDirector();
5300
5626
  if (director) {
5301
5627
  for (const tool of director.tools(FLEET_ROSTER)) {
5302
5628
  toolRegistry.register(tool);
@@ -5310,6 +5636,12 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5310
5636
  renderer.writeInfo(`Director mode enabled. Fleet manifest \u2192 ${manifestPath}`);
5311
5637
  }
5312
5638
  }
5639
+ const fleetStreamController = {
5640
+ enabled: true,
5641
+ setEnabled(enabled) {
5642
+ this.enabled = enabled;
5643
+ }
5644
+ };
5313
5645
  const slashCmds = buildBuiltinSlashCommands({
5314
5646
  registry: slashRegistry,
5315
5647
  toolRegistry,
@@ -5322,6 +5654,8 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5322
5654
  context,
5323
5655
  metricsSink,
5324
5656
  healthRegistry,
5657
+ planPath,
5658
+ fleetStreamController,
5325
5659
  onSpawn: async (description, spawnOpts) => {
5326
5660
  const { subagentId, taskId } = await multiAgentHost.spawn(description, spawnOpts);
5327
5661
  const tags = [];
@@ -5338,9 +5672,8 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5338
5672
  lines.push(` pending ${p.taskId.slice(0, 8)} \u2192 ${p.description.slice(0, 60)}`);
5339
5673
  }
5340
5674
  for (const r of s.completed) {
5341
- lines.push(
5342
- ` ${r.status === "success" ? color.green("\u2713") : color.red("\u2717")} ${r.taskId.slice(0, 8)} ${r.iterations}it ${r.toolCalls}tc ${r.durationMs}ms`
5343
- );
5675
+ const fmt = fmtTaskResultLine(r, color);
5676
+ lines.push(` ${fmt.mark} ${r.taskId.slice(0, 8)} ${fmt.stats}${fmt.tail}`);
5344
5677
  }
5345
5678
  return lines.join("\n");
5346
5679
  },
@@ -5359,9 +5692,9 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5359
5692
  if (s.completed.length > 0) {
5360
5693
  lines.push("", color.dim(" Completed"));
5361
5694
  for (const r of s.completed) {
5362
- const mark = r.status === "success" ? color.green("\u2713") : color.red("\u2717");
5695
+ const fmt = fmtTaskResultLine(r, color);
5363
5696
  lines.push(
5364
- ` ${mark} ${r.taskId.slice(0, 8)} \u2192 ${r.subagentId.slice(0, 8)} \xB7 ${r.iterations}it ${r.toolCalls}tc ${r.durationMs}ms`
5697
+ ` ${fmt.mark} ${r.taskId.slice(0, 8)} \u2192 ${r.subagentId.slice(0, 8)} \xB7 ${fmt.stats}${fmt.tail}`
5365
5698
  );
5366
5699
  }
5367
5700
  }
@@ -5402,10 +5735,191 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5402
5735
  }
5403
5736
  return `Unknown fleet action: ${action}`;
5404
5737
  },
5738
+ onFleetLog: async (subagentId, mode) => {
5739
+ const subagentsRoot = path13.join(fleetRootForPromotion, "subagents");
5740
+ let runDirs;
5741
+ try {
5742
+ runDirs = await fs3.readdir(subagentsRoot);
5743
+ } catch {
5744
+ return "No fleet transcripts on disk \u2014 no subagents have been spawned for this session.";
5745
+ }
5746
+ const found = [];
5747
+ for (const runId of runDirs) {
5748
+ const runDir = path13.join(subagentsRoot, runId);
5749
+ let files;
5750
+ try {
5751
+ files = await fs3.readdir(runDir);
5752
+ } catch {
5753
+ continue;
5754
+ }
5755
+ for (const f of files) {
5756
+ if (!f.endsWith(".jsonl")) continue;
5757
+ const full = path13.join(runDir, f);
5758
+ try {
5759
+ const stat2 = await fs3.stat(full);
5760
+ found.push({
5761
+ runId,
5762
+ subagentId: f.replace(/\.jsonl$/, ""),
5763
+ file: full,
5764
+ size: stat2.size
5765
+ });
5766
+ } catch {
5767
+ }
5768
+ }
5769
+ }
5770
+ if (found.length === 0) {
5771
+ return "No subagent transcripts found on disk.";
5772
+ }
5773
+ if (!subagentId) {
5774
+ const lines2 = [
5775
+ `${found.length} subagent transcript${found.length === 1 ? "" : "s"} on disk:`
5776
+ ];
5777
+ for (const t2 of found) {
5778
+ lines2.push(
5779
+ ` ${color.cyan(t2.subagentId.padEnd(18))} ${color.dim(t2.runId.slice(0, 18))} ${color.dim(`${(t2.size / 1024).toFixed(1)} KB`)}`
5780
+ );
5781
+ }
5782
+ lines2.push("Use `/fleet log <subagentId>` for a summary, or append `raw` for the full JSONL.");
5783
+ return lines2.join("\n");
5784
+ }
5785
+ const matches = found.filter(
5786
+ (t2) => t2.subagentId === subagentId || t2.subagentId.startsWith(subagentId)
5787
+ );
5788
+ if (matches.length === 0) {
5789
+ return `No transcript matched "${subagentId}". Run \`/fleet log\` to list available ids.`;
5790
+ }
5791
+ if (matches.length > 1) {
5792
+ return [
5793
+ `Ambiguous id "${subagentId}" \u2014 ${matches.length} matches:`,
5794
+ ...matches.map((m) => ` ${m.subagentId} (${m.runId})`)
5795
+ ].join("\n");
5796
+ }
5797
+ const t = matches[0];
5798
+ const raw = await fs3.readFile(t.file, "utf8");
5799
+ if (mode === "raw") return raw;
5800
+ const lines = raw.split("\n").filter((l) => l.trim());
5801
+ const counts = {};
5802
+ let firstUser = null;
5803
+ let lastResponse = null;
5804
+ let totalIterations = 0;
5805
+ const toolNames = /* @__PURE__ */ new Map();
5806
+ for (const line of lines) {
5807
+ try {
5808
+ const ev = JSON.parse(line);
5809
+ counts[ev.type] = (counts[ev.type] ?? 0) + 1;
5810
+ if (ev.type === "user_input" && !firstUser) {
5811
+ const txt = typeof ev.content === "string" ? ev.content : Array.isArray(ev.content) ? ev.content.filter((b) => b.type === "text").map((b) => b.text).join(" ") : "";
5812
+ firstUser = txt.slice(0, 120);
5813
+ }
5814
+ if (ev.type === "llm_response") {
5815
+ if (Array.isArray(ev.content)) {
5816
+ const txt = ev.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join(" ");
5817
+ if (txt) lastResponse = txt.slice(0, 240);
5818
+ }
5819
+ totalIterations += 1;
5820
+ }
5821
+ if (ev.type === "tool_use" && typeof ev.name === "string") {
5822
+ toolNames.set(ev.name, (toolNames.get(ev.name) ?? 0) + 1);
5823
+ }
5824
+ } catch {
5825
+ }
5826
+ }
5827
+ const toolBreakdown = toolNames.size > 0 ? Array.from(toolNames.entries()).sort((a, b) => b[1] - a[1]).map(([n, c]) => `${n}\xD7${c}`).join(", ") : "(none)";
5828
+ const out = [
5829
+ color.bold(`Subagent ${t.subagentId}`) + color.dim(` (run ${t.runId})`),
5830
+ ` ${lines.length} events \xB7 ${totalIterations} llm iterations \xB7 ${(t.size / 1024).toFixed(1)} KB`,
5831
+ ` tools: ${toolBreakdown}`
5832
+ ];
5833
+ if (firstUser) out.push("", color.dim(" task:"), ` ${firstUser}`);
5834
+ if (lastResponse) out.push("", color.dim(" last response:"), ` ${lastResponse}`);
5835
+ out.push("", color.dim(" event mix:"));
5836
+ for (const [type, count] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
5837
+ out.push(` ${type.padEnd(20)} ${count}`);
5838
+ }
5839
+ out.push("", color.dim("Use `/fleet log <id> raw` for the full JSONL."));
5840
+ return out.join("\n");
5841
+ },
5842
+ onFleetRetry: async (taskId) => {
5843
+ if (!multiAgentHost.isDirectorMode()) {
5844
+ const promoted = await multiAgentHost.promoteToDirector();
5845
+ if (!promoted) {
5846
+ return "Cannot retry: a coordinator already exists in non-director mode.";
5847
+ }
5848
+ for (const tool of promoted.tools(FLEET_ROSTER)) {
5849
+ toolRegistry.register(tool);
5850
+ }
5851
+ }
5852
+ const dir = await multiAgentHost.ensureDirector();
5853
+ if (!dir) return "Director is not available.";
5854
+ const dirStatePath = path13.join(fleetRootForPromotion, "director-state.json");
5855
+ const prior = await loadDirectorState(dirStatePath);
5856
+ if (!prior) {
5857
+ return "No prior director-state.json found \u2014 nothing to retry.";
5858
+ }
5859
+ const interrupted = prior.tasks.filter(
5860
+ (t) => t.status === "running" || t.status === "pending"
5861
+ );
5862
+ if (interrupted.length === 0) {
5863
+ return "No interrupted tasks: every prior task reached a terminal state.";
5864
+ }
5865
+ if (!taskId) {
5866
+ const lines = [
5867
+ `${interrupted.length} interrupted task${interrupted.length === 1 ? "" : "s"} from prior run:`
5868
+ ];
5869
+ for (const t of interrupted) {
5870
+ const owner = t.subagentId ? prior.subagents.find((s) => s.id === t.subagentId) : void 0;
5871
+ const tag = owner ? `${owner.name ?? owner.id} (${owner.role ?? "no-role"})` : "no-owner";
5872
+ lines.push(
5873
+ ` ${t.taskId.slice(0, 12)} ${t.status.padEnd(8)} ${tag} ${(t.description ?? "").slice(0, 60)}`
5874
+ );
5875
+ }
5876
+ lines.push("Run `/fleet retry <taskId>` or `/fleet retry all` to re-assign.");
5877
+ return lines.join("\n");
5878
+ }
5879
+ const targets = taskId === "all" ? interrupted : interrupted.filter(
5880
+ (t) => t.taskId === taskId || t.taskId.startsWith(taskId)
5881
+ );
5882
+ if (targets.length === 0) {
5883
+ return `No interrupted task matched "${taskId}".`;
5884
+ }
5885
+ const results = [];
5886
+ for (const t of targets) {
5887
+ const owner = t.subagentId ? prior.subagents.find((s) => s.id === t.subagentId) : void 0;
5888
+ if (!owner) {
5889
+ results.push(` - ${t.taskId.slice(0, 12)}: no owner record, skipped.`);
5890
+ continue;
5891
+ }
5892
+ const rosterCfg = owner.role ? FLEET_ROSTER[owner.role] : void 0;
5893
+ const cfg = rosterCfg ? { ...rosterCfg } : {
5894
+ name: owner.name ?? owner.id,
5895
+ role: owner.role,
5896
+ provider: owner.provider,
5897
+ model: owner.model
5898
+ };
5899
+ try {
5900
+ const newSubId = await dir.spawn(cfg);
5901
+ const newTaskId = await dir.assign({
5902
+ id: "",
5903
+ description: t.description ?? "(no description)",
5904
+ subagentId: newSubId
5905
+ });
5906
+ results.push(
5907
+ ` ${color.green("\u2713")} ${t.taskId.slice(0, 12)} \u2192 re-spawned ${newSubId.slice(0, 12)} (task ${newTaskId.slice(0, 12)})`
5908
+ );
5909
+ } catch (err) {
5910
+ results.push(
5911
+ ` ${color.red("\u2717")} ${t.taskId.slice(0, 12)} \u2192 ${err instanceof Error ? err.message : String(err)}`
5912
+ );
5913
+ }
5914
+ }
5915
+ return [`Retried ${targets.length} task${targets.length === 1 ? "" : "s"}:`, ...results].join(
5916
+ "\n"
5917
+ );
5918
+ },
5405
5919
  onDirector: async () => {
5406
- const director = await multiAgentHost.promoteToDirector();
5407
- if (!director) return null;
5408
- for (const tool of director.tools(FLEET_ROSTER)) {
5920
+ const director2 = await multiAgentHost.promoteToDirector();
5921
+ if (!director2) return null;
5922
+ for (const tool of director2.tools(FLEET_ROSTER)) {
5409
5923
  toolRegistry.register(tool);
5410
5924
  }
5411
5925
  const mp = path13.join(fleetRootForPromotion, "fleet.json");
@@ -5477,7 +5991,10 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
5477
5991
  savedProviderCfg,
5478
5992
  resolvedProvider: resolvedProvider ?? void 0,
5479
5993
  getPickableProviders: () => buildPickableProviders(modelsRegistry, config),
5480
- switchProviderAndModel
5994
+ switchProviderAndModel,
5995
+ director: director ?? null,
5996
+ fleetRoster: FLEET_ROSTER,
5997
+ fleetStreamController
5481
5998
  });
5482
5999
  }
5483
6000
  async function promptRecovery(reader, renderer, abandoned, autoRecover) {