@wrongstack/cli 0.9.19 → 0.9.20

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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import * as path24 from 'path';
3
3
  import * as fsp3 from 'fs/promises';
4
- import { color, DefaultPathResolver, TOKENS, DefaultSystemPromptBuilder, makeAutonomyPromptContributor, ToolRegistry, createContextManagerTool, EventBus, SlashCommandRegistry, createDelegateTool, FLEET_ROSTER, createMcpControlTool, EternalAutonomyEngine, DefaultLogger, DefaultModelsRegistry, ProviderRegistry, InMemoryMetricsSink, wireMetricsToEvents, DefaultHealthRegistry, startMetricsServer, RecoveryLock, DefaultAttachmentStore, QueueStore, Context, loadTodosCheckpoint, attachTodosCheckpoint, loadDirectorState, loadPlan, createDefaultPipelines, AutoCompactionMiddleware, estimateRequestTokens, Agent, loadPlugins, FleetManager, makeDirectorSessionFactory, Director, makeFleetEmitTool, AutoApprovePermissionPolicy, PhaseStore, AutoPhasePlanner, PhaseGraphBuilder, WorktreeManager, PhaseOrchestrator, makeLLMClassifier, resolveWstackPaths, DefaultSecretVault, migratePlaintextSecrets, DefaultConfigLoader, DefaultSessionReader, DefaultSessionRewinder, DefaultSessionStore, atomicWrite, DefaultPluginAPI, makeAgentSubagentRunner, NULL_FLEET_BUS, buildChildEnv, formatContextWindowModeList, repairToolUseAdjacency, getContextWindowMode, resolveContextWindowPolicy, AGENTS_BY_PHASE, dispatchAgent, formatTodosList, SpecStore, TaskGraphStore, analyzeCriticalPath, getTemplate, listTemplates, templateToMarkdown, SpecParser, renderSpecAnalysis, AISpecBuilder, DefaultTaskStore, TaskTracker, renderProgress, renderTaskGraph, loadGoal, goalFilePath, summarizeUsage, saveGoal, emptyGoal, buildGoalPreamble, formatGoal, pendingBtwCount, setBtwNote, decryptConfigSecrets, encryptConfigSecrets as encryptConfigSecrets$1, InputBuilder, FsError, ERROR_CODES, SpecVersioning, ParallelEternalEngine, allServers as allServers$1 } from '@wrongstack/core';
4
+ import { color, DefaultPathResolver, TOKENS, DefaultSystemPromptBuilder, makeAutonomyPromptContributor, ToolRegistry, createContextManagerTool, EventBus, SlashCommandRegistry, createDelegateTool, FLEET_ROSTER, createMcpControlTool, EternalAutonomyEngine, DefaultLogger, DefaultModelsRegistry, runProviderWithRetry, ReplayLogStore, ReplayProviderRunner, ProviderRegistry, InMemoryMetricsSink, wireMetricsToEvents, DefaultHealthRegistry, startMetricsServer, RecoveryLock, DefaultAttachmentStore, QueueStore, Context, loadTodosCheckpoint, attachTodosCheckpoint, loadDirectorState, loadPlan, createDefaultPipelines, AutoCompactionMiddleware, estimateRequestTokens, Agent, loadPlugins, FleetManager, makeDirectorSessionFactory, Director, makeFleetEmitTool, makeFleetStatusTool, AutoApprovePermissionPolicy, PhaseStore, AutoPhasePlanner, PhaseGraphBuilder, WorktreeManager, PhaseOrchestrator, makeLLMClassifier, resolveWstackPaths, DefaultSecretVault, migratePlaintextSecrets, DefaultConfigLoader, DefaultSessionReader, ToolAuditLog, DefaultSessionRewinder, DefaultSessionStore, atomicWrite, DefaultPluginAPI, makeAgentSubagentRunner, NULL_FLEET_BUS, buildChildEnv, formatContextWindowModeList, repairToolUseAdjacency, getContextWindowMode, resolveContextWindowPolicy, AGENTS_BY_PHASE, dispatchAgent, formatTodosList, SpecStore, TaskGraphStore, analyzeCriticalPath, getTemplate, listTemplates, templateToMarkdown, SpecParser, renderSpecAnalysis, AISpecBuilder, DefaultTaskStore, TaskTracker, renderProgress, renderTaskGraph, SessionRecovery, loadGoal, goalFilePath, summarizeUsage, saveGoal, emptyGoal, buildGoalPreamble, formatGoal, pendingBtwCount, setBtwNote, decryptConfigSecrets, encryptConfigSecrets as encryptConfigSecrets$1, InputBuilder, FsError, ERROR_CODES, SpecVersioning, ParallelEternalEngine, allServers as allServers$1 } from '@wrongstack/core';
5
5
  import { createRequire } from 'module';
6
6
  import * as os3 from 'os';
7
7
  import os3__default from 'os';
@@ -380,12 +380,9 @@ function buildSddCommand(opts) {
380
380
  name: "sdd",
381
381
  description: "AI-driven SDD: /sdd [new|approve|execute|cancel|status|list|show|templates]",
382
382
  async run(args) {
383
- opts.context;
384
383
  if (!opts.paths) return { message: "SDD not available \u2014 paths not configured." };
385
384
  const specsDir = opts.paths.projectSpecs;
386
- const graphsDir = opts.paths.projectTaskGraphs;
387
385
  const specStore = new SpecStore({ baseDir: specsDir });
388
- new TaskGraphStore({ baseDir: graphsDir });
389
386
  const versioning = sddState.getVersioning();
390
387
  const [verb, ...rest] = args.trim().split(/\s+/);
391
388
  const restJoined = rest.join(" ").trim();
@@ -1024,8 +1021,8 @@ Start executing the tasks one by one.`
1024
1021
  return { message: lines2.join("\n") };
1025
1022
  }
1026
1023
  try {
1027
- const graphStore2 = new TaskGraphStore({ baseDir: opts.paths.projectTaskGraphs });
1028
- const stored = await graphStore2.load(graphId);
1024
+ const graphStore = new TaskGraphStore({ baseDir: opts.paths.projectTaskGraphs });
1025
+ const stored = await graphStore.load(graphId);
1029
1026
  if (stored) {
1030
1027
  return { message: renderTaskGraph(stored, { compact: false }) };
1031
1028
  }
@@ -1245,8 +1242,8 @@ ${lines.join("\n")}`
1245
1242
  return { message: "No task graph found. Generate tasks first." };
1246
1243
  }
1247
1244
  try {
1248
- const graphStore2 = new TaskGraphStore({ baseDir: opts.paths.projectTaskGraphs });
1249
- const graph = await graphStore2.load(graphId);
1245
+ const graphStore = new TaskGraphStore({ baseDir: opts.paths.projectTaskGraphs });
1246
+ const graph = await graphStore.load(graphId);
1250
1247
  if (!graph) {
1251
1248
  return { message: "Could not load task graph." };
1252
1249
  }
@@ -1659,7 +1656,7 @@ async function checkForUpdate(signal, homeFn) {
1659
1656
  outdated: isNewer(latest, current),
1660
1657
  checkFailed: false
1661
1658
  };
1662
- } catch (err) {
1659
+ } catch (_err) {
1663
1660
  if (aborted()) {
1664
1661
  return { current, latest: current, outdated: false, checkFailed: true };
1665
1662
  }
@@ -1980,7 +1977,7 @@ async function runWebUI(opts) {
1980
1977
  }
1981
1978
  }
1982
1979
  }
1983
- async function handleUserMessage(ws, client, content) {
1980
+ async function handleUserMessage(ws, _client, content) {
1984
1981
  if (abortController) {
1985
1982
  send(ws, {
1986
1983
  type: "error",
@@ -3553,8 +3550,85 @@ function buildLoadCommand(opts) {
3553
3550
  return {
3554
3551
  name: "resume",
3555
3552
  aliases: ["load", "sessions"],
3556
- description: "List recent sessions.",
3557
- async run() {
3553
+ description: "List recent sessions, show incomplete ones (--incomplete), or plan a recovery (--recover <id>).",
3554
+ async run(args) {
3555
+ const parts = args.split(/\s+/).filter(Boolean);
3556
+ const showIncomplete = parts.includes("--incomplete") || parts.includes("-i");
3557
+ const recoverIdx = parts.findIndex((p) => p === "--recover");
3558
+ const recoverTarget = recoverIdx >= 0 ? parts[recoverIdx + 1] : void 0;
3559
+ if (recoverTarget) {
3560
+ if (!opts.paths) {
3561
+ return { message: color.yellow("No paths configured \u2014 cannot build a recovery plan.") };
3562
+ }
3563
+ const recovery = new SessionRecovery(opts.paths.projectSessions);
3564
+ const plan = await recovery.recover(recoverTarget);
3565
+ if (!plan) {
3566
+ return {
3567
+ message: color.yellow(
3568
+ `No session log found for ${recoverTarget} (or it is empty).`
3569
+ )
3570
+ };
3571
+ }
3572
+ const lines2 = [
3573
+ color.bold(`Recovery plan for ${plan.sessionId}`),
3574
+ ` Stale: ${plan.stale ? color.yellow("yes") : color.green("no")}`
3575
+ ];
3576
+ if (plan.context) {
3577
+ lines2.push(` Last in-flight context: ${color.cyan(plan.context)}`);
3578
+ }
3579
+ if (plan.lastCheckpoint && plan.lastCheckpoint.type === "checkpoint") {
3580
+ const cp = plan.lastCheckpoint;
3581
+ lines2.push(
3582
+ ` Last checkpoint: promptIndex=${cp.promptIndex} preview=${color.dim(`"${cp.promptPreview}"`)} at ${color.dim(cp.ts)}`
3583
+ );
3584
+ } else {
3585
+ lines2.push(` Last checkpoint: ${color.dim("(none \u2014 full re-execution)")}`);
3586
+ }
3587
+ lines2.push(
3588
+ ` Pending events: ${plan.pendingEvents.length} (the work that would re-run on resume)`
3589
+ );
3590
+ if (plan.pendingEvents.length > 0) {
3591
+ const summary = summarizePending(plan.pendingEvents);
3592
+ lines2.push(...summary);
3593
+ }
3594
+ lines2.push("");
3595
+ lines2.push(
3596
+ color.dim(
3597
+ plan.stale ? " This session crashed mid-iteration. The full re-execution kernel is coming in a follow-up; for now use this plan to decide whether to start fresh." : " This session ended cleanly; the plan above describes the most recent turn(s) for context."
3598
+ )
3599
+ );
3600
+ return { message: lines2.join("\n") };
3601
+ }
3602
+ if (showIncomplete) {
3603
+ if (!opts.paths) {
3604
+ return { message: color.yellow("No paths configured \u2014 cannot scan for incomplete sessions.") };
3605
+ }
3606
+ const recovery = new SessionRecovery(opts.paths.projectSessions);
3607
+ const resumable = await recovery.listResumable();
3608
+ if (resumable.length === 0) {
3609
+ return {
3610
+ message: color.dim("No incomplete sessions. (Every recorded run ended cleanly.)")
3611
+ };
3612
+ }
3613
+ const lines2 = [
3614
+ color.bold(`${resumable.length} incomplete session(s)`),
3615
+ color.dim(" (process died mid-iteration; the last event was in_flight_start)"),
3616
+ ""
3617
+ ];
3618
+ for (const s of resumable) {
3619
+ const t = color.dim(s.lastEventTs.slice(0, 19).replace("T", " "));
3620
+ lines2.push(
3621
+ ` ${color.cyan(s.sessionId)} ${t} ${color.dim(`${s.eventCount} events`)} ${s.context}`
3622
+ );
3623
+ }
3624
+ lines2.push("");
3625
+ lines2.push(
3626
+ color.dim(
3627
+ " Resuming re-executes the in-flight work from the last checkpoint. Full resume is coming in a follow-up; for now use this list to identify crashes and decide whether to restart fresh."
3628
+ )
3629
+ );
3630
+ return { message: lines2.join("\n") };
3631
+ }
3558
3632
  if (!opts.sessionStore) return { message: "No session store configured." };
3559
3633
  const list = await opts.sessionStore.list(10);
3560
3634
  if (list.length === 0) return { message: "No saved sessions." };
@@ -3565,7 +3639,8 @@ function buildLoadCommand(opts) {
3565
3639
  ${lines.join("\n")}
3566
3640
 
3567
3641
  ${color.dim(`Resume one with: wstack resume ${list[0]?.id ?? "<id>"}
3568
- `)}`;
3642
+ `)}
3643
+ ${color.dim("Tip: /resume --incomplete \u2014 list sessions that crashed mid-iteration")}`;
3569
3644
  opts.renderer.write(msg);
3570
3645
  return { message: msg };
3571
3646
  }
@@ -3589,6 +3664,47 @@ function buildExitCommand(opts) {
3589
3664
  }
3590
3665
  };
3591
3666
  }
3667
+ function summarizePending(events) {
3668
+ const lines = [];
3669
+ const cap = 12;
3670
+ for (const ev of events.slice(-cap)) {
3671
+ const t = "ts" in ev ? color.dim(String(ev.ts).slice(11, 19)) : color.dim("--:--:--");
3672
+ const kind = color.cyan(String(ev.type).padEnd(18));
3673
+ lines.push(` ${t} ${kind} ${summariseEvent(ev)}`);
3674
+ }
3675
+ if (events.length > cap) {
3676
+ lines.push(color.dim(` \u2026 and ${events.length - cap} more`));
3677
+ }
3678
+ return lines;
3679
+ }
3680
+ function summariseEvent(ev) {
3681
+ switch (ev.type) {
3682
+ case "user_input": {
3683
+ const text = "text" in ev && typeof ev.text === "string" ? ev.text : Array.isArray(ev.content) ? "\u2026" : "";
3684
+ return color.dim(text.length > 60 ? text.slice(0, 59) + "\u2026" : text);
3685
+ }
3686
+ case "llm_response":
3687
+ return color.dim("(model reply)");
3688
+ case "tool_use":
3689
+ return color.dim(`name=${ev.name ?? "?"}`);
3690
+ case "tool_result":
3691
+ return color.dim(`name=${ev.name ?? "?"}`);
3692
+ case "in_flight_start":
3693
+ return color.dim(`context="${ev.context ?? ""}"`);
3694
+ case "in_flight_end":
3695
+ return color.dim(`reason=${ev.reason ?? "?"}`);
3696
+ case "checkpoint":
3697
+ return color.dim(
3698
+ `promptIndex=${ev.promptIndex ?? "?"}`
3699
+ );
3700
+ case "compaction":
3701
+ return color.dim("(compaction)");
3702
+ case "error":
3703
+ return color.red(String(ev.message ?? ""));
3704
+ default:
3705
+ return color.dim("\u2026");
3706
+ }
3707
+ }
3592
3708
 
3593
3709
  // src/slash-commands/spawn-agents.ts
3594
3710
  function buildSpawnCommand(opts) {
@@ -4173,8 +4289,48 @@ ${lines.join("\n")}`;
4173
4289
  }
4174
4290
  };
4175
4291
  }
4176
-
4177
- // src/slash-commands/mode.ts
4292
+ async function runModePicker(modeStore, reader) {
4293
+ const modes = await modeStore.listModes();
4294
+ const active = await modeStore.getActiveMode();
4295
+ const activeIdx = modes.findIndex((m) => m.id === active?.id);
4296
+ let cursor = activeIdx >= 0 ? activeIdx : 0;
4297
+ const render = (currentCursor) => {
4298
+ const lines = [];
4299
+ lines.push(`
4300
+ ${color.bold(color.amber("WrongStack") + color.dim(" \u2014 Mode Selection"))}
4301
+ `);
4302
+ lines.push(color.dim(" \u2191\u2193 navigate Enter select q quit\n"));
4303
+ lines.push("");
4304
+ for (let i = 0; i < modes.length; i++) {
4305
+ const m = modes[i];
4306
+ const mark = m.id === active?.id ? color.green(" [active]") : "";
4307
+ const prefix = i === currentCursor ? color.bold("\u276F ") : " ";
4308
+ const name = i === currentCursor ? color.bold(m.name) : m.name;
4309
+ lines.push(` ${prefix}${name} ${color.dim(m.description)}${mark}`);
4310
+ }
4311
+ lines.push("");
4312
+ return lines.join("\n");
4313
+ };
4314
+ process.stdout.write(render(cursor));
4315
+ const options = [
4316
+ { key: "\x1B[A", label: "\u2191", value: "up" },
4317
+ { key: "\x1B[B", label: "\u2193", value: "down" },
4318
+ { key: "\r", label: "Enter", value: "enter" },
4319
+ { key: "q", label: "q", value: "quit" }
4320
+ ];
4321
+ while (true) {
4322
+ const answer = await reader.readKey("", options);
4323
+ if (answer === "quit") return void 0;
4324
+ if (answer === "enter") return modes[cursor];
4325
+ if (answer === "up") {
4326
+ cursor = cursor > 0 ? cursor - 1 : modes.length - 1;
4327
+ } else if (answer === "down") {
4328
+ cursor = cursor < modes.length - 1 ? cursor + 1 : 0;
4329
+ }
4330
+ process.stdout.write("\x1B[J");
4331
+ process.stdout.write(render(cursor));
4332
+ }
4333
+ }
4178
4334
  function buildModeCommand(opts) {
4179
4335
  return {
4180
4336
  name: "mode",
@@ -4194,7 +4350,7 @@ function buildModeCommand(opts) {
4194
4350
  " /mode brief Switch to brief mode",
4195
4351
  " /mode teach Switch to teach mode"
4196
4352
  ].join("\n"),
4197
- async run(args) {
4353
+ async run(args, _ctx) {
4198
4354
  const modeStore = opts.modeStore;
4199
4355
  if (!modeStore) {
4200
4356
  return { message: "Mode store not available in this context." };
@@ -4202,6 +4358,17 @@ function buildModeCommand(opts) {
4202
4358
  const modes = await modeStore.listModes();
4203
4359
  const active = await modeStore.getActiveMode();
4204
4360
  if (!args.trim()) {
4361
+ if (opts.inputReader) {
4362
+ const selected = await runModePicker(modeStore, opts.inputReader);
4363
+ if (!selected) {
4364
+ return { message: "Mode selection cancelled." };
4365
+ }
4366
+ await modeStore.setActiveMode(selected.id);
4367
+ return {
4368
+ message: `Switched to "${selected.name}" mode.
4369
+ ${selected.description}`
4370
+ };
4371
+ }
4205
4372
  const lines = [`Current mode: ${active?.name ?? "none"}`, "", "Available modes:"];
4206
4373
  for (const m of modes) {
4207
4374
  const mark = m.id === active?.id ? " [active]" : "";
@@ -5306,6 +5473,195 @@ function buildAutoPhaseCommand(opts) {
5306
5473
  }
5307
5474
  };
5308
5475
  }
5476
+ function buildCollabCommand(opts) {
5477
+ return {
5478
+ name: "collab",
5479
+ description: "Live collaboration helpers (status / invite / history).",
5480
+ async run(args, ctx) {
5481
+ const parts = args.split(/\s+/).filter(Boolean);
5482
+ const sub = (parts[0] ?? "status").toLowerCase();
5483
+ switch (sub) {
5484
+ case "status":
5485
+ return statusCommand(ctx.session?.id);
5486
+ case "invite":
5487
+ return inviteCommand(ctx.session?.id);
5488
+ case "history":
5489
+ return historyCommand(opts, ctx.session?.id, parts.slice(1));
5490
+ case "annotations":
5491
+ case "notes":
5492
+ return annotationsCommand(opts, ctx.session?.id);
5493
+ case "help":
5494
+ case "--help":
5495
+ case "-h":
5496
+ return helpCommand();
5497
+ default:
5498
+ return {
5499
+ message: color.yellow(
5500
+ `Unknown subcommand: ${sub}. Try: /collab status | invite | history | annotations`
5501
+ )
5502
+ };
5503
+ }
5504
+ }
5505
+ };
5506
+ }
5507
+ function statusCommand(sessionId) {
5508
+ const lines = [];
5509
+ lines.push(color.bold("Live collaboration"));
5510
+ lines.push(` Session: ${color.cyan(sessionId ?? "(none)")}`);
5511
+ lines.push(` Observers: ${color.dim("unknown (check webui)")}`);
5512
+ lines.push("");
5513
+ lines.push(
5514
+ color.dim(
5515
+ " Tip: /collab invite \u2014 print a webui join link for a teammate"
5516
+ )
5517
+ );
5518
+ lines.push(
5519
+ color.dim(" /collab history 20 \u2014 show the last 20 events of this session")
5520
+ );
5521
+ return { message: lines.join("\n") };
5522
+ }
5523
+ function inviteCommand(sessionId) {
5524
+ if (!sessionId) {
5525
+ return { message: color.yellow("No active session to invite to.") };
5526
+ }
5527
+ const url = `http://127.0.0.1:3457/?session=${encodeURIComponent(sessionId)}`;
5528
+ const lines = [
5529
+ color.bold("Live-collaboration invite"),
5530
+ ` Session ID: ${color.cyan(sessionId)}`,
5531
+ ` Share this URL with a teammate (they must be able to reach your machine):`,
5532
+ ` ${color.cyan(url)}`,
5533
+ "",
5534
+ color.dim(
5535
+ ' The teammate opens the URL in their browser, then clicks "Join as observer"'
5536
+ ),
5537
+ color.dim(" on the panel that appears above the chat. They will see a live mirror"),
5538
+ color.dim(" of tool calls, iterations, and subagent spawns \u2014 read-only."),
5539
+ "",
5540
+ color.dim(" Reminder: expose the port with: pnpm --filter @wrongstack/webui dev")
5541
+ ];
5542
+ return { message: lines.join("\n") };
5543
+ }
5544
+ async function historyCommand(opts, sessionId, args) {
5545
+ const limit = Math.max(1, Math.min(200, Number.parseInt(args[0] ?? "20", 10) || 20));
5546
+ if (!sessionId) return { message: color.yellow("No active session.") };
5547
+ if (!opts.sessionStore) {
5548
+ return { message: color.yellow("No session store configured.") };
5549
+ }
5550
+ const { DefaultSessionReader: DefaultSessionReader2 } = await import('@wrongstack/core');
5551
+ const reader = new DefaultSessionReader2({ store: opts.sessionStore });
5552
+ const events = [];
5553
+ try {
5554
+ for await (const ev of reader.replay(sessionId)) {
5555
+ events.push(ev);
5556
+ }
5557
+ } catch (err) {
5558
+ return {
5559
+ message: color.yellow(
5560
+ `Failed to read session: ${err instanceof Error ? err.message : String(err)}`
5561
+ )
5562
+ };
5563
+ }
5564
+ const tail = events.slice(-limit);
5565
+ if (tail.length === 0) {
5566
+ return { message: color.dim("No events recorded yet for this session.") };
5567
+ }
5568
+ const lines = [
5569
+ color.bold(`Last ${tail.length} events of ${sessionId}`),
5570
+ ""
5571
+ ];
5572
+ for (const raw of tail) {
5573
+ const ev = raw;
5574
+ const t = ev.ts ? color.dim(ev.ts.slice(11, 19)) : color.dim("--:--:--");
5575
+ const kind = color.cyan((ev.type ?? "unknown").padEnd(16));
5576
+ const summary = summarise(ev);
5577
+ lines.push(` ${t} ${kind} ${summary}`);
5578
+ }
5579
+ return { message: lines.join("\n") };
5580
+ }
5581
+ async function annotationsCommand(opts, sessionId) {
5582
+ if (!sessionId) return { message: color.yellow("No active session.") };
5583
+ if (!opts.sessionStore) {
5584
+ return { message: color.yellow("No session store configured.") };
5585
+ }
5586
+ const { DefaultSessionReader: DefaultSessionReader2, AnnotationsStore } = await import('@wrongstack/core');
5587
+ new DefaultSessionReader2({ store: opts.sessionStore });
5588
+ const storeDir = opts.sessionStore.dir;
5589
+ if (!storeDir) {
5590
+ return {
5591
+ message: color.yellow(
5592
+ "Annotations view needs the session store dir; not exposed in this build."
5593
+ )
5594
+ };
5595
+ }
5596
+ const annotations = new AnnotationsStore({ dir: storeDir });
5597
+ const open = await annotations.listOpen(sessionId);
5598
+ if (open.length === 0) {
5599
+ return {
5600
+ message: color.dim("No open annotations for this session.")
5601
+ };
5602
+ }
5603
+ const lines = [
5604
+ color.bold(`${open.length} open annotation(s) for ${sessionId}`),
5605
+ ""
5606
+ ];
5607
+ for (const a of open) {
5608
+ const t = color.dim(a.createdAt.slice(11, 19));
5609
+ const author = color.cyan(a.authorId.slice(0, 8));
5610
+ const idx = color.dim(`#${a.atEventIndex}`);
5611
+ lines.push(` ${t} ${idx} ${author} ${a.text}`);
5612
+ }
5613
+ return { message: lines.join("\n") };
5614
+ }
5615
+ function summarise(ev) {
5616
+ switch (ev["type"]) {
5617
+ case "user_input": {
5618
+ const text = typeof ev["text"] === "string" ? ev["text"] : "";
5619
+ return color.dim(truncate(text, 80));
5620
+ }
5621
+ case "llm_response": {
5622
+ const text = typeof ev["text"] === "string" ? ev["text"] : "";
5623
+ return color.dim(truncate(text, 80));
5624
+ }
5625
+ case "tool_result": {
5626
+ const name = String(ev["name"] ?? "?");
5627
+ const ok = ev["ok"] === false ? color.red("(failed)") : color.green("(ok)");
5628
+ return `${name} ${ok}`;
5629
+ }
5630
+ case "compaction": {
5631
+ const before = ev["before"];
5632
+ const after = ev["after"];
5633
+ return `tokens ${before}\u2192${after}`;
5634
+ }
5635
+ case "error": {
5636
+ return color.red(String(ev["message"] ?? "(no message)"));
5637
+ }
5638
+ default: {
5639
+ return color.dim(JSON.stringify(ev).slice(0, 80));
5640
+ }
5641
+ }
5642
+ }
5643
+ function truncate(s, n) {
5644
+ if (s.length <= n) return s;
5645
+ return `${s.slice(0, n - 1)}\u2026`;
5646
+ }
5647
+ function helpCommand() {
5648
+ const lines = [
5649
+ color.bold("/collab \u2014 live collaboration (read-only observers)"),
5650
+ "",
5651
+ "Subcommands:",
5652
+ ` ${color.cyan("status")} Show current session's collaboration status`,
5653
+ ` ${color.cyan("invite")} Print a webui join link for a teammate`,
5654
+ ` ${color.cyan("history [N]")} Show the last N events of this session (default 20, max 200)`,
5655
+ ` ${color.cyan("annotations")} List open (unresolved) annotations from webui annotators`,
5656
+ ` ${color.cyan("help")} Show this help`,
5657
+ "",
5658
+ color.dim(
5659
+ " The observer flow itself lives in the webui: open the URL printed"
5660
+ ),
5661
+ color.dim(' by `invite` and click "Join as observer" on the panel above the chat.')
5662
+ ];
5663
+ return { message: lines.join("\n") };
5664
+ }
5309
5665
 
5310
5666
  // src/slash-commands/worktree.ts
5311
5667
  function buildWorktreeCommand(opts) {
@@ -5524,6 +5880,7 @@ function buildBuiltinSlashCommands(opts) {
5524
5880
  buildAutoPhaseCommand(opts),
5525
5881
  buildWorktreeCommand(opts),
5526
5882
  buildSettingsCommand(opts),
5883
+ buildCollabCommand(opts),
5527
5884
  buildStatuslineCommand({
5528
5885
  cwd: opts.cwd,
5529
5886
  hiddenItems: opts.statuslineHiddenItems ?? [],
@@ -7848,9 +8205,9 @@ var updateCmd = async (args, deps) => {
7848
8205
  cwd,
7849
8206
  stdio: "pipe"
7850
8207
  });
7851
- let stderr = "";
8208
+ let _stderr = "";
7852
8209
  child.stderr?.on("data", (d) => {
7853
- stderr += d;
8210
+ _stderr += d;
7854
8211
  });
7855
8212
  child.on("error", reject);
7856
8213
  child.on("close", (code) => resolve4({ code: code ?? 0 }));
@@ -9084,6 +9441,167 @@ ${result.errors.length} error(s):
9084
9441
  return 1;
9085
9442
  }
9086
9443
  };
9444
+ var replayCmd = async (args, deps) => {
9445
+ const wpaths = resolveWstackPaths({
9446
+ projectRoot: deps.projectRoot,
9447
+ userHome: deps.userHome
9448
+ });
9449
+ const store = new ReplayLogStore({ dir: wpaths.projectSessions });
9450
+ if (args[0] === "--list" || args[0] === "-l") {
9451
+ const all = await store.list();
9452
+ if (all.length === 0) {
9453
+ deps.renderer.write(
9454
+ color.dim("No replay logs recorded yet. Run with --record to start one.") + "\n"
9455
+ );
9456
+ return 0;
9457
+ }
9458
+ const lines2 = [
9459
+ color.bold(`${all.length} replay log(s)`),
9460
+ color.dim(" Sorted by session id. Each entry has at least one recorded request/response."),
9461
+ ""
9462
+ ];
9463
+ for (const r of all) {
9464
+ lines2.push(
9465
+ ` ${color.cyan(r.sessionId)} ${color.dim(`${r.entryCount} entries`)} ${color.dim(r.path)}`
9466
+ );
9467
+ }
9468
+ lines2.push("");
9469
+ lines2.push(
9470
+ color.dim(
9471
+ " Inspect: wstack replay <sessionId>"
9472
+ )
9473
+ );
9474
+ lines2.push(
9475
+ color.dim(
9476
+ " Replay: wstack --replay <sessionId>"
9477
+ )
9478
+ );
9479
+ deps.renderer.write(lines2.join("\n") + "\n");
9480
+ return 0;
9481
+ }
9482
+ const sessionId = args[0];
9483
+ if (!sessionId) {
9484
+ deps.renderer.writeError(
9485
+ "Usage: wstack replay <sessionId>\n wstack replay --list\n\nLists recorded provider responses for the session. To actually\nre-run the agent with frozen responses, use:\n wstack --replay " + color.cyan("<sessionId>") + "\n wstack --record # start a fresh recording\n"
9486
+ );
9487
+ return 1;
9488
+ }
9489
+ const entries = await store.load(sessionId);
9490
+ if (entries.length === 0) {
9491
+ deps.renderer.write(
9492
+ color.yellow(
9493
+ `No replay entries recorded for session ${sessionId}.
9494
+ Run a session with --record first, then --replay to re-execute it.`
9495
+ ) + "\n"
9496
+ );
9497
+ return 0;
9498
+ }
9499
+ const lines = [
9500
+ color.bold(`Replay log for ${sessionId}`),
9501
+ ` ${entries.length} recorded request/response pair(s)`,
9502
+ ` Log file: ${color.dim(`${sessionId}.replay.jsonl`)}`,
9503
+ "",
9504
+ "To replay this session deterministically:",
9505
+ ` ${color.cyan(`wstack --replay ${sessionId}`)}`,
9506
+ "",
9507
+ "Recorded timestamps (oldest first):"
9508
+ ];
9509
+ for (const e of entries.slice(-10)) {
9510
+ lines.push(
9511
+ ` ${color.dim(e.ts.slice(11, 19))} ${e.hash.slice(0, 16)}\u2026 ${e.request.model}`
9512
+ );
9513
+ }
9514
+ if (entries.length > 10) {
9515
+ lines.push(color.dim(` \u2026 and ${entries.length - 10} more`));
9516
+ }
9517
+ deps.renderer.write(lines.join("\n") + "\n");
9518
+ return 0;
9519
+ };
9520
+ var auditCmd = async (args, deps) => {
9521
+ const wpaths = resolveWstackPaths({
9522
+ projectRoot: deps.projectRoot,
9523
+ userHome: deps.userHome
9524
+ });
9525
+ const log = new ToolAuditLog({ dir: wpaths.projectSessions });
9526
+ if (args[0] === "--list" || args[0] === "-l") {
9527
+ return listAudits(log, wpaths.projectSessions, deps);
9528
+ }
9529
+ const sessionId = args[0];
9530
+ if (!sessionId) {
9531
+ deps.renderer.writeError(
9532
+ "Usage: wstack audit <sessionId>\n wstack audit --list\n\nInspects a session's tamper-evident tool audit log. Each entry\nis chained to the previous via SHA-256; any post-hoc modification\nbreaks the chain. Use this to verify a session was not tampered\nwith after recording.\n"
9533
+ );
9534
+ return 1;
9535
+ }
9536
+ const entries = await log.load(sessionId);
9537
+ if (entries.length === 0) {
9538
+ deps.renderer.write(
9539
+ color.yellow(
9540
+ `No audit entries for session ${sessionId}. (The audit log is written when tools are recorded \u2014 check that the session ran with audit enabled.)`
9541
+ ) + "\n"
9542
+ );
9543
+ return 0;
9544
+ }
9545
+ const verify = await log.verify(sessionId);
9546
+ const lines = [
9547
+ color.bold(`Audit log for ${sessionId}`),
9548
+ verify.ok ? ` ${color.green("\u2713 Verified")} ${entries.length} entries, chain intact` : ` ${color.red("\u2717 BROKEN")} at entry ${verify.brokenAt}: ${verify.reason}`,
9549
+ "",
9550
+ " Entries (oldest first):"
9551
+ ];
9552
+ const cap = 12;
9553
+ for (const e of entries.slice(0, cap)) {
9554
+ const t = color.dim(e.ts.slice(11, 19));
9555
+ const idx = color.dim(`#${e.index}`.padStart(4));
9556
+ const tool = e.toolName;
9557
+ const err = e.isError ? color.red("(error)") : "";
9558
+ lines.push(
9559
+ ` ${t} ${idx} ${color.cyan(tool.padEnd(14))} ${err} ${color.dim(e.hash.slice(0, 12) + "\u2026")}`
9560
+ );
9561
+ }
9562
+ if (entries.length > cap) {
9563
+ lines.push(color.dim(` \u2026 and ${entries.length - cap} more`));
9564
+ }
9565
+ deps.renderer.write(lines.join("\n") + "\n");
9566
+ return verify.ok ? 0 : 1;
9567
+ };
9568
+ async function listAudits(log, dir, deps) {
9569
+ const fs22 = await import('fs/promises');
9570
+ let entries;
9571
+ try {
9572
+ entries = await fs22.readdir(dir);
9573
+ } catch {
9574
+ deps.renderer.write(
9575
+ color.dim(`No sessions dir found at ${dir}. Run a session first.`) + "\n"
9576
+ );
9577
+ return 0;
9578
+ }
9579
+ const out = [];
9580
+ for (const name of entries) {
9581
+ if (!name.endsWith(".audit.jsonl")) continue;
9582
+ const sessionId = name.slice(0, -".audit.jsonl".length);
9583
+ const all = await log.load(sessionId);
9584
+ const verify = await log.verify(sessionId);
9585
+ out.push({ sessionId, entryCount: all.length, ok: verify.ok });
9586
+ }
9587
+ out.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
9588
+ if (out.length === 0) {
9589
+ deps.renderer.write(color.dim("No audit logs recorded yet.") + "\n");
9590
+ return 0;
9591
+ }
9592
+ const lines = [
9593
+ color.bold(`${out.length} audit log(s)`),
9594
+ ""
9595
+ ];
9596
+ for (const r of out) {
9597
+ const status = r.ok ? color.green("\u2713 intact") : color.red("\u2717 broken");
9598
+ lines.push(
9599
+ ` ${color.cyan(r.sessionId)} ${color.dim(`${r.entryCount} entries`)} ${status}`
9600
+ );
9601
+ }
9602
+ deps.renderer.write(lines.join("\n") + "\n");
9603
+ return 0;
9604
+ }
9087
9605
  var toolsCmd = async (_args, deps) => {
9088
9606
  const reg = deps.toolRegistry;
9089
9607
  if (!reg) return 0;
@@ -9150,6 +9668,8 @@ var subcommands = {
9150
9668
  sessions: sessionsCmd,
9151
9669
  config: configCmd,
9152
9670
  rewind: rewindCmd,
9671
+ replay: replayCmd,
9672
+ audit: auditCmd,
9153
9673
  tools: toolsCmd,
9154
9674
  skills: skillsCmd,
9155
9675
  providers: providersCmd,
@@ -9186,22 +9706,22 @@ function fmtDuration(ms) {
9186
9706
  const remMin = m - h * 60;
9187
9707
  return `${h}h${remMin}m`;
9188
9708
  }
9189
- function fmtTaskResultLine(r, color37) {
9709
+ function fmtTaskResultLine(r, color41) {
9190
9710
  const stats = `${r.iterations}it ${r.toolCalls}tc ${fmtDuration(r.durationMs)}`;
9191
9711
  const errMsg = typeof r.error === "string" ? r.error : r.error?.message;
9192
9712
  const errKind = typeof r.error === "object" ? r.error?.kind : void 0;
9193
9713
  const errTail = errMsg ? ` \u2014 ${errMsg.replace(/\s+/g, " ").slice(0, 80)}${errMsg.length > 80 ? "\u2026" : ""}` : "";
9194
- const errKindChip = errKind ? color37.dim(` [${errKind}]`) : "";
9195
- const errSnip = errMsg || errKind ? `${errKindChip}${color37.dim(errTail)}` : "";
9714
+ const errKindChip = errKind ? color41.dim(` [${errKind}]`) : "";
9715
+ const errSnip = errMsg || errKind ? `${errKindChip}${color41.dim(errTail)}` : "";
9196
9716
  switch (r.status) {
9197
9717
  case "success":
9198
- return { mark: color37.green("\u2713"), stats, tail: "" };
9718
+ return { mark: color41.green("\u2713"), stats, tail: "" };
9199
9719
  case "timeout":
9200
- return { mark: color37.yellow("\u23F1"), stats: `${color37.yellow("timeout")} ${stats}`, tail: errSnip };
9720
+ return { mark: color41.yellow("\u23F1"), stats: `${color41.yellow("timeout")} ${stats}`, tail: errSnip };
9201
9721
  case "stopped":
9202
- return { mark: color37.dim("\u2298"), stats: `${color37.dim("stopped")} ${stats}`, tail: errSnip };
9722
+ return { mark: color41.dim("\u2298"), stats: `${color41.dim("stopped")} ${stats}`, tail: errSnip };
9203
9723
  case "failed":
9204
- return { mark: color37.red("\u2717"), stats: `${color37.red("failed")} ${stats}`, tail: errSnip };
9724
+ return { mark: color41.red("\u2717"), stats: `${color41.red("failed")} ${stats}`, tail: errSnip };
9205
9725
  }
9206
9726
  }
9207
9727
 
@@ -9691,7 +10211,7 @@ async function runRepl(opts) {
9691
10211
  );
9692
10212
  interrupts = 0;
9693
10213
  try {
9694
- const ok = await engine.runOneIteration();
10214
+ await engine.runOneIteration();
9695
10215
  const afterGoal = await loadGoalSafe(opts);
9696
10216
  const last = afterGoal?.journal[afterGoal.journal.length - 1];
9697
10217
  if (coord) {
@@ -9805,7 +10325,7 @@ ${color.dim(taskList2)}
9805
10325
  }
9806
10326
  }
9807
10327
  }
9808
- } catch (runErr) {
10328
+ } catch (_runErr) {
9809
10329
  opts.renderer.writeWarning("AI auto-trigger failed. You can continue manually.");
9810
10330
  }
9811
10331
  }
@@ -10505,7 +11025,7 @@ async function execute(deps) {
10505
11025
  fleetStatusLine?.stop();
10506
11026
  try {
10507
11027
  stats.render(renderer);
10508
- } catch (err) {
11028
+ } catch (_err) {
10509
11029
  }
10510
11030
  await Promise.resolve(detachTodosCheckpoint?.()).catch(() => void 0);
10511
11031
  await mcpRegistry.stopAll();
@@ -10535,6 +11055,10 @@ var MultiAgentHost = class {
10535
11055
  * critic.evaluation) onto the fleet bus without needing the tool registered
10536
11056
  * in the host's ToolRegistry. */
10537
11057
  fleetEmitTool;
11058
+ /** Own FleetStatusTool — created in buildDirector() so subagents in director
11059
+ * mode can read fleet state (subagent statuses, task progress) without the
11060
+ * tool being in the host's ToolRegistry. */
11061
+ fleetStatusTool;
10538
11062
  /** Lazily built alongside the director — produces per-subagent JSONL
10539
11063
  * writers under `<sessionsRoot>/<runId>/`. Null without sessionsRoot. */
10540
11064
  sessionFactory;
@@ -10571,7 +11095,7 @@ var MultiAgentHost = class {
10571
11095
  getDirector() {
10572
11096
  return this.director;
10573
11097
  }
10574
- async ensureCoordinator(config) {
11098
+ async ensureCoordinator(_config) {
10575
11099
  await this.buildDirector();
10576
11100
  }
10577
11101
  async buildDirector() {
@@ -10638,6 +11162,15 @@ var MultiAgentHost = class {
10638
11162
  totalExtensions: payload.totalExtensions
10639
11163
  });
10640
11164
  });
11165
+ this.director.fleet.filter("ctx.pct", (e) => {
11166
+ const payload = e.payload;
11167
+ this.deps.events.emit("subagent.ctx_pct", {
11168
+ subagentId: e.subagentId,
11169
+ load: payload.load,
11170
+ tokens: payload.tokens,
11171
+ maxContext: payload.maxContext
11172
+ });
11173
+ });
10641
11174
  this.getCoordinator().on(
10642
11175
  "task.assigned",
10643
11176
  ({
@@ -10652,6 +11185,7 @@ var MultiAgentHost = class {
10652
11185
  }
10653
11186
  );
10654
11187
  this.fleetEmitTool = makeFleetEmitTool(this.director);
11188
+ this.fleetStatusTool = makeFleetStatusTool(this.director);
10655
11189
  const runner = await this.buildSubagentRunner(config);
10656
11190
  this.getCoordinator().setRunner(runner);
10657
11191
  }
@@ -10709,11 +11243,17 @@ var MultiAgentHost = class {
10709
11243
  }
10710
11244
  const tools = subCfg.tools ? [...subCfg.tools] : void 0;
10711
11245
  let injectedFleetEmit;
10712
- if (tools?.includes("fleet_emit")) {
10713
- const fleetTool = this.fleetEmitTool;
10714
- if (fleetTool) {
10715
- tools.splice(tools.indexOf("fleet_emit"), 1);
10716
- injectedFleetEmit = fleetTool;
11246
+ let injectedFleetStatus;
11247
+ if (tools) {
11248
+ const emitIdx = tools.indexOf("fleet_emit");
11249
+ if (emitIdx >= 0 && this.fleetEmitTool) {
11250
+ tools.splice(emitIdx, 1);
11251
+ injectedFleetEmit = this.fleetEmitTool;
11252
+ }
11253
+ const statusIdx = tools.indexOf("fleet_status");
11254
+ if (statusIdx >= 0 && this.fleetStatusTool) {
11255
+ tools.splice(statusIdx, 1);
11256
+ injectedFleetStatus = this.fleetStatusTool;
10717
11257
  }
10718
11258
  }
10719
11259
  const ctx = new Context({
@@ -10729,6 +11269,7 @@ var MultiAgentHost = class {
10729
11269
  });
10730
11270
  const baseRegistry = this.subagentToolRegistry(tools);
10731
11271
  if (injectedFleetEmit) baseRegistry.register(injectedFleetEmit);
11272
+ if (injectedFleetStatus) baseRegistry.register(injectedFleetStatus);
10732
11273
  const toolExecutor = new ToolExecutor(baseRegistry, {
10733
11274
  permissionPolicy: new AutoApprovePermissionPolicy(),
10734
11275
  secretScrubber: this.deps.secretScrubber,
@@ -11140,9 +11681,9 @@ function makeConfirmAwaiter(reader) {
11140
11681
  function stringifyInput(input) {
11141
11682
  if (!input || typeof input !== "object") return "";
11142
11683
  const obj = input;
11143
- return Object.entries(obj).filter(([k]) => k !== "content" && k !== "new_string").map(([k, v]) => `${k}: ${truncate(JSON.stringify(v), 80)}`).join(" ");
11684
+ return Object.entries(obj).filter(([k]) => k !== "content" && k !== "new_string").map(([k, v]) => `${k}: ${truncate2(JSON.stringify(v), 80)}`).join(" ");
11144
11685
  }
11145
- function truncate(s, max) {
11686
+ function truncate2(s, max) {
11146
11687
  return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
11147
11688
  }
11148
11689
  function hasDiff(input) {
@@ -11150,6 +11691,26 @@ function hasDiff(input) {
11150
11691
  input && typeof input === "object" && "diff" in input
11151
11692
  );
11152
11693
  }
11694
+ function bindReplayToContainer(opts) {
11695
+ const { container, wpaths, sessionId, mode, logger } = opts;
11696
+ if (!opts.container.has(TOKENS.ProviderRunner)) {
11697
+ container.bind(TOKENS.ProviderRunner, () => ({
11698
+ run: (o) => runProviderWithRetry(o)
11699
+ }));
11700
+ }
11701
+ const inner = container.resolve(TOKENS.ProviderRunner);
11702
+ const log = new ReplayLogStore({ dir: wpaths.projectSessions });
11703
+ const wrapped = new ReplayProviderRunner(inner, {
11704
+ log,
11705
+ sessionId,
11706
+ mode,
11707
+ logger: logger ? {
11708
+ debug: (m) => logger.debug?.(m),
11709
+ warn: (m) => logger.warn?.(m)
11710
+ } : void 0
11711
+ });
11712
+ container.bind(TOKENS.ProviderRunner, () => wrapped);
11713
+ }
11153
11714
  var SessionStats = class {
11154
11715
  tokenCounter;
11155
11716
  startedAt = Date.now();
@@ -11628,7 +12189,6 @@ async function setupCompaction(params) {
11628
12189
  function createAgent(params) {
11629
12190
  const secretScrubber = params.container.resolve(TOKENS.SecretScrubber);
11630
12191
  const renderer = params.container.has(TOKENS.Renderer) ? params.container.resolve(TOKENS.Renderer) : void 0;
11631
- params.container.resolve(TOKENS.Logger);
11632
12192
  const toolExecutor = new ToolExecutor(params.tools, {
11633
12193
  permissionPolicy: params.permissionPolicy ?? params.container.resolve(TOKENS.PermissionPolicy),
11634
12194
  secretScrubber,
@@ -12012,7 +12572,6 @@ async function main(argv) {
12012
12572
  wpaths,
12013
12573
  cwd,
12014
12574
  projectRoot,
12015
- userHome,
12016
12575
  flags,
12017
12576
  positional,
12018
12577
  modelsRegistry,
@@ -12054,6 +12613,22 @@ async function main(argv) {
12054
12613
  compactor: { preserveK: config.context.preserveK, eliseThreshold: config.context.eliseThreshold },
12055
12614
  bundledSkillsDir: config.features.skills ? resolveBundledSkillsDir2() : void 0
12056
12615
  });
12616
+ const replayFlag = flags["replay"];
12617
+ const recordFlag = flags["record"];
12618
+ if (typeof replayFlag === "string" || recordFlag === true) {
12619
+ const sessionId = typeof replayFlag === "string" ? replayFlag : `record-${Date.now()}`;
12620
+ const mode = recordFlag === true ? "record" : "replay";
12621
+ bindReplayToContainer({
12622
+ container,
12623
+ wpaths,
12624
+ sessionId,
12625
+ mode,
12626
+ logger
12627
+ });
12628
+ logger.info(
12629
+ `replay: ProviderRunner bound in '${mode}' mode for session ${sessionId}`
12630
+ );
12631
+ }
12057
12632
  const configStore = container.resolve(TOKENS.ConfigStore);
12058
12633
  container.bind(TOKENS.PathResolver, () => pathResolver);
12059
12634
  container.bind(TOKENS.Renderer, () => renderer);
@@ -12121,7 +12696,7 @@ async function main(argv) {
12121
12696
  }
12122
12697
  const events = new EventBus();
12123
12698
  events.setLogger(logger);
12124
- const { metricsSink, healthRegistry, metricsServerHandle } = (() => {
12699
+ const { metricsSink, healthRegistry } = (() => {
12125
12700
  const ms = setupMetrics({ flags, wpaths, events, logger, config: { provider: config.provider, model: config.model } });
12126
12701
  return ms;
12127
12702
  })();
@@ -12200,7 +12775,6 @@ async function main(argv) {
12200
12775
  });
12201
12776
  const session = sessResult.session;
12202
12777
  sessionRef.current = session;
12203
- sessResult.restoredMessages;
12204
12778
  const context = sessResult.context;
12205
12779
  const attachments = sessResult.attachments;
12206
12780
  const recoveryLock = sessResult.recoveryLock;