@multiclawcli/dashboard 0.5.1 → 0.6.1

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/tui.js CHANGED
@@ -1,13 +1,44 @@
1
1
  // src/tui/index.ts
2
+ import { AgentSessionManager as AgentSessionManager2 } from "@multiclawcli/core";
2
3
  import { render } from "ink";
3
4
  import { createElement } from "react";
4
5
 
5
6
  // src/tui/App.tsx
6
- import * as path2 from "path";
7
- import { describeResumePlan, isAbortError, Orchestrator } from "@multiclawcli/core";
7
+ import * as path4 from "path";
8
+ import {
9
+ AgentSessionManager,
10
+ describeResumePlan,
11
+ isAbortError,
12
+ Orchestrator,
13
+ validateRuntimeQuestionContent
14
+ } from "@multiclawcli/core";
8
15
  import { Box as Box5, Text as Text5, useApp, useInput, useWindowSize } from "ink";
9
16
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
10
17
 
18
+ // src/tui/atomic.ts
19
+ import * as fs from "fs";
20
+ import * as path from "path";
21
+ var writes = /* @__PURE__ */ new Map();
22
+ function writeJsonFile(target, json) {
23
+ const write = (writes.get(target) ?? Promise.resolve()).catch(() => {
24
+ }).then(async () => {
25
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
26
+ const tmp = `${target}.tmp`;
27
+ await fs.promises.writeFile(tmp, json, "utf-8");
28
+ await fs.promises.rename(tmp, target);
29
+ });
30
+ writes.set(target, write);
31
+ void write.finally(() => {
32
+ if (writes.get(target) === write) writes.delete(target);
33
+ }).catch(() => {
34
+ });
35
+ return write;
36
+ }
37
+ function pendingWrite(target) {
38
+ return (writes.get(target) ?? Promise.resolve()).catch(() => {
39
+ });
40
+ }
41
+
11
42
  // src/tui/commands.ts
12
43
  var COMMANDS = [
13
44
  { name: "agents", description: "List the configured team", availability: "always" },
@@ -69,6 +100,60 @@ var COMMANDS = [
69
100
  description: "Cancel the running turn and its queue",
70
101
  availability: "running"
71
102
  },
103
+ {
104
+ name: "task",
105
+ args: "<requirement>",
106
+ description: "Start a new task with fresh agent sessions",
107
+ availability: "idle"
108
+ },
109
+ {
110
+ name: "steer",
111
+ args: "<text>",
112
+ description: "Send a message into the agents running right now",
113
+ availability: "running"
114
+ },
115
+ {
116
+ name: "answer",
117
+ args: "<text>",
118
+ description: "Answer the leader's pending question",
119
+ availability: "idle"
120
+ },
121
+ {
122
+ name: "pause",
123
+ description: "Pause queued turns, not the active agent",
124
+ availability: "always"
125
+ },
126
+ { name: "continue", description: "Resume queued turns after a checkpoint", availability: "idle" },
127
+ {
128
+ name: "respond",
129
+ args: "<question id> <JSON object>",
130
+ description: "Respond to a structured tool question",
131
+ availability: "always"
132
+ },
133
+ {
134
+ name: "skip",
135
+ args: "<question id>",
136
+ description: "Decline a structured tool question",
137
+ availability: "always"
138
+ },
139
+ {
140
+ name: "dismiss",
141
+ args: "<question id>",
142
+ description: "Cancel a structured tool question",
143
+ availability: "always"
144
+ },
145
+ {
146
+ name: "approve",
147
+ args: "<request id> <option number>",
148
+ description: "Choose an offered permission option explicitly",
149
+ availability: "always"
150
+ },
151
+ {
152
+ name: "deny",
153
+ args: "<request id>",
154
+ description: "Deny a pending permission request",
155
+ availability: "always"
156
+ },
72
157
  { name: "help", description: "Show the key map", availability: "always" },
73
158
  { name: "quit", description: "Exit the console", availability: "always" }
74
159
  ];
@@ -254,6 +339,29 @@ function formatCost(usd) {
254
339
  if (usd < 0.01) return "<$0.01";
255
340
  return `$${usd.toFixed(2)}`;
256
341
  }
342
+ function describeSteering(reports) {
343
+ const names = (list) => list.map((report) => report.agentId).join(", ");
344
+ const of = (status) => reports.filter((report) => report.status === status);
345
+ const injected = of("injected");
346
+ const idle = of("idle");
347
+ const unsupported = of("unsupported");
348
+ const failed = of("error");
349
+ const why = failed.find((report) => report.error)?.error ?? "unknown error";
350
+ if (injected.length > 0) {
351
+ const parts = [`steered ${names(injected)}`];
352
+ if (idle.length > 0) {
353
+ parts.push(`${names(idle)} ${idle.length === 1 ? "was" : "were"} between turns`);
354
+ }
355
+ if (unsupported.length > 0) parts.push(`${names(unsupported)} cannot take mid-turn input`);
356
+ if (failed.length > 0) parts.push(`${names(failed)} could not take it: ${why}`);
357
+ return parts.join(" \xB7 ");
358
+ }
359
+ if (failed.length > 0) return `could not reach ${names(failed)}: ${why} \u2014 queued as a follow-up`;
360
+ if (unsupported.length > 0) {
361
+ return "no running agent can take mid-turn input \u2014 queued as a follow-up";
362
+ }
363
+ return "no agent was mid-turn \u2014 queued as a follow-up";
364
+ }
257
365
 
258
366
  // src/tui/layout.ts
259
367
  var MIN_COLUMNS = 40;
@@ -395,8 +503,8 @@ function Header({ name, right }) {
395
503
  function Rule({ width }) {
396
504
  return /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: COLOR.muted, dimColor: true, children: "\u2500".repeat(Math.max(0, width)) }) });
397
505
  }
398
- function Chip({ agent, fallbackName, spinner }) {
399
- const status = agent?.status ?? "pending";
506
+ function Chip({ agent, fallbackName, spinner, statusOverride }) {
507
+ const status = statusOverride ?? agent?.status ?? "pending";
400
508
  const style = STATUS[status];
401
509
  const glyph = status === "running" ? spinner : style.glyph;
402
510
  return /* @__PURE__ */ jsx(Box, { marginRight: 1, children: /* @__PURE__ */ jsxs(Text, { color: style.color, dimColor: style.dim, children: [
@@ -443,10 +551,11 @@ function PipelineRail({ turn, spinner }) {
443
551
  Chip,
444
552
  {
445
553
  agent: turn.agents[agent.id],
554
+ statusOverride: stage.memberStatuses?.[agent.id],
446
555
  fallbackName: agent.name,
447
556
  spinner
448
557
  },
449
- agent.id
558
+ `${stage.stageIndex}-${agent.id}`
450
559
  )
451
560
  );
452
561
  }
@@ -579,7 +688,7 @@ function reduceEvent(state, event) {
579
688
  ...state,
580
689
  phase: "running",
581
690
  name: event.payload.name,
582
- requirement: event.payload.requirement ?? state.requirement,
691
+ effectiveRequirement: event.payload.requirement ?? state.effectiveRequirement,
583
692
  reason: event.payload.selectionReason,
584
693
  stages: event.payload.stages,
585
694
  agents: mergeStageAgents(state.agents, event.payload.stages)
@@ -591,15 +700,29 @@ function reduceEvent(state, event) {
591
700
  case "orchestration:stages": {
592
701
  return {
593
702
  ...state,
594
- stages: event.payload.stages,
703
+ stages: event.payload.stages.map((stage) => {
704
+ const previous = state.stages.find((previous2) => previous2.stageIndex === stage.stageIndex);
705
+ return previous?.outcome === void 0 ? stage : { ...stage, outcome: previous.outcome, memberStatuses: previous.memberStatuses };
706
+ }),
595
707
  agents: mergeStageAgents(state.agents, event.payload.stages)
596
708
  };
597
709
  }
598
- // Stage boundaries carry no state of their own — the per-agent statuses
599
- // derived from them already drive the pipeline bar.
600
710
  case "stage:start":
601
- case "stage:complete":
602
711
  return state;
712
+ case "stage:complete": {
713
+ const stage = state.stages.find((stage2) => stage2.stageIndex === event.payload.stageIndex);
714
+ if (!stage || stage.outcome !== void 0) return state;
715
+ const outcome2 = stageStatus(stage, state.agents);
716
+ const memberStatuses = Object.fromEntries(
717
+ stage.agents.map((agent) => [agent.id, state.agents[agent.id]?.status ?? "pending"])
718
+ );
719
+ return {
720
+ ...state,
721
+ stages: state.stages.map(
722
+ (entry) => entry === stage ? { ...entry, outcome: outcome2, memberStatuses } : entry
723
+ )
724
+ };
725
+ }
603
726
  case "agent:start": {
604
727
  const { agentId, agentName, icon, model, taskTitle } = event.payload;
605
728
  const existing = state.agents[agentId];
@@ -611,7 +734,14 @@ function reduceEvent(state, event) {
611
734
  model: model ?? existing.model,
612
735
  taskTitle: taskTitle ?? existing.taskTitle,
613
736
  status: "running",
614
- startedAt: startedAt ?? existing.startedAt
737
+ startedAt: startedAt ?? existing.startedAt,
738
+ duration: void 0,
739
+ attempts: void 0,
740
+ retry: void 0,
741
+ error: void 0,
742
+ usage: void 0,
743
+ costUsd: void 0,
744
+ logs: existing.startedAt !== void 0 && existing.logs ? appendLog(existing.logs, "\n[agent restarted]\n") : existing.logs
615
745
  } : blankAgent(agentId, agentName, {
616
746
  icon,
617
747
  model,
@@ -648,6 +778,7 @@ function reduceEvent(state, event) {
648
778
  status: result.status,
649
779
  duration: result.duration,
650
780
  attempts: result.attempts,
781
+ retry: void 0,
651
782
  error: result.error,
652
783
  usage: result.usage,
653
784
  costUsd: result.costUsd,
@@ -657,6 +788,14 @@ function reduceEvent(state, event) {
657
788
  };
658
789
  const existing = state.agents[result.agentId];
659
790
  const next = existing ? { ...existing, ...patch } : blankAgent(result.agentId, result.agentName, patch);
791
+ if (event.type === "agent:failed") {
792
+ if (next.logs === "" && result.output) next.logs = appendLog("", result.output);
793
+ if (result.error && !next.logs.includes(stripAnsi(result.error))) {
794
+ next.logs = appendLog(next.logs, `
795
+ [error] ${result.error}
796
+ `);
797
+ }
798
+ }
660
799
  return { ...state, agents: { ...state.agents, [result.agentId]: next } };
661
800
  }
662
801
  case "agent:skipped": {
@@ -682,6 +821,9 @@ function reduceEvent(state, event) {
682
821
  ...state,
683
822
  phase: "complete",
684
823
  success: event.payload.success,
824
+ acceptance: event.payload.acceptance,
825
+ cancelled: event.payload.cancelled,
826
+ effectiveRequirement: event.payload.requirement ?? state.effectiveRequirement,
685
827
  totalDuration: event.payload.totalDuration,
686
828
  totalCostUsd: event.payload.totalCostUsd,
687
829
  endedAt: epoch(event.timestamp) ?? Date.now(),
@@ -702,6 +844,9 @@ function snapshot(state) {
702
844
  return {
703
845
  turn: state.turn,
704
846
  requirement: state.requirement,
847
+ effectiveRequirement: state.effectiveRequirement,
848
+ cancelled: state.cancelled,
849
+ interrupted: state.interrupted,
705
850
  phase: state.phase,
706
851
  startedAt: state.startedAt,
707
852
  endedAt: state.endedAt,
@@ -712,6 +857,7 @@ function snapshot(state) {
712
857
  autoFollow: state.autoFollow,
713
858
  scroll: state.scroll,
714
859
  success: state.success,
860
+ acceptance: state.acceptance,
715
861
  totalDuration: state.totalDuration,
716
862
  totalCostUsd: state.totalCostUsd,
717
863
  reason: state.reason,
@@ -733,7 +879,7 @@ function recordedTurns(state) {
733
879
  function conversationTurns(state) {
734
880
  const turns = [];
735
881
  for (const turn of recordedTurns(state)) {
736
- if (turn.phase !== "complete" || !turn.requirement) continue;
882
+ if (turn.phase !== "complete" || turn.cancelled || !turn.requirement) continue;
737
883
  turns.push(
738
884
  turn.reply ? { requirement: turn.requirement, reply: turn.reply } : { requirement: turn.requirement }
739
885
  );
@@ -824,6 +970,19 @@ function tuiReducer(state, action) {
824
970
  if (!id || !agent) return turn;
825
971
  return { ...turn, agents: { ...turn.agents, [id]: { ...agent, logs: "" } }, scroll: 0 };
826
972
  });
973
+ case "stream:note": {
974
+ const agent = state.agents[action.agentId];
975
+ if (!agent) return state;
976
+ return {
977
+ ...state,
978
+ agents: {
979
+ ...state.agents,
980
+ [action.agentId]: { ...agent, logs: appendLog(agent.logs, `
981
+ ${action.text}
982
+ `) }
983
+ }
984
+ };
985
+ }
827
986
  case "view:open": {
828
987
  const record = state.history.find((turn) => turn.turn === action.turn);
829
988
  return record ? { ...state, view: record } : state;
@@ -835,6 +994,7 @@ function tuiReducer(state, action) {
835
994
  }
836
995
  }
837
996
  function stageStatus(stage, agents) {
997
+ if (stage.outcome !== void 0) return stage.outcome;
838
998
  const statuses = stage.agents.map((a) => agents[a.id]?.status ?? "pending");
839
999
  if (statuses.some((s) => s === "failed")) return "failed";
840
1000
  if (statuses.some((s) => s === "running" || s === "retrying")) return "running";
@@ -886,7 +1046,7 @@ var HELP_GROUPS = [
886
1046
  title: "prompt",
887
1047
  rows: [
888
1048
  ["enter", "Run the requirement"],
889
- ["enter (busy)", "Queue it to run next"],
1049
+ ["enter (busy)", "Queue only; /approve tools, /respond questions"],
890
1050
  ["esc", "Clear the line, close an overlay"],
891
1051
  ["\u2190 \u2192 home end", "Move the caret"]
892
1052
  ]
@@ -906,7 +1066,7 @@ var HELP_GROUPS = [
906
1066
  rows: [
907
1067
  ["/", "Command menu"],
908
1068
  ["?", "This help"],
909
- ["ctrl+c", "Cancel the turn and its queue \xB7 again to quit"],
1069
+ ["ctrl+c", "Cancel turn and queue \xB7 idle exits after cleanup"],
910
1070
  ["/<command>", `All ${COMMANDS.length} commands \u2014 type / to browse`]
911
1071
  ]
912
1072
  }
@@ -941,11 +1101,115 @@ function HelpOverlay({ width, maxRows }) {
941
1101
  /* @__PURE__ */ jsx2(Box2, { marginTop: gap, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "esc or ? to close" }) })
942
1102
  ] });
943
1103
  }
1104
+ function PermissionOverlay({
1105
+ request,
1106
+ shortId,
1107
+ queued,
1108
+ width,
1109
+ maxRows
1110
+ }) {
1111
+ const clean = (value) => stripAnsi(value).replace(/\p{Cc}/gu, " ");
1112
+ const input = request.input === void 0 ? "(no input)" : JSON.stringify(request.input) ?? "(no input)";
1113
+ const inputRows = Math.max(1, Math.min(3, maxRows - request.options.length - 7));
1114
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, width, children: [
1115
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: COLOR.warn, wrap: "truncate-end", children: `PERMISSION ${shortId} \xB7 ${queued} pending \xB7 ${clean(request.agentId)}` }),
1116
+ /* @__PURE__ */ jsx2(Text2, { wrap: "truncate-end", children: clean(request.title) }),
1117
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, wrap: "truncate-end", children: `request ${clean(request.requestId)} \xB7 ${clean(request.toolName ?? request.kind ?? request.toolCallId)}` }),
1118
+ /* @__PURE__ */ jsx2(Text2, { color: COLOR.accent, wrap: "truncate-end", children: `/approve ${shortId} <number> \xB7 /deny ${shortId}` }),
1119
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, wrap: "truncate-end", children: "Ordinary text queues a follow-up; it never grants permission." }),
1120
+ /* @__PURE__ */ jsx2(Box2, { height: inputRows, overflow: "hidden", flexShrink: 0, children: /* @__PURE__ */ jsx2(Text2, { children: `input: ${clean(input)}` }) }),
1121
+ request.options.map((option, index) => /* @__PURE__ */ jsx2(Text2, { wrap: "truncate-end", children: `${index + 1}. ${clean(option.name)} (${option.kind})` }, option.optionId))
1122
+ ] });
1123
+ }
1124
+ function questionLines(request, width) {
1125
+ const lines = [request.message, `request ${request.requestId}`];
1126
+ for (const [key, field] of Object.entries(request.requestedSchema.properties ?? {})) {
1127
+ const schema = field;
1128
+ const items = schema.items;
1129
+ const choices = schema.oneOf ?? schema.enum ?? items?.anyOf ?? items?.enum;
1130
+ lines.push(
1131
+ `${key}${request.requestedSchema.required?.includes(key) ? " *" : ""}: ${typeof schema.title === "string" ? schema.title : key} (${field.type})`
1132
+ );
1133
+ if (typeof schema.description === "string") lines.push(schema.description);
1134
+ const limits = [
1135
+ "minimum",
1136
+ "maximum",
1137
+ "exclusiveMinimum",
1138
+ "exclusiveMaximum",
1139
+ "minLength",
1140
+ "maxLength",
1141
+ "minItems",
1142
+ "maxItems",
1143
+ "uniqueItems"
1144
+ ].filter((limit) => schema[limit] !== void 0 && schema[limit] !== null).map((limit) => `${limit}=${schema[limit]}`);
1145
+ if (limits.length) lines.push(limits.join(" \xB7 "));
1146
+ for (const choice of choices ?? []) {
1147
+ lines.push(
1148
+ typeof choice === "string" ? JSON.stringify(choice) : `${JSON.stringify(choice.const)} \u2014 ${choice.title ?? choice.const}${choice.description ? `: ${choice.description}` : ""}`
1149
+ );
1150
+ }
1151
+ }
1152
+ return lines.flatMap(
1153
+ (line) => wrapLine(stripAnsi(line).replace(/\p{Cc}/gu, " "), Math.max(1, width - 2))
1154
+ );
1155
+ }
1156
+ function QuestionOverlay({
1157
+ request,
1158
+ shortId,
1159
+ queued,
1160
+ width,
1161
+ maxRows,
1162
+ offset
1163
+ }) {
1164
+ const lines = questionLines(request, width);
1165
+ const capacity = Math.max(1, maxRows - 4);
1166
+ const start = Math.min(offset, Math.max(0, lines.length - capacity));
1167
+ const visible = lines.slice(start, start + capacity);
1168
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, width, children: [
1169
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: COLOR.warn, wrap: "truncate-end", children: `QUESTION ${shortId} \xB7 ${queued} pending \xB7 ${stripAnsi(request.agentId)}` }),
1170
+ /* @__PURE__ */ jsx2(Text2, { color: COLOR.accent, wrap: "truncate-end", children: `/respond ${shortId} <JSON object>` }),
1171
+ /* @__PURE__ */ jsx2(
1172
+ Text2,
1173
+ {
1174
+ dimColor: true,
1175
+ wrap: "truncate-end",
1176
+ children: `/skip ${shortId} declines \xB7 /dismiss ${shortId} cancels`
1177
+ }
1178
+ ),
1179
+ visible.map((line, index) => (
1180
+ // biome-ignore lint/suspicious/noArrayIndexKey: immutable text rows are identified by their absolute form offset.
1181
+ /* @__PURE__ */ jsx2(Text2, { wrap: "truncate-end", children: line }, `${start + index}-${line}`)
1182
+ )),
1183
+ /* @__PURE__ */ jsx2(
1184
+ Text2,
1185
+ {
1186
+ dimColor: true,
1187
+ wrap: "truncate-end",
1188
+ children: `\u2191\u2193 / pgup pgdn form \xB7 ${start + 1}\u2013${start + visible.length}/${lines.length} \xB7 ordinary text queues`
1189
+ }
1190
+ )
1191
+ ] });
1192
+ }
944
1193
  function outcome(turn) {
945
1194
  if (turn.reply) {
946
1195
  return turn.reply.mode === "ask" ? { text: `${GLYPH.ask} needs input`, color: COLOR.warn } : { text: `${STATUS.success.glyph} answered`, color: COLOR.ok };
947
1196
  }
948
- if (turn.phase !== "complete") return { text: "interrupted", color: COLOR.warn };
1197
+ if (turn.interrupted || turn.phase !== "complete") {
1198
+ return { text: "interrupted", color: COLOR.warn };
1199
+ }
1200
+ if (turn.acceptance) {
1201
+ switch (turn.acceptance.status) {
1202
+ case "approved":
1203
+ return { text: `${STATUS.success.glyph} approved`, color: COLOR.ok };
1204
+ case "changes_required":
1205
+ return { text: `${GLYPH.warn} changes required`, color: COLOR.warn };
1206
+ case "failed":
1207
+ return { text: `${STATUS.failed.glyph} acceptance failed`, color: COLOR.error };
1208
+ case "cancelled":
1209
+ return { text: `${STATUS.cancelled.glyph} acceptance cancelled`, color: COLOR.warn };
1210
+ }
1211
+ }
1212
+ if (turn.cancelled) return { text: `${STATUS.cancelled.glyph} cancelled`, color: COLOR.warn };
949
1213
  return turn.success ? { text: `${STATUS.success.glyph} done`, color: COLOR.ok } : { text: `${STATUS.failed.glyph} failed`, color: COLOR.error };
950
1214
  }
951
1215
  function HistoryOverlay({
@@ -1238,7 +1502,7 @@ function Prompt({
1238
1502
  const ghost = value === "";
1239
1503
  const before = shown.slice(0, cursor);
1240
1504
  const cell = ghost ? placeholder.slice(0, 1) : shown.slice(cursor, cursor + 1);
1241
- const tail = ghost ? placeholder.slice(1) : shown.slice(cursor + 1);
1505
+ const tail2 = ghost ? placeholder.slice(1) : shown.slice(cursor + 1);
1242
1506
  return /* @__PURE__ */ jsxs4(Box4, { flexShrink: 0, children: [
1243
1507
  /* @__PURE__ */ jsxs4(Text4, { color: COLOR.accent, children: [
1244
1508
  GLYPH.caret,
@@ -1246,7 +1510,7 @@ function Prompt({
1246
1510
  ] }),
1247
1511
  /* @__PURE__ */ jsx4(Text4, { dimColor: ghost, children: before }),
1248
1512
  /* @__PURE__ */ jsx4(Text4, { inverse: caretVisible, dimColor: !caretVisible && ghost, children: cell || " " }),
1249
- /* @__PURE__ */ jsx4(Text4, { dimColor: ghost, children: tail })
1513
+ /* @__PURE__ */ jsx4(Text4, { dimColor: ghost, children: tail2 })
1250
1514
  ] });
1251
1515
  }
1252
1516
  function CommandMenu({ rows, selected }) {
@@ -1273,25 +1537,45 @@ function CommandMenu({ rows, selected }) {
1273
1537
  }
1274
1538
 
1275
1539
  // src/tui/history.ts
1276
- import * as fs from "fs";
1277
- import * as path from "path";
1540
+ import * as fs2 from "fs";
1541
+ import * as path2 from "path";
1278
1542
  var CONSOLE_DIR = "console";
1279
1543
  var HISTORY_FILE = "history.json";
1280
1544
  var EXPORT_DIR = "exports";
1281
1545
  function historyPath(workDir) {
1282
- return path.join(workDir, ".multiclaw", CONSOLE_DIR, HISTORY_FILE);
1546
+ return path2.join(workDir, ".multiclaw", CONSOLE_DIR, HISTORY_FILE);
1283
1547
  }
1284
1548
  function isObject(value) {
1285
1549
  return typeof value === "object" && value !== null;
1286
1550
  }
1551
+ function isAcceptance(value) {
1552
+ if (!isObject(value)) return false;
1553
+ return typeof value.status === "string" && ["approved", "changes_required", "failed", "cancelled"].includes(value.status) && typeof value.reviewer === "string" && Number.isInteger(value.repairRounds) && value.repairRounds >= 0 && (value.reason === void 0 || typeof value.reason === "string") && Array.isArray(value.reviews) && value.reviews.every(
1554
+ (review) => isObject(review) && (review.verdict === "approved" || review.verdict === "changes_required") && typeof review.summary === "string" && Array.isArray(review.issues) && review.issues.every((issue) => typeof issue === "string")
1555
+ );
1556
+ }
1287
1557
  function isTurnView(value) {
1288
1558
  if (!isObject(value)) return false;
1289
1559
  const turn = value;
1290
- return typeof turn.turn === "number" && typeof turn.phase === "string" && isObject(turn.agents) && Object.values(turn.agents).every(isObject) && Array.isArray(turn.stages) && turn.stages.every(isObject) && Array.isArray(turn.leaderIds);
1560
+ return typeof turn.turn === "number" && typeof turn.phase === "string" && (turn.cancelled === void 0 || typeof turn.cancelled === "boolean") && (turn.interrupted === void 0 || typeof turn.interrupted === "boolean") && (turn.acceptance === void 0 || isAcceptance(turn.acceptance)) && (turn.effectiveRequirement === void 0 || typeof turn.effectiveRequirement === "string") && isObject(turn.agents) && Object.values(turn.agents).every(isObject) && Array.isArray(turn.stages) && turn.stages.every(
1561
+ (stage) => isObject(stage) && (stage.outcome === void 0 || ["pending", "running", "retrying", "success", "failed", "cancelled", "skipped"].includes(
1562
+ stage.outcome
1563
+ )) && (stage.memberStatuses === void 0 || isObject(stage.memberStatuses) && Object.values(stage.memberStatuses).every(
1564
+ (status) => typeof status === "string" && [
1565
+ "pending",
1566
+ "running",
1567
+ "retrying",
1568
+ "success",
1569
+ "failed",
1570
+ "cancelled",
1571
+ "skipped"
1572
+ ].includes(status)
1573
+ ))
1574
+ ) && Array.isArray(turn.leaderIds);
1291
1575
  }
1292
1576
  function loadHistory(workDir) {
1293
1577
  try {
1294
- const raw = fs.readFileSync(historyPath(workDir), "utf-8");
1578
+ const raw = fs2.readFileSync(historyPath(workDir), "utf-8");
1295
1579
  const parsed = JSON.parse(raw);
1296
1580
  if (!Array.isArray(parsed)) return [];
1297
1581
  return parsed.filter(isTurnView).slice(-MAX_HISTORY);
@@ -1299,19 +1583,15 @@ function loadHistory(workDir) {
1299
1583
  return [];
1300
1584
  }
1301
1585
  }
1302
- async function saveHistory(workDir, turns) {
1303
- const target = historyPath(workDir);
1304
- await fs.promises.mkdir(path.dirname(target), { recursive: true });
1305
- const tmp = `${target}.tmp`;
1306
- await fs.promises.writeFile(tmp, JSON.stringify(turns), "utf-8");
1307
- await fs.promises.rename(tmp, target);
1586
+ function saveHistory(workDir, turns) {
1587
+ return writeJsonFile(historyPath(workDir), JSON.stringify(turns));
1308
1588
  }
1309
1589
  function exportPath(workDir, turn) {
1310
- return path.join(workDir, ".multiclaw", CONSOLE_DIR, EXPORT_DIR, `turn-${turn}.md`);
1590
+ return path2.join(workDir, ".multiclaw", CONSOLE_DIR, EXPORT_DIR, `turn-${turn}.md`);
1311
1591
  }
1312
1592
  async function exportTurn(turn, filePath) {
1313
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
1314
- await fs.promises.writeFile(filePath, formatTurnMarkdown(turn), "utf-8");
1593
+ await fs2.promises.mkdir(path2.dirname(filePath), { recursive: true });
1594
+ await fs2.promises.writeFile(filePath, formatTurnMarkdown(turn), "utf-8");
1315
1595
  }
1316
1596
  function formatTurnMarkdown(turn) {
1317
1597
  const lines = [`# Turn ${turn.turn}`, ""];
@@ -1319,7 +1599,22 @@ function formatTurnMarkdown(turn) {
1319
1599
  if (turn.reply) {
1320
1600
  lines.push("## Reply", "", `_${turn.reply.mode}_`, "", turn.reply.message, "");
1321
1601
  } else {
1322
- lines.push("## Result", "", `Status: ${turn.success ? "success" : "failed"}`, "");
1602
+ const acceptance = turn.acceptance;
1603
+ const status = turn.interrupted ? "interrupted" : acceptance ? acceptance.status === "failed" ? "acceptance failed" : acceptance.status === "cancelled" ? "acceptance cancelled" : acceptance.status.replace("_", " ") : turn.cancelled ? "cancelled" : turn.success ? "success" : "failed";
1604
+ lines.push("## Result", "", `Status: ${status}`, "");
1605
+ if (acceptance) {
1606
+ lines.push(
1607
+ `Reviewer: ${acceptance.reviewer}`,
1608
+ `Repair rounds: ${acceptance.repairRounds}`,
1609
+ ""
1610
+ );
1611
+ if (acceptance.reason) lines.push(acceptance.reason, "");
1612
+ for (const review of acceptance.reviews) {
1613
+ lines.push(`### Review: ${review.verdict.replace("_", " ")}`, "", review.summary, "");
1614
+ for (const issue of review.issues) lines.push(`- ${issue}`);
1615
+ lines.push("");
1616
+ }
1617
+ }
1323
1618
  }
1324
1619
  const agents = Object.values(turn.agents);
1325
1620
  if (agents.length > 0) {
@@ -1337,11 +1632,147 @@ function formatTurnMarkdown(turn) {
1337
1632
  `;
1338
1633
  }
1339
1634
 
1635
+ // src/tui/session.ts
1636
+ import * as fs3 from "fs";
1637
+ import * as path3 from "path";
1638
+ var SESSION_FILE = "session.json";
1639
+ var SESSION_VERSION = 1;
1640
+ var MAX_JOURNAL_LOG_CHARS = 1200;
1641
+ function sessionPath(workDir) {
1642
+ return path3.join(workDir, ".multiclaw", CONSOLE_DIR, SESSION_FILE);
1643
+ }
1644
+ function isObject2(value) {
1645
+ return typeof value === "object" && value !== null;
1646
+ }
1647
+ var STATUSES = /* @__PURE__ */ new Set([
1648
+ "pending",
1649
+ "running",
1650
+ "retrying",
1651
+ "success",
1652
+ "failed",
1653
+ "cancelled",
1654
+ "skipped"
1655
+ ]);
1656
+ function isAgentView(value) {
1657
+ if (!isObject2(value)) return false;
1658
+ return typeof value.id === "string" && typeof value.name === "string" && typeof value.logs === "string" && typeof value.status === "string" && STATUSES.has(value.status);
1659
+ }
1660
+ function isJournalTurn(value) {
1661
+ if (!isObject2(value)) return false;
1662
+ return Number.isInteger(value.turn) && typeof value.requirement === "string" && typeof value.savedAt === "number" && Number.isFinite(value.savedAt) && (value.startedAt === void 0 || typeof value.startedAt === "number") && (value.effectiveRequirement === void 0 || typeof value.effectiveRequirement === "string") && (value.reason === void 0 || typeof value.reason === "string") && (value.warning === void 0 || typeof value.warning === "string") && (value.focusId === void 0 || typeof value.focusId === "string") && isObject2(value.agents) && Object.values(value.agents).every(isAgentView) && Array.isArray(value.leaderIds) && value.leaderIds.every((id) => typeof id === "string") && Array.isArray(value.stages) && value.stages.every(isObject2) && (value.pending === void 0 || isObject2(value.pending) && Number.isInteger(value.pending.permissions) && Number.isInteger(value.pending.questions));
1663
+ }
1664
+ function loadJournal(workDir) {
1665
+ try {
1666
+ const parsed = JSON.parse(fs3.readFileSync(sessionPath(workDir), "utf-8"));
1667
+ if (!isObject2(parsed) || parsed.version !== SESSION_VERSION) return void 0;
1668
+ const queue = Array.isArray(parsed.queue) ? parsed.queue.filter((item) => typeof item === "string" && item !== "") : [];
1669
+ return {
1670
+ version: SESSION_VERSION,
1671
+ ...Number.isInteger(parsed.taskStart) && parsed.taskStart >= 0 ? { taskStart: parsed.taskStart } : {},
1672
+ ...queue.length > 0 ? { queue } : {},
1673
+ ...isJournalTurn(parsed.inFlight) ? { inFlight: parsed.inFlight } : {}
1674
+ };
1675
+ } catch {
1676
+ return void 0;
1677
+ }
1678
+ }
1679
+ function isEmpty(journal) {
1680
+ return journal.inFlight === void 0 && journal.queue === void 0 && !journal.taskStart;
1681
+ }
1682
+ function saveJournal(workDir, journal) {
1683
+ const target = sessionPath(workDir);
1684
+ if (isEmpty(journal) && !fs3.existsSync(target)) return Promise.resolve();
1685
+ return writeJsonFile(target, JSON.stringify(journal));
1686
+ }
1687
+ function tail(text, max) {
1688
+ return text.length > max ? `\u2026${text.slice(text.length - max)}` : text;
1689
+ }
1690
+ function journalTurn(state, pending) {
1691
+ const agents = {};
1692
+ for (const [id, agent] of Object.entries(state.agents)) {
1693
+ agents[id] = { ...agent, logs: tail(agent.logs, MAX_JOURNAL_LOG_CHARS) };
1694
+ }
1695
+ return {
1696
+ turn: state.turn,
1697
+ requirement: state.requirement ?? "",
1698
+ startedAt: state.startedAt,
1699
+ effectiveRequirement: state.effectiveRequirement,
1700
+ reason: state.reason,
1701
+ warning: state.warning,
1702
+ savedAt: Date.now(),
1703
+ leaderIds: state.leaderIds,
1704
+ focusId: state.focusId,
1705
+ stages: state.stages,
1706
+ agents,
1707
+ pending
1708
+ };
1709
+ }
1710
+ var UNSETTLED = /* @__PURE__ */ new Set(["pending", "running", "retrying"]);
1711
+ function interruptedTurn(entry) {
1712
+ const agents = {};
1713
+ for (const [id, agent] of Object.entries(entry.agents)) {
1714
+ agents[id] = UNSETTLED.has(agent.status) ? { ...agent, status: "cancelled" } : { ...agent };
1715
+ }
1716
+ return {
1717
+ turn: entry.turn,
1718
+ requirement: entry.requirement,
1719
+ effectiveRequirement: entry.effectiveRequirement,
1720
+ reason: entry.reason,
1721
+ warning: entry.warning,
1722
+ phase: "complete",
1723
+ interrupted: true,
1724
+ success: false,
1725
+ startedAt: entry.startedAt,
1726
+ endedAt: entry.savedAt,
1727
+ stages: entry.stages.map((stage) => ({ stageIndex: stage.stageIndex, agents: stage.agents })),
1728
+ agents,
1729
+ leaderIds: entry.leaderIds,
1730
+ focusId: entry.focusId,
1731
+ autoFollow: false,
1732
+ scroll: 0
1733
+ };
1734
+ }
1735
+ function plural(count, word) {
1736
+ return count === 1 ? word : `${word}s`;
1737
+ }
1738
+ function restoreConsole(workDir) {
1739
+ const history = loadHistory(workDir);
1740
+ const journal = loadJournal(workDir);
1741
+ const inFlight = journal?.inFlight;
1742
+ const last = history[history.length - 1];
1743
+ const parts = [];
1744
+ if (inFlight && (!last || inFlight.turn > last.turn)) {
1745
+ history.push(interruptedTurn(inFlight));
1746
+ parts.push(`turn ${inFlight.turn} was interrupted by a restart`);
1747
+ const pending = (inFlight.pending?.permissions ?? 0) + (inFlight.pending?.questions ?? 0);
1748
+ if (pending > 0) {
1749
+ parts.push(`${pending} pending ${plural(pending, "decision")} discarded`);
1750
+ }
1751
+ }
1752
+ const queue = journal?.queue ?? [];
1753
+ if (queue.length > 0) {
1754
+ parts.push(
1755
+ `${queue.length} queued ${plural(queue.length, "follow-up")} restored \u2014 /continue resumes ${queue.length === 1 ? "it" : "them"}`
1756
+ );
1757
+ }
1758
+ const restored = history.slice(-MAX_HISTORY);
1759
+ return {
1760
+ history: restored,
1761
+ queue,
1762
+ taskStart: journal?.taskStart ?? 0,
1763
+ // Read from the *final* last turn: a recovered interrupted turn has no
1764
+ // reply, so it correctly clears the "waiting for an answer" state.
1765
+ awaitingAnswer: restored[restored.length - 1]?.reply?.mode === "ask",
1766
+ ...parts.length > 0 ? { notice: parts.join(" \xB7 ") } : {}
1767
+ };
1768
+ }
1769
+
1340
1770
  // src/tui/App.tsx
1341
1771
  import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1342
1772
  var FLUSH_INTERVAL_MS = 80;
1343
1773
  var SPINNER_INTERVAL_MS = 110;
1344
1774
  var BLINK_INTERVAL_MS = 530;
1775
+ var SESSION_SAVE_INTERVAL_MS = 2e3;
1345
1776
  var INPUT_PLACEHOLDER = "describe what the team should do\u2026";
1346
1777
  var RUNNING_PLACEHOLDER = "type to queue a follow-up\u2026";
1347
1778
  var EMPTY_LINE = { text: "", cursor: 0 };
@@ -1372,7 +1803,8 @@ function StatusLine({
1372
1803
  return /* @__PURE__ */ jsx5(Text5, { color: COLOR.error, wrap: "truncate-end", children: error });
1373
1804
  if (state.warning && state.phase !== "complete")
1374
1805
  return /* @__PURE__ */ jsx5(Text5, { color: COLOR.warn, wrap: "truncate-end", children: state.warning });
1375
- if (cancelled && !running) return /* @__PURE__ */ jsx5(Text5, { color: COLOR.warn, children: "cancelled" });
1806
+ if ((cancelled || state.cancelled) && !running)
1807
+ return /* @__PURE__ */ jsx5(Text5, { color: COLOR.warn, children: "cancelled \xB7 workspace changes are retained" });
1376
1808
  if (state.reply) {
1377
1809
  const asking = state.reply.mode === "ask";
1378
1810
  return /* @__PURE__ */ jsx5(Text5, { color: asking ? COLOR.warn : COLOR.ok, wrap: "truncate-end", children: asking ? `${GLYPH.ask} needs your input` : `${STATUS.success.glyph} answered` });
@@ -1394,7 +1826,7 @@ function StatusLine({
1394
1826
  }
1395
1827
  if (state.phase === "complete") {
1396
1828
  const time = state.totalDuration === void 0 ? "" : ` ${formatDuration(state.totalDuration)}`;
1397
- const outcome2 = state.success ? `${STATUS.success.glyph} done${time}` : `${STATUS.failed.glyph} finished with failures${time}`;
1829
+ const outcome2 = state.acceptance ? `${state.success ? STATUS.success.glyph : STATUS.failed.glyph} ${state.acceptance.status === "approved" ? "delivery approved" : state.acceptance.status === "changes_required" ? "changes required" : "acceptance failed"}${time}` : state.success ? `${STATUS.success.glyph} done${time}` : `${STATUS.failed.glyph} finished with failures${time}`;
1398
1830
  const cost = state.totalCostUsd === void 0 ? "" : ` \xB7 ${formatCost(state.totalCostUsd)}`;
1399
1831
  return /* @__PURE__ */ jsxs5(Text5, { color: state.success ? COLOR.ok : COLOR.error, wrap: "truncate-end", children: [
1400
1832
  outcome2,
@@ -1410,13 +1842,16 @@ function StatusLine({
1410
1842
  stages > 0 ? ` \xB7 ${stages} stages` : ""
1411
1843
  ] });
1412
1844
  }
1413
- function App({ config, onTurnStart }) {
1845
+ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }) {
1414
1846
  const { exit } = useApp();
1415
1847
  const { columns, rows } = useWindowSize();
1848
+ const restoredRef = useRef(null);
1849
+ if (!restoredRef.current) restoredRef.current = restoreConsole(config.workDir);
1850
+ const restored = restoredRef.current;
1416
1851
  const [state, dispatch] = useReducer(
1417
1852
  tuiReducer,
1418
1853
  config,
1419
- (c) => createInitialState(c, loadHistory(c.workDir))
1854
+ (c) => createInitialState(c, restored.history)
1420
1855
  );
1421
1856
  const stateRef = useRef(state);
1422
1857
  stateRef.current = state;
@@ -1424,10 +1859,12 @@ function App({ config, onTurnStart }) {
1424
1859
  const [caretOn, setCaretOn] = useState(true);
1425
1860
  const [tick, setTick] = useState(0);
1426
1861
  const [running, setRunning] = useState(false);
1427
- const [queue, setQueue] = useState([]);
1862
+ const [queue, setQueue] = useState(restored.queue);
1863
+ const queueRef = useRef(queue);
1864
+ queueRef.current = queue;
1428
1865
  const [cancelled, setCancelled] = useState(false);
1429
1866
  const [error, setError] = useState();
1430
- const [notice, setNotice] = useState();
1867
+ const [notice, setNotice] = useState(restored.notice);
1431
1868
  const [helpOpen, setHelpOpen] = useState(false);
1432
1869
  const [historyOpen, setHistoryOpen] = useState(false);
1433
1870
  const [historyCursor, setHistoryCursor] = useState(0);
@@ -1436,7 +1873,73 @@ function App({ config, onTurnStart }) {
1436
1873
  index: 0
1437
1874
  });
1438
1875
  const abortRef = useRef(null);
1876
+ const sessionsRef = useRef(null);
1877
+ if (!sessionsRef.current) sessionsRef.current = providedSessions ?? new AgentSessionManager();
1878
+ const activeTurnRef = useRef(null);
1879
+ const reporterErrorRef = useRef(null);
1880
+ const closingRef = useRef(false);
1881
+ const shutdownRef = useRef(null);
1882
+ const busyRef = useRef(false);
1883
+ const taskStartRef = useRef(restored.taskStart);
1884
+ const turnSerialRef = useRef(0);
1885
+ const [awaitingAnswer, setAwaitingAnswer] = useState(restored.awaitingAnswer);
1886
+ const [queuePaused, setQueuePaused] = useState(restored.queue.length > 0);
1887
+ const awaitingAnswerRef = useRef(restored.awaitingAnswer);
1888
+ const queuePausedRef = useRef(restored.queue.length > 0);
1439
1889
  const pending = useRef([]);
1890
+ const permissionSerialRef = useRef(0);
1891
+ const questionSerialRef = useRef(0);
1892
+ const mountedRef = useRef(true);
1893
+ const interactionsRef = useRef([]);
1894
+ const [interactions, setInteractions] = useState([]);
1895
+ const [questionScroll, setQuestionScroll] = useState({ id: "", offset: 0 });
1896
+ const requestPermission = useCallback((request, signal) => {
1897
+ if (closingRef.current || signal.aborted) return Promise.resolve(void 0);
1898
+ const shortId = `p${++permissionSerialRef.current}`;
1899
+ return new Promise((resolve2) => {
1900
+ const entry = {
1901
+ kind: "permission",
1902
+ request,
1903
+ shortId,
1904
+ settle: (optionId) => {
1905
+ if (!interactionsRef.current.includes(entry)) return;
1906
+ signal.removeEventListener("abort", onAbort);
1907
+ interactionsRef.current = interactionsRef.current.filter((item) => item !== entry);
1908
+ if (mountedRef.current) setInteractions([...interactionsRef.current]);
1909
+ resolve2(optionId);
1910
+ }
1911
+ };
1912
+ const onAbort = () => entry.settle();
1913
+ interactionsRef.current.push(entry);
1914
+ signal.addEventListener("abort", onAbort, { once: true });
1915
+ if (mountedRef.current) setInteractions([...interactionsRef.current]);
1916
+ });
1917
+ }, []);
1918
+ const requestQuestion = useCallback((request, signal) => {
1919
+ if (closingRef.current || signal.aborted) return Promise.resolve({ action: "cancel" });
1920
+ const shortId = `q${++questionSerialRef.current}`;
1921
+ return new Promise((resolve2) => {
1922
+ const entry = {
1923
+ kind: "question",
1924
+ request,
1925
+ shortId,
1926
+ settle: (response = { action: "cancel" }) => {
1927
+ if (!interactionsRef.current.includes(entry)) return;
1928
+ signal.removeEventListener("abort", onAbort);
1929
+ interactionsRef.current = interactionsRef.current.filter((item) => item !== entry);
1930
+ if (mountedRef.current) setInteractions([...interactionsRef.current]);
1931
+ resolve2(response);
1932
+ }
1933
+ };
1934
+ const onAbort = () => entry.settle();
1935
+ interactionsRef.current.push(entry);
1936
+ signal.addEventListener("abort", onAbort, { once: true });
1937
+ if (mountedRef.current) setInteractions([...interactionsRef.current]);
1938
+ });
1939
+ }, []);
1940
+ const clearInteractions = useCallback(() => {
1941
+ for (const entry of [...interactionsRef.current]) entry.settle();
1942
+ }, []);
1440
1943
  const flush = useCallback(() => {
1441
1944
  const batch = pending.current;
1442
1945
  if (batch.length === 0) return;
@@ -1467,12 +1970,75 @@ function App({ config, onTurnStart }) {
1467
1970
  (err) => setNotice(`history not saved: ${err.message}`)
1468
1971
  );
1469
1972
  }, [state.history, state.phase]);
1973
+ const persistSession = useCallback(() => {
1974
+ if (closingRef.current) return;
1975
+ const current = stateRef.current;
1976
+ const waiting = { permissions: 0, questions: 0 };
1977
+ for (const entry of interactionsRef.current) {
1978
+ if (entry.kind === "permission") waiting.permissions += 1;
1979
+ else waiting.questions += 1;
1980
+ }
1981
+ const journal = {
1982
+ version: SESSION_VERSION,
1983
+ taskStart: taskStartRef.current,
1984
+ ...queueRef.current.length > 0 ? { queue: [...queueRef.current] } : {},
1985
+ // Only a turn that is actually running is in flight. Writing one for an
1986
+ // idle console would invent work the next session would then "recover".
1987
+ ...current.phase === "running" && current.requirement !== void 0 ? { inFlight: journalTurn(current, waiting) } : {}
1988
+ };
1989
+ void saveJournal(config.workDir, journal).catch((err) => {
1990
+ if (mountedRef.current) setNotice(`session not saved: ${err.message}`);
1991
+ });
1992
+ }, [config.workDir]);
1993
+ const journalSettled = useRef(false);
1994
+ useEffect(() => {
1995
+ if (!journalSettled.current) {
1996
+ journalSettled.current = true;
1997
+ return;
1998
+ }
1999
+ persistSession();
2000
+ }, [state.history, state.phase, queue, persistSession]);
2001
+ useEffect(() => {
2002
+ if (!running) return;
2003
+ const timer = setInterval(persistSession, SESSION_SAVE_INTERVAL_MS);
2004
+ return () => clearInterval(timer);
2005
+ }, [running, persistSession]);
1470
2006
  const startTurn = useCallback(
1471
2007
  async (requirement, opts) => {
2008
+ if (closingRef.current || busyRef.current) return;
2009
+ busyRef.current = true;
2010
+ setRunning(true);
2011
+ if (opts?.newTask) {
2012
+ try {
2013
+ await sessionsRef.current?.reset();
2014
+ if (closingRef.current) {
2015
+ busyRef.current = false;
2016
+ setRunning(false);
2017
+ return;
2018
+ }
2019
+ taskStartRef.current = stateRef.current.turn;
2020
+ } catch (err) {
2021
+ busyRef.current = false;
2022
+ setRunning(false);
2023
+ setError(`session reset failed: ${err.message}`);
2024
+ return;
2025
+ }
2026
+ }
2027
+ if (closingRef.current) {
2028
+ busyRef.current = false;
2029
+ setRunning(false);
2030
+ return;
2031
+ }
2032
+ awaitingAnswerRef.current = false;
2033
+ setAwaitingAnswer(false);
1472
2034
  setError(void 0);
1473
2035
  setCancelled(false);
1474
2036
  setNotice(void 0);
1475
- const earlierTurns = conversationTurns(stateRef.current);
2037
+ const earlierTurns = conversationTurns({
2038
+ ...stateRef.current,
2039
+ history: stateRef.current.history.filter((turn) => turn.turn > taskStartRef.current),
2040
+ ...stateRef.current.turn <= taskStartRef.current ? { phase: "idle" } : {}
2041
+ });
1476
2042
  dispatch({
1477
2043
  type: "turn:start",
1478
2044
  requirement,
@@ -1480,8 +2046,8 @@ function App({ config, onTurnStart }) {
1480
2046
  leaderIds: leaderAgentIds(config),
1481
2047
  startedAt: Date.now()
1482
2048
  });
1483
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1484
- const logDir = path2.join(config.workDir, ".multiclaw", "runs", `run-${stamp}`, "logs");
2049
+ const stamp = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${++turnSerialRef.current}`;
2050
+ const logDir = path4.join(config.workDir, ".multiclaw", "runs", `run-${stamp}`, "logs");
1485
2051
  const runConfig = {
1486
2052
  ...config,
1487
2053
  context: { ...config.context, requirement },
@@ -1489,39 +2055,118 @@ function App({ config, onTurnStart }) {
1489
2055
  // so the leader is handed the turns behind this one as well.
1490
2056
  history: earlierTurns,
1491
2057
  logDir,
2058
+ permissions: !config.permissions || config.permissions.mode === "ask" ? { mode: "ask", onRequest: requestPermission } : config.permissions,
2059
+ questions: config.questions?.onRequest ? config.questions : { onRequest: requestQuestion },
1492
2060
  // Only a retry carries this; a normal turn lets the leader route.
1493
2061
  ...opts?.resume ? { resume: opts.resume } : {}
1494
2062
  };
1495
2063
  const controller = new AbortController();
1496
2064
  abortRef.current = controller;
1497
2065
  setRunning(true);
1498
- const orchestrator = new Orchestrator(runConfig);
1499
- const unsubscribe = orchestrator.eventBus.subscribe((event) => {
1500
- pending.current.push(event);
1501
- });
1502
- onTurnStart?.(orchestrator.eventBus, logDir);
2066
+ let unsubscribe;
2067
+ let disposeReporter;
1503
2068
  const timer = setInterval(flush, FLUSH_INTERVAL_MS);
2069
+ let finishTurn;
2070
+ activeTurnRef.current = new Promise((resolve2) => {
2071
+ finishTurn = resolve2;
2072
+ });
1504
2073
  try {
1505
- await orchestrator.run({ signal: controller.signal });
2074
+ const orchestrator = new Orchestrator(runConfig, sessionsRef.current ?? void 0);
2075
+ unsubscribe = orchestrator.eventBus.subscribe((event) => {
2076
+ pending.current.push(event);
2077
+ });
2078
+ const reporter = onTurnStart?.(orchestrator.eventBus, logDir);
2079
+ if (reporter) disposeReporter = reporter;
2080
+ const result = await orchestrator.run({ signal: controller.signal });
2081
+ const asks = result.reply?.mode === "ask";
2082
+ awaitingAnswerRef.current = asks;
2083
+ setAwaitingAnswer(asks);
2084
+ if (asks || !result.success || result.cancelled) {
2085
+ queuePausedRef.current = true;
2086
+ setQueuePaused(true);
2087
+ }
2088
+ if (result.cancelled) setCancelled(true);
1506
2089
  } catch (err) {
2090
+ queuePausedRef.current = true;
2091
+ setQueuePaused(true);
1507
2092
  if (isAbortError(err)) setCancelled(true);
1508
2093
  else setError(err.message);
1509
2094
  } finally {
2095
+ clearInteractions();
1510
2096
  clearInterval(timer);
1511
2097
  flush();
1512
- unsubscribe();
2098
+ unsubscribe?.();
2099
+ try {
2100
+ await disposeReporter?.();
2101
+ } catch (err) {
2102
+ queuePausedRef.current = true;
2103
+ setQueuePaused(true);
2104
+ reporterErrorRef.current = err instanceof Error ? err : new Error(String(err));
2105
+ setError(`log cleanup failed: ${reporterErrorRef.current.message}`);
2106
+ }
1513
2107
  abortRef.current = null;
2108
+ busyRef.current = false;
1514
2109
  setRunning(false);
2110
+ finishTurn();
2111
+ activeTurnRef.current = null;
1515
2112
  }
1516
2113
  },
1517
- [config, flush, onTurnStart]
2114
+ [config, flush, onTurnStart, requestPermission, requestQuestion, clearInteractions]
1518
2115
  );
1519
2116
  useEffect(() => {
1520
- if (running || queue.length === 0) return;
2117
+ if (running || busyRef.current || closingRef.current || queuePaused || awaitingAnswer || queue.length === 0)
2118
+ return;
1521
2119
  const [next, ...rest] = queue;
1522
2120
  setQueue(rest);
1523
2121
  void startTurn(next);
1524
- }, [running, queue, startTurn]);
2122
+ }, [running, queue, queuePaused, awaitingAnswer, startTurn]);
2123
+ const cleanup = useCallback(async () => {
2124
+ closingRef.current = true;
2125
+ abortRef.current?.abort();
2126
+ clearInteractions();
2127
+ await activeTurnRef.current;
2128
+ await sessionsRef.current?.close();
2129
+ await Promise.all([
2130
+ pendingWrite(historyPath(config.workDir)),
2131
+ pendingWrite(sessionPath(config.workDir))
2132
+ ]);
2133
+ if (reporterErrorRef.current) throw reporterErrorRef.current;
2134
+ }, [clearInteractions, config.workDir]);
2135
+ useEffect(() => {
2136
+ mountedRef.current = true;
2137
+ onCleanupReady?.(cleanup);
2138
+ return () => {
2139
+ mountedRef.current = false;
2140
+ void cleanup().catch(() => {
2141
+ });
2142
+ };
2143
+ }, [cleanup, onCleanupReady]);
2144
+ const shutdown = useCallback(() => {
2145
+ if (shutdownRef.current) return shutdownRef.current;
2146
+ closingRef.current = true;
2147
+ const leftover = [...queueRef.current];
2148
+ queuePausedRef.current = true;
2149
+ setQueuePaused(true);
2150
+ setQueue([]);
2151
+ setNotice("closing sessions\u2026");
2152
+ abortRef.current?.abort();
2153
+ shutdownRef.current = (async () => {
2154
+ try {
2155
+ await cleanup();
2156
+ const turns = recordedTurns(stateRef.current).slice(-MAX_HISTORY);
2157
+ if (turns.length) await saveHistory(config.workDir, turns);
2158
+ await saveJournal(config.workDir, {
2159
+ version: SESSION_VERSION,
2160
+ taskStart: taskStartRef.current,
2161
+ ...leftover.length > 0 ? { queue: leftover } : {}
2162
+ });
2163
+ exit();
2164
+ } catch (err) {
2165
+ setError(`shutdown failed: ${err.message}`);
2166
+ }
2167
+ })();
2168
+ return shutdownRef.current;
2169
+ }, [config.workDir, exit, cleanup]);
1525
2170
  const query = menuQuery(line.text);
1526
2171
  const matches = useMemo(
1527
2172
  () => query === void 0 ? [] : matchCommands(query, running),
@@ -1553,20 +2198,168 @@ function App({ config, onTurnStart }) {
1553
2198
  overlay: helpOpen || historyOpen,
1554
2199
  menuRows: paletteOpen ? paletteTotal : 0,
1555
2200
  queueRows: queue.length,
1556
- notice: notice !== void 0,
2201
+ notice: notice !== void 0 || queuePaused && queue.length > 0,
1557
2202
  viewing: state.view !== void 0,
1558
2203
  hasRail: shown.leaderIds.length > 0 || shown.stages.length > 0,
1559
2204
  railCells: railCellWidths(shown)
1560
2205
  });
2206
+ const steerRunningAgents = useCallback(async (text) => {
2207
+ const manager = sessionsRef.current;
2208
+ if (!manager) {
2209
+ setNotice("no live sessions to steer");
2210
+ return;
2211
+ }
2212
+ setNotice("steering\u2026");
2213
+ let reports;
2214
+ try {
2215
+ reports = await manager.supplement(text);
2216
+ } catch (error2) {
2217
+ if (mountedRef.current) setNotice(`steering failed: ${error2.message}`);
2218
+ return;
2219
+ }
2220
+ if (!mountedRef.current || closingRef.current) return;
2221
+ const injected = reports.filter((report) => report.status === "injected");
2222
+ if (injected.length === 0) setQueue((current) => [...current, text]);
2223
+ for (const report of injected) {
2224
+ dispatch({ type: "stream:note", agentId: report.agentId, text: `[steer] ${text}` });
2225
+ }
2226
+ setNotice(describeSteering(reports));
2227
+ }, []);
1561
2228
  const runCommand = useCallback(
1562
- (name, args) => {
2229
+ (name, args, rawText) => {
1563
2230
  setLine(EMPTY_LINE);
1564
2231
  if (!canRun(name, running)) {
1565
2232
  setNotice(unavailableReason(name, running));
1566
2233
  return;
1567
2234
  }
1568
2235
  const shown2 = state.view ?? state;
2236
+ if (closingRef.current) return;
1569
2237
  switch (name) {
2238
+ case "respond":
2239
+ case "skip":
2240
+ case "dismiss": {
2241
+ const [id, ...jsonParts] = args;
2242
+ if (!id || (name === "respond" ? jsonParts.length === 0 : args.length !== 1)) {
2243
+ setNotice(
2244
+ name === "respond" ? "usage: /respond <question id> <JSON object>" : `usage: /${name} <question id>`
2245
+ );
2246
+ return;
2247
+ }
2248
+ const matches2 = interactionsRef.current.filter(
2249
+ (entry2) => entry2.request.requestId === id || entry2.shortId === id
2250
+ );
2251
+ const entry = matches2.length === 1 ? matches2[0] : void 0;
2252
+ if (entry?.kind !== "question") {
2253
+ setNotice(`unknown or stale question request: ${id}`);
2254
+ return;
2255
+ }
2256
+ if (name !== "respond") {
2257
+ entry.settle({ action: name === "skip" ? "decline" : "cancel" });
2258
+ setNotice(`${name === "skip" ? "skipped" : "dismissed"} ${entry.shortId}`);
2259
+ return;
2260
+ }
2261
+ let content;
2262
+ try {
2263
+ const rawJson = /^\/respond\s+\S+\s+([\s\S]+)$/.exec(rawText ?? "")?.[1] ?? "";
2264
+ content = JSON.parse(rawJson);
2265
+ } catch {
2266
+ setNotice("response must be a valid JSON object");
2267
+ return;
2268
+ }
2269
+ if (!validateRuntimeQuestionContent(entry.request.requestedSchema, content)) {
2270
+ setNotice(
2271
+ "response does not match the question schema; check required fields and choices"
2272
+ );
2273
+ return;
2274
+ }
2275
+ entry.settle({
2276
+ action: "accept",
2277
+ content
2278
+ });
2279
+ setNotice(`responded to ${entry.shortId}`);
2280
+ return;
2281
+ }
2282
+ case "approve":
2283
+ case "deny": {
2284
+ const [id, number] = args;
2285
+ if (!id || (name === "approve" ? args.length !== 2 : args.length !== 1)) {
2286
+ setNotice(
2287
+ name === "approve" ? "usage: /approve <request id> <option number>" : "usage: /deny <request id>"
2288
+ );
2289
+ return;
2290
+ }
2291
+ const matches2 = interactionsRef.current.filter(
2292
+ (entry2) => entry2.request.requestId === id || entry2.shortId === id
2293
+ );
2294
+ if (matches2.length !== 1) {
2295
+ setNotice(`unknown or stale permission request: ${id}`);
2296
+ return;
2297
+ }
2298
+ const entry = matches2[0];
2299
+ if (entry.kind !== "permission") {
2300
+ setNotice(`not a permission request: ${id}`);
2301
+ return;
2302
+ }
2303
+ if (name === "deny") {
2304
+ entry.settle();
2305
+ setNotice(`denied ${entry.shortId}`);
2306
+ return;
2307
+ }
2308
+ const option = /^\d+$/.test(number ?? "") ? entry.request.options[Number(number) - 1] : void 0;
2309
+ if (!option) {
2310
+ setNotice(
2311
+ `invalid option for ${entry.shortId}; choose 1\u2013${entry.request.options.length}`
2312
+ );
2313
+ return;
2314
+ }
2315
+ entry.settle(option.optionId);
2316
+ setNotice(`selected ${option.name} for ${entry.shortId}`);
2317
+ return;
2318
+ }
2319
+ case "task": {
2320
+ const text = args.join(" ");
2321
+ if (!text) {
2322
+ setNotice("usage: /task <new requirement> \u2014 closes prior agent sessions");
2323
+ return;
2324
+ }
2325
+ setQueue([]);
2326
+ queuePausedRef.current = false;
2327
+ setQueuePaused(false);
2328
+ void startTurn(text, { newTask: true });
2329
+ return;
2330
+ }
2331
+ case "answer": {
2332
+ const text = args.join(" ");
2333
+ if (!awaitingAnswerRef.current || !text) {
2334
+ setNotice("usage: /answer <text> \u2014 only when the leader is waiting for an answer");
2335
+ return;
2336
+ }
2337
+ void startTurn(text);
2338
+ return;
2339
+ }
2340
+ case "steer": {
2341
+ const text = args.join(" ");
2342
+ if (!text) {
2343
+ setNotice("usage: /steer <text> \u2014 delivered to the agents running now");
2344
+ return;
2345
+ }
2346
+ void steerRunningAgents(text);
2347
+ return;
2348
+ }
2349
+ case "pause":
2350
+ queuePausedRef.current = true;
2351
+ setQueuePaused(true);
2352
+ setNotice("queue paused \u2014 the active turn continues");
2353
+ return;
2354
+ case "continue":
2355
+ if (awaitingAnswerRef.current) {
2356
+ setNotice("answer the leader first; queued input is not an answer");
2357
+ return;
2358
+ }
2359
+ queuePausedRef.current = false;
2360
+ setQueuePaused(false);
2361
+ setNotice("queue resumed");
2362
+ return;
1570
2363
  case "agents":
1571
2364
  setNotice(
1572
2365
  `team: ${plannedAgents(config).map((agent) => agent.id).join(", ")}`
@@ -1614,7 +2407,11 @@ function App({ config, onTurnStart }) {
1614
2407
  return;
1615
2408
  }
1616
2409
  let ids;
1617
- if (args[0]) {
2410
+ if (state.acceptance && state.acceptance.status !== "approved" && (!args[0] || args[0] === state.acceptance.reviewer)) {
2411
+ ids = [
2412
+ ...new Set(state.stages.flatMap((stage) => stage.agents.map((agent) => agent.id)))
2413
+ ].filter((id) => id !== state.acceptance?.reviewer);
2414
+ } else if (args[0]) {
1618
2415
  const resolved = resolveAgentId(args[0], Object.keys(state.agents));
1619
2416
  if ("error" in resolved) {
1620
2417
  setNotice(resolved.error);
@@ -1631,7 +2428,12 @@ function App({ config, onTurnStart }) {
1631
2428
  const sessions = Object.fromEntries(
1632
2429
  ids.filter((id) => state.agents[id]?.sessionId).map((id) => [id, state.agents[id].sessionId])
1633
2430
  );
1634
- void startTurn(state.requirement, {
2431
+ if (ids.some((id) => state.leaderIds.includes(id))) {
2432
+ void startTurn(state.requirement);
2433
+ setNotice("retrying leader routing");
2434
+ return;
2435
+ }
2436
+ void startTurn(state.effectiveRequirement ?? state.requirement, {
1635
2437
  resume: {
1636
2438
  agents: ids,
1637
2439
  ...Object.keys(sessions).length > 0 ? { sessions } : {}
@@ -1672,7 +2474,7 @@ function App({ config, onTurnStart }) {
1672
2474
  setNotice(target.error);
1673
2475
  return;
1674
2476
  }
1675
- const file = args[1] ? path2.resolve(config.workDir, args[1]) : exportPath(config.workDir, target.record.turn);
2477
+ const file = args[1] ? path4.resolve(config.workDir, args[1]) : exportPath(config.workDir, target.record.turn);
1676
2478
  void exportTurn(target.record, file).then(() => setNotice(`exported turn ${target.record.turn} to ${file}`)).catch((err) => setNotice(`export failed: ${err.message}`));
1677
2479
  return;
1678
2480
  }
@@ -1702,6 +2504,7 @@ function App({ config, onTurnStart }) {
1702
2504
  }
1703
2505
  const dropped = queue.length;
1704
2506
  abortRef.current.abort();
2507
+ clearInteractions();
1705
2508
  setQueue([]);
1706
2509
  setCancelled(true);
1707
2510
  setNotice(dropped > 0 ? `cancelling\u2026 \xB7 dropped ${dropped} queued` : "cancelling\u2026");
@@ -1712,25 +2515,36 @@ function App({ config, onTurnStart }) {
1712
2515
  setHelpOpen(true);
1713
2516
  return;
1714
2517
  case "quit":
1715
- exit();
2518
+ void shutdown();
1716
2519
  return;
1717
2520
  default:
1718
2521
  setNotice(`unknown command: /${name}`);
1719
2522
  }
1720
2523
  },
1721
- [config, exit, layout.logViewport, layout.streamWidth, queue.length, running, state, startTurn]
2524
+ [
2525
+ config,
2526
+ shutdown,
2527
+ clearInteractions,
2528
+ layout.logViewport,
2529
+ layout.streamWidth,
2530
+ queue.length,
2531
+ running,
2532
+ state,
2533
+ startTurn,
2534
+ steerRunningAgents
2535
+ ]
1722
2536
  );
1723
2537
  const submit = useCallback(() => {
1724
2538
  const text = line.text.trim();
1725
- if (!text) return;
2539
+ if (!text || closingRef.current) return;
1726
2540
  const command = parseCommand(text);
1727
2541
  if (command) {
1728
- runCommand(command.name, command.args);
2542
+ runCommand(command.name, command.args, text);
1729
2543
  return;
1730
2544
  }
1731
2545
  setLine(EMPTY_LINE);
1732
2546
  setNotice(void 0);
1733
- if (running) {
2547
+ if (running || busyRef.current) {
1734
2548
  setQueue((pending2) => [...pending2, text]);
1735
2549
  return;
1736
2550
  }
@@ -1771,13 +2585,14 @@ function App({ config, onTurnStart }) {
1771
2585
  setHistoryOpen(false);
1772
2586
  } else if (abortRef.current) {
1773
2587
  abortRef.current.abort();
2588
+ clearInteractions();
1774
2589
  setNotice(queue.length > 0 ? `cancelling\u2026 \xB7 dropped ${queue.length} queued` : "cancelling\u2026");
1775
2590
  setQueue([]);
1776
2591
  setCancelled(true);
1777
2592
  } else if (line.text) {
1778
2593
  setLine(EMPTY_LINE);
1779
2594
  } else {
1780
- exit();
2595
+ void shutdown();
1781
2596
  }
1782
2597
  return;
1783
2598
  }
@@ -1807,7 +2622,7 @@ function App({ config, onTurnStart }) {
1807
2622
  return;
1808
2623
  }
1809
2624
  if (key.ctrl && char === "d") {
1810
- if (!line.text) exit();
2625
+ if (!line.text) void shutdown();
1811
2626
  return;
1812
2627
  }
1813
2628
  if (key.ctrl && char === "l") {
@@ -1846,6 +2661,20 @@ function App({ config, onTurnStart }) {
1846
2661
  return;
1847
2662
  }
1848
2663
  }
2664
+ if (interactions[0]?.kind === "question" && (key.upArrow || key.downArrow || key.pageUp || key.pageDown)) {
2665
+ const entry = interactions[0];
2666
+ const capacity = Math.max(1, layout.bodyHeight - 4);
2667
+ const maxOffset = Math.max(0, questionLines(entry.request, layout.columns).length - capacity);
2668
+ const delta = key.pageDown ? capacity : key.pageUp ? -capacity : key.downArrow ? 1 : -1;
2669
+ setQuestionScroll((scroll) => ({
2670
+ id: entry.shortId,
2671
+ offset: Math.min(
2672
+ maxOffset,
2673
+ Math.max(0, (scroll.id === entry.shortId ? scroll.offset : 0) + delta)
2674
+ )
2675
+ }));
2676
+ return;
2677
+ }
1849
2678
  if (key.upArrow) {
1850
2679
  dispatch({
1851
2680
  type: "stream:scroll",
@@ -1932,7 +2761,7 @@ function App({ config, onTurnStart }) {
1932
2761
  const handlerRef = useRef(handleKey);
1933
2762
  handlerRef.current = handleKey;
1934
2763
  useInput((char, key) => handlerRef.current(char, key));
1935
- const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 enter run \xB7 esc close" : running ? "enter queues \xB7 ctrl+c cancel" : state.view ? "esc live \xB7 tab agent \xB7 ? help" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 / commands \xB7 ? help";
2764
+ const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 enter run \xB7 esc close" : running ? interactions.length > 0 ? interactions[0].kind === "question" ? "/respond <id> <JSON> \xB7 /skip <id> \xB7 /dismiss <id>" : "/approve <id> <number> \xB7 /deny <id> \xB7 ctrl+c cancel" : "enter queues \xB7 ctrl+c cancel" : state.view ? "esc live \xB7 tab agent \xB7 ? help" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 / commands \xB7 ? help";
1936
2765
  return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", height: layout.rows, children: [
1937
2766
  /* @__PURE__ */ jsx5(
1938
2767
  Header,
@@ -1966,6 +2795,25 @@ function App({ config, onTurnStart }) {
1966
2795
  width: layout.columns,
1967
2796
  maxRows: layout.bodyHeight
1968
2797
  }
2798
+ ) : interactions[0]?.kind === "question" ? /* @__PURE__ */ jsx5(
2799
+ QuestionOverlay,
2800
+ {
2801
+ request: interactions[0].request,
2802
+ shortId: interactions[0].shortId,
2803
+ queued: interactions.length,
2804
+ width: layout.columns,
2805
+ maxRows: layout.bodyHeight,
2806
+ offset: questionScroll.id === interactions[0].shortId ? questionScroll.offset : 0
2807
+ }
2808
+ ) : interactions[0]?.kind === "permission" ? /* @__PURE__ */ jsx5(
2809
+ PermissionOverlay,
2810
+ {
2811
+ request: interactions[0].request,
2812
+ shortId: interactions[0].shortId,
2813
+ queued: interactions.length,
2814
+ width: layout.columns,
2815
+ maxRows: layout.bodyHeight
2816
+ }
1969
2817
  ) : layout.welcome ? /* @__PURE__ */ jsx5(WelcomePanel, { turn: state, width: layout.columns }) : error ? (
1970
2818
  // The error wins over a reply: a run that stopped with an error is the
1971
2819
  // most urgent thing on screen, and a stale answer underneath it would
@@ -1990,9 +2838,9 @@ function App({ config, onTurnStart }) {
1990
2838
  }
1991
2839
  ),
1992
2840
  state.view ? /* @__PURE__ */ jsx5(Text5, { color: COLOR.warn, wrap: "truncate-end", children: ` viewing turn ${shown.turn} \xB7 ${shown.requirement ?? "(no requirement)"} \xB7 esc to return` }) : null,
1993
- notice ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, wrap: "truncate-end", children: [
2841
+ notice || queuePaused && queue.length > 0 ? /* @__PURE__ */ jsxs5(Text5, { dimColor: true, wrap: "truncate-end", children: [
1994
2842
  " ",
1995
- notice
2843
+ notice ?? (awaitingAnswer ? "queue paused \u2014 answer the leader first" : "queue paused \u2014 /continue resumes it")
1996
2844
  ] }) : null,
1997
2845
  /* @__PURE__ */ jsx5(QueueStrip, { items: queue, max: layout.queueRows }),
1998
2846
  paletteOpen ? /* @__PURE__ */ jsx5(CommandMenu, { rows: paletteWindow, selected: paletteSelected }) : null,
@@ -2002,7 +2850,7 @@ function App({ config, onTurnStart }) {
2002
2850
  value: line.text,
2003
2851
  cursor: line.cursor,
2004
2852
  caretVisible: caretOn,
2005
- placeholder: running ? RUNNING_PLACEHOLDER : INPUT_PLACEHOLDER
2853
+ placeholder: running ? RUNNING_PLACEHOLDER : awaitingAnswer ? "answer the leader\u2026 \xB7 /task starts a new task" : INPUT_PLACEHOLDER
2006
2854
  }
2007
2855
  ),
2008
2856
  /* @__PURE__ */ jsx5(
@@ -2036,12 +2884,24 @@ async function startTui(options) {
2036
2884
  const restore = () => process.stdout.write("\x1B[?25h\x1B[?1049l");
2037
2885
  process.stdout.write("\x1B[?1049h\x1B[?25l");
2038
2886
  process.on("exit", restore);
2887
+ const sessions = new AgentSessionManager2();
2888
+ let cleanup;
2889
+ const onCleanupReady = (callback) => {
2890
+ cleanup = callback;
2891
+ };
2039
2892
  try {
2040
- const app = render(createElement(App, options), { exitOnCtrlC: false });
2893
+ const app = render(createElement(App, { ...options, sessions, onCleanupReady }), {
2894
+ exitOnCtrlC: false
2895
+ });
2041
2896
  await app.waitUntilExit();
2042
2897
  } finally {
2043
- process.off("exit", restore);
2044
- restore();
2898
+ try {
2899
+ if (cleanup) await cleanup();
2900
+ else await sessions.close();
2901
+ } finally {
2902
+ process.off("exit", restore);
2903
+ restore();
2904
+ }
2045
2905
  }
2046
2906
  }
2047
2907
  export {