@multiclawcli/dashboard 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  describeResumePlan,
11
11
  isAbortError,
12
12
  Orchestrator,
13
+ resolvePermissionMode,
13
14
  validateRuntimeQuestionContent
14
15
  } from "@multiclawcli/core";
15
16
  import { Box as Box6, Text as Text6, useApp, useInput, useWindowSize } from "ink";
@@ -57,149 +58,95 @@ function appendLines(target, lines) {
57
58
 
58
59
  // src/tui/commands.ts
59
60
  var COMMANDS = [
60
- { name: "agents", description: "List the configured team", availability: "always" },
61
+ { name: "new", description: "Start an empty session", availability: "idle" },
62
+ { name: "sessions", description: "Switch or delete saved sessions", availability: "idle" },
61
63
  {
62
64
  name: "focus",
63
- args: "<id>",
64
- description: "Focus an agent by id",
65
+ args: "[id]",
66
+ description: "Choose the focused agent",
65
67
  availability: "always",
66
68
  completes: "agent"
67
69
  },
68
- { name: "clear", description: "Clear the focused agent's log", availability: "always" },
69
70
  {
70
- name: "sessions",
71
- description: "Switch between, or delete, saved sessions",
71
+ name: "mode",
72
+ args: "[auto | ask | deny]",
73
+ description: "Choose the permission mode",
72
74
  availability: "always"
73
75
  },
76
+ { name: "verbose", description: "Toggle tool detail in the stream", availability: "always" },
74
77
  {
75
- name: "verbose",
76
- description: "Toggle tool detail in the stream",
78
+ name: "search",
79
+ args: "[text]",
80
+ description: "Search the focused stream",
77
81
  availability: "always"
78
82
  },
83
+ { name: "latest", description: "Jump to latest output", availability: "always" },
79
84
  {
80
85
  name: "retry",
81
86
  args: "[id]",
82
- description: "Re-run a failed agent (or all of them)",
87
+ description: "Choose failed agents to retry",
83
88
  availability: "idle"
84
89
  },
85
90
  {
86
91
  name: "replay",
87
92
  args: "[turn]",
88
- description: "Run an earlier turn again",
93
+ description: "Choose a turn to run again",
89
94
  availability: "idle"
90
95
  },
91
96
  {
92
- name: "fork",
97
+ name: "edit",
93
98
  args: "[turn]",
94
- description: "Edit an earlier turn's requirement",
99
+ description: "Edit a previous requirement",
95
100
  availability: "always"
96
101
  },
97
102
  {
98
103
  name: "export",
99
104
  args: "[turn] [path]",
100
- description: "Export a finished turn as Markdown",
101
- availability: "always"
102
- },
103
- {
104
- name: "search",
105
- args: "<text>",
106
- description: "Search the focused stream",
107
- availability: "always"
108
- },
109
- {
110
- name: "latest",
111
- description: "Jump the stream to latest output",
105
+ description: "Export a turn as Markdown",
112
106
  availability: "always"
113
107
  },
114
- {
115
- name: "cancel",
116
- description: "Cancel the running turn and its queue",
117
- availability: "running"
118
- },
119
- {
120
- name: "new",
121
- args: "<requirement>",
122
- description: "Start a new session with fresh agent sessions",
123
- availability: "idle"
124
- },
125
108
  {
126
109
  name: "steer",
127
- args: "<text>",
128
- description: "Send a message into the agents running right now",
110
+ args: "[text]",
111
+ description: "Send a message to running agents",
129
112
  availability: "running"
130
113
  },
131
- {
132
- name: "answer",
133
- args: "<text>",
134
- description: "Answer the leader's pending question",
135
- availability: "idle"
136
- },
137
- {
138
- name: "pause",
139
- description: "Pause queued turns, not the active agent",
140
- availability: "always"
141
- },
114
+ { name: "pause", description: "Pause queued turns", availability: "always" },
142
115
  {
143
116
  name: "continue",
144
- description: "Resume queued turns or pass a checkpoint",
145
- availability: "always"
146
- },
147
- {
148
- name: "modify",
149
- args: "<instruction>",
150
- description: "Provide feedback at a checkpoint and continue",
151
- availability: "running"
152
- },
153
- {
154
- name: "respond",
155
- args: "<question id> <JSON object>",
156
- description: "Answer a question in one line of JSON",
157
- availability: "always"
158
- },
159
- {
160
- name: "skip",
161
- args: "<question id>",
162
- description: "Decline a structured tool question",
163
- availability: "always"
164
- },
165
- {
166
- name: "dismiss",
167
- args: "<question id>",
168
- description: "Cancel a structured tool question",
169
- availability: "always"
170
- },
171
- {
172
- name: "approve",
173
- args: "[id] <number>",
174
- description: "Approve the request on screen, or a named one",
117
+ description: "Resume the queue or continue a checkpoint",
175
118
  availability: "always"
176
- },
177
- {
178
- name: "deny",
179
- args: "[id]",
180
- description: "Deny the request on screen, or a named one",
181
- availability: "always"
182
- },
183
- { name: "help", description: "Show the key map", availability: "always" },
184
- { name: "quit", description: "Exit the console", availability: "always" }
119
+ }
185
120
  ];
186
- function availableCommands(running) {
121
+ function relevant(name, context) {
122
+ if (!context) return true;
123
+ if (context.checkpoint) return name === "continue";
124
+ if (["replay", "edit", "export"].includes(name)) return context.history;
125
+ if (name === "retry") return context.retry;
126
+ if (name === "pause") return context.queue > 0 && !context.paused;
127
+ if (name === "continue") return context.queue > 0 && context.paused && !context.awaitingAnswer;
128
+ return true;
129
+ }
130
+ function availableCommands(running, context) {
187
131
  const phase = running ? "running" : "idle";
188
- return COMMANDS.filter((c) => c.availability === "always" || c.availability === phase);
132
+ return COMMANDS.filter(
133
+ (c) => (c.availability === "always" || c.availability === phase) && relevant(c.name, context)
134
+ );
189
135
  }
190
- function canRun(name, running) {
136
+ function canRun(name, running, context) {
191
137
  const command = COMMANDS.find((c) => c.name === name);
192
138
  if (!command) return true;
193
- return availableCommands(running).includes(command);
139
+ return availableCommands(running, context).includes(command);
194
140
  }
195
- function unavailableReason(name, running) {
141
+ function unavailableReason(name, running, context) {
196
142
  const command = COMMANDS.find((c) => c.name === name);
197
- if (!command || availableCommands(running).includes(command)) return void 0;
143
+ if (!command || availableCommands(running, context).includes(command)) return void 0;
144
+ if (!relevant(name, context)) return `/${name} has nothing to act on right now`;
198
145
  return running ? `/${name} waits for the current turn to finish` : `/${name} only applies while a turn is running`;
199
146
  }
200
- function matchCommands(query, running) {
147
+ function matchCommands(query, running, context) {
201
148
  const q = query.trim().toLowerCase();
202
- const pool = availableCommands(running);
149
+ const pool = availableCommands(running, context);
203
150
  if (!q) return pool;
204
151
  const byName = [];
205
152
  const byDescription = [];
@@ -459,16 +406,24 @@ function computeLayout(input) {
459
406
  const compact = columns < COMPACT_COLUMNS;
460
407
  const welcome = !input.overlay && input.turn === 0;
461
408
  const showRail = !welcome && !input.overlay && rows >= RAIL_MIN_ROWS && input.hasRail;
462
- const menuRows = Math.min(Math.max(0, input.menuRows), MAX_MENU_ROWS);
463
- const queueRows = Math.min(Math.max(0, input.queueRows ?? 0), MAX_QUEUE_ROWS);
464
409
  const promptRows = Math.max(1, input.promptRows ?? 1);
465
410
  const railRows = showRail ? Math.min(MAX_RAIL_ROWS, Math.max(1, measureRailRows(input.railCells, columns))) : 0;
411
+ const fixedRows = 4 + MIN_BODY_ROWS + promptRows + (input.details ? 1 : 0) + (input.notice ? 1 : 0) + (railRows > 0 ? railRows + 1 : 0);
412
+ const queueRows = Math.min(
413
+ Math.max(0, input.queueRows ?? 0),
414
+ MAX_QUEUE_ROWS,
415
+ Math.max(0, rows - fixedRows - (input.menuRows > 0 ? 1 : 0))
416
+ );
417
+ const menuRows = Math.min(
418
+ Math.max(0, input.menuRows),
419
+ MAX_MENU_ROWS,
420
+ Math.max(1, rows - fixedRows - queueRows)
421
+ );
466
422
  const chrome = 1 + // header
467
423
  1 + // rule under the header
468
424
  (railRows > 0 ? railRows + 1 : 0) + // pipeline rail plus the gap above it
469
425
  1 + // gap before the body
470
- (input.notice ? 1 : 0) + (input.viewing ? 1 : 0) + // "viewing turn N" banner
471
- queueRows + menuRows + promptRows + 1;
426
+ (input.notice ? 1 : 0) + (input.details ? 1 : 0) + queueRows + menuRows + promptRows + 1;
472
427
  const barRoom = Math.max(0, rows - chrome - MIN_BODY_ROWS - 1);
473
428
  const interactionRows2 = barRoom < MIN_INTERACTION_ROWS ? 0 : Math.min(Math.max(0, input.interactionRows ?? 0), MAX_INTERACTION_ROWS, barRoom);
474
429
  const reserved = chrome + (interactionRows2 > 0 ? interactionRows2 + 1 : 0);
@@ -508,6 +463,7 @@ var STATUS = {
508
463
  pending: { glyph: "\u25CB", color: COLOR.muted, dim: true, label: "queued" },
509
464
  running: { glyph: "\u25D0", color: COLOR.accent, label: "running" },
510
465
  retrying: { glyph: "\u21BB", color: COLOR.warn, label: "retrying" },
466
+ handed_back: { glyph: "\u21A9", color: COLOR.warn, label: "handed back" },
511
467
  success: { glyph: "\u2713", color: COLOR.ok, label: "done" },
512
468
  failed: { glyph: "\u2717", color: COLOR.error, label: "failed" },
513
469
  // Stopped, not broken: a filled square reads as "halted" and shares the muted
@@ -857,11 +813,55 @@ function reduceEvent(state, event) {
857
813
  }
858
814
  };
859
815
  }
816
+ case "agent:session": {
817
+ const { record, action } = event.payload;
818
+ const existing = state.agents[record.agentId];
819
+ const isCurrentWork = record.workId ? state.work?.id === record.workId : state.leaderIds.includes(record.agentId);
820
+ const startsBinding = action === "opening" || action === "load_requested";
821
+ const matchesBinding = !existing?.session || existing.session.bindingId === record.bindingId || startsBinding;
822
+ const history = state.history.map((turn) => {
823
+ if (!record.workId || turn.work?.id !== record.workId) return turn;
824
+ const member = turn.work.agents.find((item) => item.agentId === record.agentId);
825
+ if (member?.session?.bindingId !== record.bindingId) return turn;
826
+ return {
827
+ ...turn,
828
+ agents: turn.agents[record.agentId]?.session?.bindingId === record.bindingId ? {
829
+ ...turn.agents,
830
+ [record.agentId]: { ...turn.agents[record.agentId], session: record }
831
+ } : turn.agents,
832
+ work: {
833
+ ...turn.work,
834
+ agents: turn.work.agents.map(
835
+ (item) => item.agentId === record.agentId ? { ...item, session: record } : item
836
+ )
837
+ }
838
+ };
839
+ });
840
+ const updatesCurrent = isCurrentWork && matchesBinding && existing;
841
+ const work = updatesCurrent && state.work && state.work.id === record.workId ? {
842
+ ...state.work,
843
+ agents: state.work.agents.map(
844
+ (item) => item.agentId === record.agentId ? {
845
+ ...item,
846
+ session: record.sessionId || !item.session?.sessionId || record.failure?.code === "cleanup_failed" ? record : item.session
847
+ } : item
848
+ )
849
+ } : state.work;
850
+ return {
851
+ ...state,
852
+ work,
853
+ history,
854
+ sessionEvents: [...state.sessionEvents ?? [], event.payload],
855
+ agents: updatesCurrent ? { ...state.agents, [record.agentId]: { ...existing, session: record } } : state.agents
856
+ };
857
+ }
858
+ case "agent:handed-back":
860
859
  case "agent:complete":
861
860
  case "agent:failed": {
862
861
  const result = event.payload;
863
862
  const patch = {
864
863
  status: result.status,
864
+ handoffReason: result.handoffReason,
865
865
  duration: result.duration,
866
866
  attempts: result.attempts,
867
867
  retry: void 0,
@@ -870,18 +870,25 @@ function reduceEvent(state, event) {
870
870
  costUsd: result.costUsd,
871
871
  // Present only on the success path; a failed agent has no session to
872
872
  // continue, so `/retry` re-runs it cold.
873
- sessionId: result.sessionId
873
+ session: result.session ?? state.agents[result.agentId]?.session
874
874
  };
875
875
  const existing = state.agents[result.agentId];
876
876
  const next = existing ? { ...existing, ...patch } : blankAgent(result.agentId, result.agentName, patch);
877
+ if (event.type === "agent:handed-back")
878
+ next.logs = appendLog(next.logs, "[handed back] " + result.handoffReason, "system");
877
879
  if (event.type === "agent:failed") {
878
880
  if (next.logs.length === 0 && result.output)
879
881
  next.logs = appendLog([], result.output, "text");
880
- if (result.error && !next.logs.some((s) => s.text.includes(stripAnsi(result.error)))) {
881
- next.logs = appendLog(next.logs, result.error, "system");
882
+ const error = result.error;
883
+ if (error && !next.logs.some((s) => s.text.includes(stripAnsi(error)))) {
884
+ next.logs = appendLog(next.logs, error, "system");
882
885
  }
883
886
  }
884
- return { ...state, agents: { ...state.agents, [result.agentId]: next } };
887
+ return {
888
+ ...state,
889
+ agentResults: [...state.agentResults ?? [], result],
890
+ agents: { ...state.agents, [result.agentId]: next }
891
+ };
885
892
  }
886
893
  case "agent:skipped": {
887
894
  const { agentId, agentName, icon } = event.payload;
@@ -901,6 +908,15 @@ function reduceEvent(state, event) {
901
908
  });
902
909
  return { ...state, agents: { ...state.agents, [agentId]: next } };
903
910
  }
911
+ case "orchestration:route":
912
+ return {
913
+ ...state,
914
+ routing: event.payload.routing,
915
+ work: event.payload.work,
916
+ warning: event.payload.routing.reroutes ? "Worker handed work back \u2014 rerouting once" : state.warning
917
+ };
918
+ case "orchestration:routing-error":
919
+ return { ...state, warning: event.payload.message };
904
920
  case "orchestration:warning":
905
921
  return { ...state, warning: event.payload.message };
906
922
  case "orchestration:complete":
@@ -908,6 +924,10 @@ function reduceEvent(state, event) {
908
924
  ...state,
909
925
  phase: "complete",
910
926
  success: event.payload.success,
927
+ work: event.payload.work,
928
+ routing: event.payload.routing,
929
+ agentResults: event.payload.agentResults,
930
+ effectiveAgentResults: event.payload.effectiveAgentResults,
911
931
  acceptance: event.payload.acceptance,
912
932
  cancelled: event.payload.cancelled,
913
933
  effectiveRequirement: event.payload.requirement ?? state.effectiveRequirement,
@@ -920,14 +940,120 @@ function reduceEvent(state, event) {
920
940
  return state;
921
941
  }
922
942
  }
943
+ var wrappedCache = /* @__PURE__ */ new WeakMap();
944
+ var MAX_CACHED_WIDTHS = 3;
923
945
  function wrapLog(logs, width) {
924
946
  if (logs.length === 0) return [];
947
+ let byWidth = wrappedCache.get(logs);
948
+ if (byWidth === void 0) {
949
+ byWidth = /* @__PURE__ */ new Map();
950
+ wrappedCache.set(logs, byWidth);
951
+ }
952
+ const cached = byWidth.get(width);
953
+ if (cached !== void 0) return cached;
925
954
  const rendered = renderSegments(logs, width);
926
- return rendered.trimEnd().split("\n").flatMap((line) => wrapLine(line, width));
955
+ const rows = rendered.trimEnd().split("\n").flatMap((line) => wrapLine(line, width));
956
+ if (byWidth.size >= MAX_CACHED_WIDTHS) byWidth.clear();
957
+ byWidth.set(width, rows);
958
+ return rows;
927
959
  }
928
- function focusedRowCount(turn, width) {
929
- const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
930
- return agent?.logs ? wrapLog(agent.logs, width).length : 0;
960
+ function totalRows(parts) {
961
+ let total = 0;
962
+ for (const part of parts) total += part.length;
963
+ return total;
964
+ }
965
+ function windowParts(parts, viewport, scroll) {
966
+ const height = Math.max(1, viewport);
967
+ const total = totalRows(parts);
968
+ const maxScroll = Math.max(0, total - height);
969
+ const scrolledBack = Math.min(Math.max(0, scroll), maxScroll);
970
+ const end = total - scrolledBack;
971
+ const start = Math.max(0, end - height);
972
+ const rows = [];
973
+ let offset = 0;
974
+ for (const part of parts) {
975
+ const partEnd = offset + part.length;
976
+ if (partEnd <= start) {
977
+ offset = partEnd;
978
+ continue;
979
+ }
980
+ if (offset >= end) break;
981
+ const from = Math.max(0, start - offset);
982
+ const to = Math.min(part.length, end - offset);
983
+ for (let i = from; i < to; i++) rows.push(part[i]);
984
+ offset = partEnd;
985
+ }
986
+ return { rows, scrolledBack, following: scrolledBack === 0 };
987
+ }
988
+ function clipToColumns(text, max) {
989
+ if (max <= 0) return "";
990
+ if (displayWidth(text) <= max) return text;
991
+ let head = "";
992
+ let used = 0;
993
+ for (const char of text) {
994
+ const width = displayWidth(char);
995
+ if (used + width > max - 1) break;
996
+ head += char;
997
+ used += width;
998
+ }
999
+ return `${head}\u2026`;
1000
+ }
1001
+ function turnDivider(turn, width) {
1002
+ const MAX_REQ = 45;
1003
+ const raw = turn.requirement?.replace(/\s+/g, " ").trim();
1004
+ const req = raw ? clipToColumns(raw, MAX_REQ) : void 0;
1005
+ const label = req ? ` turn ${turn.turn} \xB7 ${req} ` : ` turn ${turn.turn} `;
1006
+ const fill = Math.max(1, Math.floor((width - displayWidth(label)) / 2));
1007
+ const rule = `${"\u2500".repeat(fill)}${label}${"\u2500".repeat(fill)}`;
1008
+ return `\x1B[2m${clipToColumns(rule, width)}\x1B[0m`;
1009
+ }
1010
+ function pushRows(target, rows) {
1011
+ for (const row of rows) target.push(row);
1012
+ }
1013
+ var historyCache = /* @__PURE__ */ new WeakMap();
1014
+ function historyRows(history, agentId, width) {
1015
+ if (history.length === 0) return [];
1016
+ let byAgent = historyCache.get(history);
1017
+ if (byAgent === void 0) {
1018
+ byAgent = /* @__PURE__ */ new Map();
1019
+ historyCache.set(history, byAgent);
1020
+ }
1021
+ let byWidth = byAgent.get(agentId);
1022
+ if (byWidth === void 0) {
1023
+ byWidth = /* @__PURE__ */ new Map();
1024
+ byAgent.set(agentId, byWidth);
1025
+ }
1026
+ const cached = byWidth.get(width);
1027
+ if (cached !== void 0) return cached;
1028
+ const rows = [];
1029
+ for (const turn of history) {
1030
+ rows.push(turnDivider(turn, width));
1031
+ const agent = turn.agents[agentId];
1032
+ if (agent && agent.logs.length > 0) pushRows(rows, wrapLog(agent.logs, width));
1033
+ }
1034
+ if (byWidth.size >= MAX_CACHED_WIDTHS) byWidth.clear();
1035
+ byWidth.set(width, rows);
1036
+ return rows;
1037
+ }
1038
+ function liveRows(state, agentId, width) {
1039
+ const agent = state.agents[agentId];
1040
+ if (!agent || agent.logs.length === 0) return [];
1041
+ const rows = wrapLog(agent.logs, width);
1042
+ if (state.history.length === 0) return rows;
1043
+ const out = [turnDivider(state, width)];
1044
+ pushRows(out, rows);
1045
+ return out;
1046
+ }
1047
+ function timelineParts(state, agentId, width) {
1048
+ const history = historyRows(state.history, agentId, width);
1049
+ const live = liveRows(state, agentId, width);
1050
+ if (live.length === 0) return history.length === 0 ? [] : [history];
1051
+ return history.length === 0 ? [live] : [history, live];
1052
+ }
1053
+ function streamMatches(state, query, width) {
1054
+ if (!state.focusId || !query.trim()) return [];
1055
+ const needle = query.toLowerCase();
1056
+ return timelineParts(state, state.focusId, width).flat().flatMap((row, index) => row.toLowerCase().includes(needle) ? [index] : []);
931
1057
  }
932
1058
  function snapshot(state) {
933
1059
  return {
@@ -946,6 +1072,11 @@ function snapshot(state) {
946
1072
  autoFollow: state.autoFollow,
947
1073
  scroll: state.scroll,
948
1074
  success: state.success,
1075
+ work: state.work,
1076
+ routing: state.routing,
1077
+ sessionEvents: state.sessionEvents,
1078
+ agentResults: state.agentResults,
1079
+ effectiveAgentResults: state.effectiveAgentResults,
949
1080
  acceptance: state.acceptance,
950
1081
  totalDuration: state.totalDuration,
951
1082
  totalCostUsd: state.totalCostUsd,
@@ -954,14 +1085,6 @@ function snapshot(state) {
954
1085
  reply: state.reply
955
1086
  };
956
1087
  }
957
- function mapShown(state, fn) {
958
- if (state.view) {
959
- const next2 = fn(state.view);
960
- return next2 === state.view ? state : { ...state, view: next2 };
961
- }
962
- const next = fn(state);
963
- return next === state ? state : { ...state, ...next };
964
- }
965
1088
  function recordedTurns(state) {
966
1089
  return [...state.history, ...state.phase === "complete" ? [snapshot(state)] : []];
967
1090
  }
@@ -970,12 +1093,18 @@ function conversationTurns(state) {
970
1093
  for (const turn of recordedTurns(state)) {
971
1094
  if (turn.phase !== "complete" || turn.cancelled || !turn.requirement) continue;
972
1095
  turns.push(
973
- turn.reply ? { requirement: turn.requirement, reply: turn.reply } : { requirement: turn.requirement, outcome: conversationOutcome(turn) }
1096
+ turn.reply ? { requirement: turn.requirement, reply: turn.reply } : { requirement: turn.requirement, outcome: conversationOutcome(turn), work: turn.work }
974
1097
  );
975
1098
  }
976
1099
  return turns;
977
1100
  }
1101
+ function latestWorkFromTurns(turns) {
1102
+ const latest = [...turns].reverse().find((turn) => turn.work || !turn.reply && turn.stages.length > 0);
1103
+ if (!latest?.work || latest.cancelled) return void 0;
1104
+ return { ...latest.work, outcome: conversationOutcome(latest) };
1105
+ }
978
1106
  function conversationOutcome(turn) {
1107
+ if (!turn.interrupted && !turn.cancelled && turn.work?.outcome) return turn.work.outcome;
979
1108
  const reviewer = turn.acceptance?.reviewer;
980
1109
  const isWorker = (id) => id !== reviewer && !turn.leaderIds.includes(id);
981
1110
  const workers = Object.values(turn.agents).filter((agent) => isWorker(agent.id));
@@ -1035,68 +1164,55 @@ function tuiReducer(state, action) {
1035
1164
  history,
1036
1165
  // A new requirement also replaces the previous leader metadata; leaving
1037
1166
  // it up would make the old answer or routing reason look like the
1038
- // response to the new request. Reading a past turn ends for the same
1039
- // reason — the panes belong to the run.
1167
+ // response to the new request.
1040
1168
  reason: void 0,
1041
1169
  reply: void 0,
1042
- view: void 0,
1043
1170
  // A console setting, carried across turns rather than reset with them.
1044
1171
  verbose: state.verbose
1045
1172
  };
1046
1173
  }
1047
- case "focus:cycle":
1048
- return mapShown(state, (turn) => {
1049
- const ids = Object.keys(turn.agents);
1050
- if (ids.length === 0) return turn;
1051
- const current = turn.focusId ? ids.indexOf(turn.focusId) : -1;
1052
- const nextIndex = (current + action.delta + ids.length) % ids.length;
1053
- if (nextIndex === current) return turn;
1054
- return { ...turn, focusId: ids[nextIndex], autoFollow: false, scroll: 0 };
1055
- });
1056
- case "focus:set":
1057
- return mapShown(state, (turn) => {
1058
- if (!turn.agents[action.agentId]) return turn;
1059
- if (turn.focusId === action.agentId && !turn.autoFollow && turn.scroll === 0) return turn;
1060
- return { ...turn, focusId: action.agentId, autoFollow: false, scroll: 0 };
1061
- });
1062
- case "stream:scroll":
1063
- return mapShown(state, (turn) => {
1064
- const rows = focusedRowCount(turn, action.width);
1065
- const maxScroll = Math.max(0, rows - Math.max(1, action.viewport));
1066
- const next = Math.min(Math.max(0, turn.scroll + action.delta), maxScroll);
1067
- return next === turn.scroll ? turn : { ...turn, scroll: next };
1068
- });
1069
- case "stream:search":
1070
- return mapShown(state, (turn) => {
1071
- const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
1072
- if (!agent?.logs || action.query === "") return turn;
1073
- const rows = wrapLog(agent.logs, action.width);
1074
- const needle = action.query.toLowerCase();
1075
- let index = -1;
1076
- for (let i = rows.length - 1; i >= 0; i--) {
1077
- if (rows[i].toLowerCase().includes(needle)) {
1078
- index = i;
1079
- break;
1080
- }
1081
- }
1082
- if (index === -1) return turn;
1083
- const height = Math.max(1, action.viewport);
1084
- const maxScroll = Math.max(0, rows.length - height);
1085
- const scroll = Math.min(Math.max(0, rows.length - 1 - index), maxScroll);
1086
- return scroll === turn.scroll && !turn.autoFollow ? turn : { ...turn, scroll, autoFollow: false };
1087
- });
1174
+ case "focus:cycle": {
1175
+ const ids = Object.keys(state.agents);
1176
+ if (ids.length === 0) return state;
1177
+ const current = state.focusId ? ids.indexOf(state.focusId) : -1;
1178
+ const nextIndex = (current + action.delta + ids.length) % ids.length;
1179
+ if (nextIndex === current) return state;
1180
+ return { ...state, focusId: ids[nextIndex], autoFollow: false, scroll: 0 };
1181
+ }
1182
+ case "focus:set": {
1183
+ if (!state.agents[action.agentId]) return state;
1184
+ if (state.focusId === action.agentId && !state.autoFollow && state.scroll === 0) return state;
1185
+ return { ...state, focusId: action.agentId, autoFollow: false, scroll: 0 };
1186
+ }
1187
+ case "stream:scroll": {
1188
+ const focusId = state.focusId;
1189
+ if (!focusId) return state;
1190
+ const rows = totalRows(timelineParts(state, focusId, action.width));
1191
+ const maxScroll = Math.max(0, rows - Math.max(1, action.viewport));
1192
+ const next = Math.min(Math.max(0, state.scroll + action.delta), maxScroll);
1193
+ return next === state.scroll ? state : { ...state, scroll: next };
1194
+ }
1195
+ case "stream:search": {
1196
+ const focusId = state.focusId;
1197
+ if (!focusId || action.query === "") return state;
1198
+ const parts = timelineParts(state, focusId, action.width);
1199
+ const matches = streamMatches(state, action.query, action.width);
1200
+ const index = matches[action.match ?? matches.length - 1];
1201
+ if (index === void 0) return state;
1202
+ const total = totalRows(parts);
1203
+ const height = Math.max(1, action.viewport);
1204
+ const maxScroll = Math.max(0, total - height);
1205
+ const scroll = Math.min(Math.max(0, total - 1 - index), maxScroll);
1206
+ return scroll === state.scroll && !state.autoFollow ? state : { ...state, scroll, autoFollow: false };
1207
+ }
1088
1208
  case "stream:latest":
1089
- return mapShown(
1090
- state,
1091
- (turn) => turn.scroll === 0 && turn.autoFollow ? turn : { ...turn, scroll: 0, autoFollow: true }
1092
- );
1093
- case "stream:clear":
1094
- return mapShown(state, (turn) => {
1095
- const id = turn.focusId;
1096
- const agent = id ? turn.agents[id] : void 0;
1097
- if (!id || !agent) return turn;
1098
- return { ...turn, agents: { ...turn.agents, [id]: { ...agent, logs: [] } }, scroll: 0 };
1099
- });
1209
+ return state.scroll === 0 && state.autoFollow ? state : { ...state, scroll: 0, autoFollow: true };
1210
+ case "stream:clear": {
1211
+ const id = state.focusId;
1212
+ const agent = id ? state.agents[id] : void 0;
1213
+ if (!id || !agent) return state;
1214
+ return { ...state, agents: { ...state.agents, [id]: { ...agent, logs: [] } }, scroll: 0 };
1215
+ }
1100
1216
  case "stream:note": {
1101
1217
  const agent = state.agents[action.agentId];
1102
1218
  if (!agent) return state;
@@ -1108,12 +1224,6 @@ function tuiReducer(state, action) {
1108
1224
  }
1109
1225
  };
1110
1226
  }
1111
- case "view:open": {
1112
- const record = state.history.find((turn) => turn.turn === action.turn);
1113
- return record ? { ...state, view: record } : state;
1114
- }
1115
- case "view:close":
1116
- return state.view ? { ...state, view: void 0 } : state;
1117
1227
  case "verbose:toggle":
1118
1228
  return { ...state, verbose: !state.verbose };
1119
1229
  case "session:reset":
@@ -1148,7 +1258,6 @@ function seededState(state, agents, leaderIds, history) {
1148
1258
  autoFollow: true,
1149
1259
  scroll: 0,
1150
1260
  history,
1151
- view: void 0,
1152
1261
  verbose: state.verbose
1153
1262
  };
1154
1263
  }
@@ -1156,6 +1265,7 @@ function stageStatus(stage, agents) {
1156
1265
  if (stage.outcome !== void 0) return stage.outcome;
1157
1266
  const statuses = stage.agents.map((a) => agents[a.id]?.status ?? "pending");
1158
1267
  if (statuses.some((s) => s === "failed")) return "failed";
1268
+ if (statuses.some((s) => s === "handed_back")) return "handed_back";
1159
1269
  if (statuses.some((s) => s === "running" || s === "retrying")) return "running";
1160
1270
  if (statuses.length > 0 && statuses.every((s) => s === "success")) return "success";
1161
1271
  if (statuses.length > 0 && statuses.every((s) => s === "skipped")) return "skipped";
@@ -1163,6 +1273,7 @@ function stageStatus(stage, agents) {
1163
1273
  return "pending";
1164
1274
  }
1165
1275
  var SETTLED = /* @__PURE__ */ new Set([
1276
+ "handed_back",
1166
1277
  "success",
1167
1278
  "failed",
1168
1279
  "cancelled",
@@ -1184,18 +1295,8 @@ function turnElapsed(turn, now) {
1184
1295
  const end = turn.phase === "running" ? now : turn.endedAt ?? now;
1185
1296
  return Math.max(0, end - turn.startedAt);
1186
1297
  }
1187
- function visibleLog(turn, viewport, width) {
1188
- const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
1189
- const all = agent?.logs ? wrapLog(agent.logs, width) : [];
1190
- const height = Math.max(1, viewport);
1191
- const maxScroll = Math.max(0, all.length - height);
1192
- const scrolledBack = Math.min(Math.max(0, turn.scroll), maxScroll);
1193
- const end = all.length - scrolledBack;
1194
- return {
1195
- rows: all.slice(Math.max(0, end - height), end),
1196
- scrolledBack,
1197
- following: scrolledBack === 0
1198
- };
1298
+ function visibleTimeline(state, agentId, viewport, width, scroll) {
1299
+ return windowParts(timelineParts(state, agentId, width), viewport, scroll);
1199
1300
  }
1200
1301
 
1201
1302
  // src/tui/interaction.ts
@@ -1452,7 +1553,7 @@ function formComplete(fields, state) {
1452
1553
  }
1453
1554
  var MAX_FORM_FIELDS = 6;
1454
1555
  function questionBarRows(fields) {
1455
- return 2 + Math.min(fields.length, MAX_FORM_FIELDS) + 1 + 1;
1556
+ return 2 + Math.min(fields.length, MAX_FORM_FIELDS) + 1 + 2;
1456
1557
  }
1457
1558
  function interactionRows(interactions, width) {
1458
1559
  const front = interactions[0];
@@ -1480,11 +1581,11 @@ var HELP_GROUPS = [
1480
1581
  {
1481
1582
  title: "panes",
1482
1583
  rows: [
1483
- ["tab \u21E7tab", "Next / previous agent"],
1584
+ ["tab", "Next agent"],
1585
+ ["\u21E7tab", "Toggle permission mode"],
1484
1586
  ["\u2191 \u2193", "Scroll the stream one line"],
1485
1587
  ["pgup pgdn", "Scroll the stream a page"],
1486
- ["ctrl+l", "Clear the focused log"],
1487
- ["esc", "Stop reading a past turn"]
1588
+ ["ctrl+l", "Clear the current turn's output"]
1488
1589
  ]
1489
1590
  },
1490
1591
  {
@@ -1583,18 +1684,22 @@ function PermissionBar({
1583
1684
  others,
1584
1685
  cursor,
1585
1686
  released,
1586
- width
1687
+ width,
1688
+ maxRows = Number.POSITIVE_INFINITY
1587
1689
  }) {
1588
1690
  const choices = permissionChoices(request);
1589
- const impact = describeImpact(request, Math.max(8, width - 6));
1590
- const also = waitingLine(others);
1691
+ const capacity = Math.max(1, Math.min(choices.length, maxRows - 3));
1692
+ const start = listWindowStart(cursor ?? 0, choices.length, capacity);
1693
+ const extra = Math.max(0, maxRows - 3 - capacity);
1694
+ const impact = describeImpact(request, Math.max(8, width - 6)).slice(0, extra);
1695
+ const also = extra > impact.length ? waitingLine(others) : void 0;
1591
1696
  const tool = clean(request.toolName ?? request.kind ?? "tool");
1592
1697
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, width, children: [
1593
1698
  /* @__PURE__ */ jsx3(Text3, { bold: true, color: COLOR.warn, wrap: "truncate-end", children: `permission \xB7 ${clean(request.agentId)} \xB7 ${tool}${others.length > 0 ? ` \xB7 ${others.length + 1} waiting` : ""}` }),
1594
1699
  /* @__PURE__ */ jsx3(Text3, { wrap: "truncate-end", children: ` ${truncate(clean(request.title), Math.max(8, width - 4))}` }),
1595
1700
  impact.map((line) => /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` ${line}` }, line)),
1596
- choices.map((choice, index) => {
1597
- const active = index === cursor;
1701
+ choices.slice(start, start + capacity).map((choice, index) => {
1702
+ const active = start + index === cursor;
1598
1703
  return /* @__PURE__ */ jsx3(
1599
1704
  Text3,
1600
1705
  {
@@ -1607,7 +1712,7 @@ function PermissionBar({
1607
1712
  );
1608
1713
  }),
1609
1714
  also === void 0 ? null : /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` ${also}` }),
1610
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: released ? " typing \xB7 /approve 1 or /deny still reach it" : " \u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type instead" })
1715
+ maxRows < 4 ? null : /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: released ? " typing \xB7 esc to return to permission options" : " \u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type instead" })
1611
1716
  ] });
1612
1717
  }
1613
1718
  function QuestionForm({
@@ -1616,18 +1721,26 @@ function QuestionForm({
1616
1721
  fields,
1617
1722
  form,
1618
1723
  draft,
1619
- width
1724
+ width,
1725
+ maxRows = Number.POSITIVE_INFINITY
1620
1726
  }) {
1621
1727
  const focused = fields[form.field];
1622
1728
  const titleWidth = Math.min(
1623
1729
  24,
1624
1730
  Math.max(4, ...fields.map((field) => field.title.length + (field.required ? 2 : 0)))
1625
1731
  );
1626
- const capacity = Math.min(fields.length, MAX_FORM_FIELDS);
1627
- const start = listWindowStart(form.field, fields.length, Math.max(1, capacity));
1732
+ const actions = ["Submit answers", "Skip question", "Cancel request"];
1733
+ const actionIndex = form.field - fields.length;
1734
+ const compact = maxRows < 7;
1735
+ const capacity = compact ? focused ? 1 : 0 : Math.min(fields.length, MAX_FORM_FIELDS, maxRows - 5);
1736
+ const start = listWindowStart(
1737
+ Math.min(form.field, fields.length - 1),
1738
+ fields.length,
1739
+ Math.max(1, capacity)
1740
+ );
1628
1741
  const visible = fields.slice(start, start + capacity);
1629
- const range = fields.length > capacity ? ` \xB7 ${start + 1}\u2013${start + visible.length}/${fields.length}` : "";
1630
- const detail = focused === void 0 ? [] : detailLines(focused, width);
1742
+ const range = fields.length > capacity && focused ? ` \xB7 field ${form.field + 1}/${fields.length}` : "";
1743
+ const detail = focused && (!compact || maxRows >= 5) ? detailLines(focused, width).slice(0, 1) : [];
1631
1744
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, width, children: [
1632
1745
  /* @__PURE__ */ jsx3(Text3, { bold: true, color: COLOR.warn, wrap: "truncate-end", children: `question \xB7 ${clean(request.agentId)}${others.length > 0 ? ` \xB7 ${others.length + 1} waiting` : ""}` }),
1633
1746
  /* @__PURE__ */ jsx3(Text3, { wrap: "truncate-end", children: clean(request.message) }),
@@ -1648,7 +1761,8 @@ function QuestionForm({
1648
1761
  );
1649
1762
  }),
1650
1763
  detail.map((line) => /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` ${line}` }, line)),
1651
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` tab next \xB7 \u2191\u2193 pick \xB7 space toggle \xB7 enter next${range} \xB7 esc clear` })
1764
+ compact && focused ? null : /* @__PURE__ */ jsx3(Text3, { color: actionIndex >= 0 ? COLOR.accent : void 0, wrap: "truncate-end", children: compact || width < 72 ? actionIndex >= 0 ? `\u203A ${actions[actionIndex]}` : "tab to submit / skip / cancel" : actions.map((label, index) => (actionIndex === index ? "\u203A " : " ") + label).join(" \xB7 ") }),
1765
+ maxRows < 4 ? null : /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` tab next \xB7 \u2191\u2193 pick \xB7 space toggle \xB7 enter next${range} \xB7 esc clear` })
1652
1766
  ] });
1653
1767
  }
1654
1768
  function fieldValue(field, form, draft, focused) {
@@ -1678,6 +1792,7 @@ function detailLines(field, width) {
1678
1792
  }
1679
1793
 
1680
1794
  // src/tui/components/panels.tsx
1795
+ import { describeSession } from "@multiclawcli/core";
1681
1796
  import { Box as Box4, Text as Text4 } from "ink";
1682
1797
  import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1683
1798
  function rightColumn(agent, now) {
@@ -1766,21 +1881,27 @@ function Divider() {
1766
1881
  );
1767
1882
  }
1768
1883
  function StreamPanel({
1769
- turn,
1884
+ state,
1770
1885
  height,
1771
1886
  width,
1772
1887
  now,
1773
1888
  spinner
1774
1889
  }) {
1775
- const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
1890
+ const agent = state.focusId ? state.agents[state.focusId] : void 0;
1776
1891
  const viewport = Math.max(1, height - 1);
1777
- const window = visibleLog(turn, viewport, width);
1892
+ const window = visibleTimeline(state, state.focusId ?? "", viewport, width, state.scroll);
1778
1893
  const style = agent ? STATUS[agent.status] : void 0;
1779
1894
  const elapsed = agent ? agentElapsed(agent, now) : void 0;
1895
+ const focusId = state.focusId;
1896
+ const turnCount = focusId ? recordedTurns(state).filter((turn) => (turn.agents[focusId]?.logs.length ?? 0) > 0).length : 0;
1780
1897
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", flexGrow: 1, height, paddingLeft: 1, children: [
1781
1898
  /* @__PURE__ */ jsxs4(Box4, { justifyContent: "space-between", children: [
1782
1899
  /* @__PURE__ */ jsx4(Box4, { flexShrink: 1, children: /* @__PURE__ */ jsxs4(Text4, { wrap: "truncate-end", children: [
1783
1900
  /* @__PURE__ */ jsx4(Text4, { bold: true, children: agent?.name ?? "\u2014" }),
1901
+ agent?.session ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1902
+ " \xB7 ",
1903
+ describeSession(agent.session)
1904
+ ] }) : null,
1784
1905
  agent?.model ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1785
1906
  " \xB7 ",
1786
1907
  agent.model
@@ -1803,6 +1924,11 @@ function StreamPanel({
1803
1924
  agent?.costUsd !== void 0 ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1804
1925
  " \xB7 ",
1805
1926
  formatCost(agent.costUsd)
1927
+ ] }) : null,
1928
+ turnCount > 1 ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1929
+ " \xB7 ",
1930
+ turnCount,
1931
+ " turns"
1806
1932
  ] }) : null
1807
1933
  ] }) }),
1808
1934
  /* @__PURE__ */ jsxs4(Box4, { flexShrink: 0, children: [
@@ -1813,13 +1939,14 @@ function StreamPanel({
1813
1939
  /* @__PURE__ */ jsx4(Text4, { color: window.following ? COLOR.accent : COLOR.warn, children: window.following ? GLYPH.live : `${GLYPH.paused} ${window.scrolledBack}\u2191` })
1814
1940
  ] })
1815
1941
  ] }),
1816
- window.rows.length === 0 ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: turn.phase === "running" ? "waiting for output\u2026" : "no output yet" }) : (
1817
- // The tail, already wrapped to `width` by `visibleLog` — the pane draws
1818
- // rows, and one log line is several of them. `visibleLog` also picks
1819
- // which rows, so the content here is exactly the pane's height and the
1820
- // oldest rows are the ones already gone: nothing is left to clip, which
1821
- // is why this box is not bottom-pinned. Pinning it would only leave a
1822
- // gap under the header whenever an agent has not said much yet.
1942
+ window.rows.length === 0 ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: state.phase === "running" ? "waiting for output\u2026" : "no output yet" }) : (
1943
+ // The tail, already wrapped to `width` by `visibleTimeline` — the pane
1944
+ // draws rows, and one log line is several of them. `visibleTimeline`
1945
+ // also picks which rows, so the content here is exactly the pane's
1946
+ // height and the oldest rows are the ones already gone: nothing is left
1947
+ // to clip, which is why this box is not bottom-pinned. Pinning it would
1948
+ // only leave a gap under the header whenever an agent has not said much
1949
+ // yet.
1823
1950
  //
1824
1951
  // Rows are rendered with `truncate-end` rather than letting Ink wrap
1825
1952
  // them again: they are supposed to fit, and if `width` was ever
@@ -1996,6 +2123,7 @@ function CommandMenu({ rows, selected }) {
1996
2123
  // src/tui/history.ts
1997
2124
  import * as fs3 from "fs";
1998
2125
  import * as path3 from "path";
2126
+ import { describeSession as describeSession2, isSessionEvent, isSessionRecord, isWorkContext } from "@multiclawcli/core";
1999
2127
 
2000
2128
  // src/tui/sessions.ts
2001
2129
  import { randomUUID } from "crypto";
@@ -2094,14 +2222,24 @@ function isAcceptance(value) {
2094
2222
  function isTurnView(value) {
2095
2223
  if (!isObject(value)) return false;
2096
2224
  const turn = value;
2097
- 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(
2098
- (stage) => isObject(stage) && (stage.outcome === void 0 || ["pending", "running", "retrying", "success", "failed", "cancelled", "skipped"].includes(
2099
- stage.outcome
2100
- )) && (stage.memberStatuses === void 0 || isObject(stage.memberStatuses) && Object.values(stage.memberStatuses).every(
2225
+ 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.work === void 0 || isWorkContext(turn.work)) && (turn.sessionEvents === void 0 || Array.isArray(turn.sessionEvents) && turn.sessionEvents.every(isSessionEvent)) && (turn.acceptance === void 0 || isAcceptance(turn.acceptance)) && (turn.effectiveRequirement === void 0 || typeof turn.effectiveRequirement === "string") && isObject(turn.agents) && Object.values(turn.agents).every(
2226
+ (agent) => isObject(agent) && (agent.session === void 0 || isSessionRecord(agent.session))
2227
+ ) && Array.isArray(turn.stages) && turn.stages.every(
2228
+ (stage) => isObject(stage) && (stage.outcome === void 0 || [
2229
+ "pending",
2230
+ "running",
2231
+ "retrying",
2232
+ "handed_back",
2233
+ "success",
2234
+ "failed",
2235
+ "cancelled",
2236
+ "skipped"
2237
+ ].includes(stage.outcome)) && (stage.memberStatuses === void 0 || isObject(stage.memberStatuses) && Object.values(stage.memberStatuses).every(
2101
2238
  (status) => typeof status === "string" && [
2102
2239
  "pending",
2103
2240
  "running",
2104
2241
  "retrying",
2242
+ "handed_back",
2105
2243
  "success",
2106
2244
  "failed",
2107
2245
  "cancelled",
@@ -2144,6 +2282,27 @@ async function exportTurn(turn, filePath) {
2144
2282
  function formatTurnMarkdown(turn) {
2145
2283
  const lines = [`# Turn ${turn.turn}`, ""];
2146
2284
  if (turn.requirement) lines.push("## Requirement", "", turn.requirement, "");
2285
+ if (turn.effectiveRequirement && turn.effectiveRequirement !== turn.requirement)
2286
+ lines.push("## Complete requirement", "", turn.effectiveRequirement, "");
2287
+ if (turn.routing) {
2288
+ lines.push(
2289
+ "## Routing",
2290
+ "",
2291
+ "Type: " + turn.routing.mode,
2292
+ "Work: " + (turn.routing.workId ?? "none"),
2293
+ "Reroutes: " + turn.routing.reroutes,
2294
+ ""
2295
+ );
2296
+ if (turn.routing.error) lines.push("Incomplete: " + turn.routing.error, "");
2297
+ }
2298
+ if (turn.agentResults?.length) {
2299
+ lines.push("## Execution attempts", "");
2300
+ for (const result of turn.agentResults)
2301
+ lines.push(
2302
+ "- " + result.agentId + ": " + result.status + (result.handoffReason ? " \u2014 " + result.handoffReason : "") + (result.error ? " \u2014 " + result.error : "")
2303
+ );
2304
+ lines.push("");
2305
+ }
2147
2306
  if (turn.reply) {
2148
2307
  lines.push("## Reply", "", `_${turn.reply.mode}_`, "", turn.reply.message, "");
2149
2308
  } else {
@@ -2164,11 +2323,20 @@ function formatTurnMarkdown(turn) {
2164
2323
  }
2165
2324
  }
2166
2325
  }
2326
+ if (turn.sessionEvents?.length) {
2327
+ lines.push("## Session lifecycle", "");
2328
+ for (const event of turn.sessionEvents)
2329
+ lines.push(
2330
+ "- " + event.record.agentId + ": " + event.action + (event.requestedSessionId ? " target=" + event.requestedSessionId : "") + " \u2014 " + describeSession2(event.record) + (event.reason ? " \u2014 " + event.reason : "")
2331
+ );
2332
+ lines.push("");
2333
+ }
2167
2334
  const agents = Object.values(turn.agents);
2168
2335
  if (agents.length > 0) {
2169
2336
  lines.push("## Agents", "");
2170
2337
  for (const agent of agents) {
2171
2338
  lines.push(`- ${agent.id}: ${agent.name} \u2014 ${agent.status}`);
2339
+ if (agent.session) lines.push(" " + describeSession2(agent.session));
2172
2340
  }
2173
2341
  lines.push("");
2174
2342
  lines.push("## Logs", "");
@@ -2184,6 +2352,7 @@ function formatTurnMarkdown(turn) {
2184
2352
  // src/tui/session.ts
2185
2353
  import * as fs4 from "fs";
2186
2354
  import * as path4 from "path";
2355
+ import { isSessionEvent as isSessionEvent2, isSessionRecord as isSessionRecord2, isWorkContext as isWorkContext2 } from "@multiclawcli/core";
2187
2356
  var SESSION_FILE = "session.json";
2188
2357
  var SESSION_VERSION = 1;
2189
2358
  var MAX_JOURNAL_LOG_CHARS = 1200;
@@ -2197,6 +2366,7 @@ var STATUSES = /* @__PURE__ */ new Set([
2197
2366
  "pending",
2198
2367
  "running",
2199
2368
  "retrying",
2369
+ "handed_back",
2200
2370
  "success",
2201
2371
  "failed",
2202
2372
  "cancelled",
@@ -2204,11 +2374,11 @@ var STATUSES = /* @__PURE__ */ new Set([
2204
2374
  ]);
2205
2375
  function isAgentView(value) {
2206
2376
  if (!isObject2(value)) return false;
2207
- return typeof value.id === "string" && typeof value.name === "string" && Array.isArray(value.logs) && typeof value.status === "string" && STATUSES.has(value.status);
2377
+ return typeof value.id === "string" && typeof value.name === "string" && (value.session === void 0 || isSessionRecord2(value.session)) && Array.isArray(value.logs) && typeof value.status === "string" && STATUSES.has(value.status);
2208
2378
  }
2209
2379
  function isJournalTurn(value) {
2210
2380
  if (!isObject2(value)) return false;
2211
- 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));
2381
+ return Number.isInteger(value.turn) && typeof value.requirement === "string" && typeof value.savedAt === "number" && Number.isFinite(value.savedAt) && (value.work === void 0 || isWorkContext2(value.work)) && (value.sessionEvents === void 0 || Array.isArray(value.sessionEvents) && value.sessionEvents.every(isSessionEvent2)) && (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));
2212
2382
  }
2213
2383
  function loadJournal(workDir, sessionId) {
2214
2384
  try {
@@ -2256,6 +2426,10 @@ function journalTurn(state, pending) {
2256
2426
  }
2257
2427
  return {
2258
2428
  turn: state.turn,
2429
+ work: state.work,
2430
+ routing: state.routing,
2431
+ sessionEvents: state.sessionEvents,
2432
+ agentResults: state.agentResults,
2259
2433
  requirement: state.requirement ?? "",
2260
2434
  startedAt: state.startedAt,
2261
2435
  effectiveRequirement: state.effectiveRequirement,
@@ -2273,10 +2447,24 @@ var UNSETTLED = /* @__PURE__ */ new Set(["pending", "running", "retrying"]);
2273
2447
  function interruptedTurn(entry) {
2274
2448
  const agents = {};
2275
2449
  for (const [id, agent] of Object.entries(entry.agents)) {
2276
- agents[id] = UNSETTLED.has(agent.status) ? { ...agent, status: "cancelled" } : { ...agent };
2450
+ agents[id] = {
2451
+ ...agent,
2452
+ ...UNSETTLED.has(agent.status) ? { status: "cancelled" } : {},
2453
+ session: agent.session ? { ...agent.session, state: agent.session.state === "closed" ? "closed" : "unknown" } : void 0
2454
+ };
2277
2455
  }
2278
2456
  return {
2279
2457
  turn: entry.turn,
2458
+ work: entry.work ? {
2459
+ ...entry.work,
2460
+ agents: entry.work.agents.map((agent) => ({
2461
+ ...agent,
2462
+ session: agent.session ? { ...agent.session, state: agent.session.state === "closed" ? "closed" : "unknown" } : void 0
2463
+ }))
2464
+ } : void 0,
2465
+ routing: entry.routing,
2466
+ sessionEvents: entry.sessionEvents,
2467
+ agentResults: entry.agentResults,
2280
2468
  requirement: entry.requirement,
2281
2469
  effectiveRequirement: entry.effectiveRequirement,
2282
2470
  reason: entry.reason,
@@ -2339,6 +2527,16 @@ var SESSION_SAVE_INTERVAL_MS = 2e3;
2339
2527
  var INPUT_PLACEHOLDER = "describe what the team should do\u2026";
2340
2528
  var RUNNING_PLACEHOLDER = "type to queue a follow-up\u2026";
2341
2529
  var EMPTY_LINE = { text: "", cursor: 0 };
2530
+ function retryCandidates(state) {
2531
+ const turn = recordedTurns(state).slice(-1)[0];
2532
+ if (!turn?.requirement) return [];
2533
+ if (turn.acceptance && turn.acceptance.status !== "approved") {
2534
+ return [
2535
+ ...new Set(turn.stages.flatMap((stage) => stage.agents.map((agent) => agent.id)))
2536
+ ].filter((id) => id !== turn.acceptance?.reviewer);
2537
+ }
2538
+ return Object.values(turn.agents).filter((agent) => agent.status === "failed").map((agent) => agent.id);
2539
+ }
2342
2540
  function pickRecordedTurn(state, raw, whenEmpty) {
2343
2541
  const turns = recordedTurns(state);
2344
2542
  if (turns.length === 0) return { error: whenEmpty };
@@ -2352,20 +2550,6 @@ function pickTurn(state, raw, whenEmpty) {
2352
2550
  if (!target.record.requirement) return { error: `turn ${target.record.turn} has no requirement` };
2353
2551
  return { record: target.record, requirement: target.record.requirement };
2354
2552
  }
2355
- function turnSessions(turn) {
2356
- const sessions = {};
2357
- for (const agent of Object.values(turn.agents)) {
2358
- if (agent.sessionId) sessions[agent.id] = agent.sessionId;
2359
- }
2360
- return sessions;
2361
- }
2362
- function continuationResume(continuation, live) {
2363
- if (!continuation) return void 0;
2364
- const sessions = Object.fromEntries(
2365
- Object.entries(continuation.sessions).filter(([id]) => !live?.has(id))
2366
- );
2367
- return Object.keys(sessions).length > 0 ? { sessions } : void 0;
2368
- }
2369
2553
  function StatusLine({
2370
2554
  state,
2371
2555
  running,
@@ -2382,6 +2566,8 @@ function StatusLine({
2382
2566
  return /* @__PURE__ */ jsx6(Text6, { color: COLOR.warn, wrap: "truncate-end", children: state.warning });
2383
2567
  if ((cancelled || state.cancelled) && !running)
2384
2568
  return /* @__PURE__ */ jsx6(Text6, { color: COLOR.warn, children: "cancelled \xB7 workspace changes are retained" });
2569
+ if (state.routing?.error && !running)
2570
+ return /* @__PURE__ */ jsx6(Text6, { color: COLOR.warn, wrap: "truncate-end", children: state.routing.error });
2385
2571
  if (state.reply) {
2386
2572
  const asking = state.reply.mode === "ask";
2387
2573
  return /* @__PURE__ */ jsx6(Text6, { color: asking ? COLOR.warn : COLOR.ok, wrap: "truncate-end", children: asking ? `${GLYPH.ask} needs your input` : `${STATUS.success.glyph} answered` });
@@ -2470,10 +2656,19 @@ function App({
2470
2656
  queueRef.current = queue;
2471
2657
  const [cancelled, setCancelled] = useState(false);
2472
2658
  const [error, setError] = useState();
2659
+ const [permissionMode, setPermissionMode] = useState(
2660
+ // Same precedence the runner uses, so the header can never announce a mode
2661
+ // the agents are not actually running under. `ask` when nobody configured
2662
+ // one, because a human is sitting here to answer.
2663
+ resolvePermissionMode(config) ?? "ask"
2664
+ );
2473
2665
  const [notice, setNotice] = useState(
2474
2666
  launchNoticeRef.current ?? restored.notice
2475
2667
  );
2476
2668
  const [helpOpen, setHelpOpen] = useState(false);
2669
+ const [picker, setPicker] = useState(null);
2670
+ const [operationInput, setOperationInput] = useState(null);
2671
+ const [searchResult, setSearchResult] = useState(null);
2477
2672
  const [sessionsOpen, setSessionsOpen] = useState(false);
2478
2673
  const [sessionCursor, setSessionCursor] = useState(0);
2479
2674
  const [sessionList, setSessionList] = useState([]);
@@ -2484,15 +2679,6 @@ function App({
2484
2679
  const abortRef = useRef(null);
2485
2680
  const sessionsRef = useRef(null);
2486
2681
  if (!sessionsRef.current) sessionsRef.current = providedSessions ?? new AgentSessionManager();
2487
- const continuationRef = useRef(null);
2488
- useEffect(() => {
2489
- if (initialSession?.kind !== "resume" || !("id" in initialSession)) return;
2490
- const history = restoredRef.current?.history ?? [];
2491
- const last = history[history.length - 1];
2492
- if (last) dispatch({ type: "view:open", turn: last.turn });
2493
- const resumable = [...history].reverse().find((turn) => Object.keys(turnSessions(turn)).length > 0);
2494
- continuationRef.current = resumable ? { turn: resumable.turn, sessions: turnSessions(resumable) } : null;
2495
- }, [initialSession]);
2496
2682
  const activeTurnRef = useRef(null);
2497
2683
  const reporterErrorRef = useRef(null);
2498
2684
  const closingRef = useRef(false);
@@ -2563,16 +2749,21 @@ function App({
2563
2749
  const handleCheckpoint = useCallback((info, signal) => {
2564
2750
  if (closingRef.current || signal.aborted) return Promise.resolve({ action: "cancel" });
2565
2751
  return new Promise((resolve2) => {
2566
- checkpointResolveRef.current = resolve2;
2752
+ const settle = (response) => {
2753
+ signal.removeEventListener("abort", onAbort);
2754
+ if (checkpointResolveRef.current === settle) {
2755
+ checkpointResolveRef.current = null;
2756
+ checkpointLabelRef.current = "";
2757
+ }
2758
+ resolve2(response);
2759
+ };
2760
+ checkpointResolveRef.current = settle;
2567
2761
  checkpointLabelRef.current = info.label;
2568
2762
  const names = info.completedAgents.map((a) => a.name).join(", ");
2569
2763
  setNotice(
2570
- `checkpoint: ${info.label} \u2014 ${names} done \xB7 /continue /modify <instruction> /cancel`
2764
+ `checkpoint: ${info.label} \u2014 ${names} done \xB7 /continue \xB7 type feedback \xB7 ctrl+c cancel`
2571
2765
  );
2572
- const onAbort = () => {
2573
- checkpointResolveRef.current = null;
2574
- resolve2({ action: "cancel" });
2575
- };
2766
+ const onAbort = () => settle({ action: "cancel" });
2576
2767
  signal.addEventListener("abort", onAbort, { once: true });
2577
2768
  });
2578
2769
  }, []);
@@ -2653,9 +2844,7 @@ function App({
2653
2844
  if (closingRef.current || busyRef.current) return;
2654
2845
  busyRef.current = true;
2655
2846
  setRunning(true);
2656
- const continuation = opts?.newSession ? null : continuationRef.current;
2657
- continuationRef.current = null;
2658
- const resume = opts?.resume ?? continuationResume(continuation, sessionsRef.current);
2847
+ const resume = opts?.resume;
2659
2848
  if (opts?.newSession) {
2660
2849
  try {
2661
2850
  await sessionsRef.current?.reset();
@@ -2689,6 +2878,13 @@ function App({
2689
2878
  setError(void 0);
2690
2879
  setCancelled(false);
2691
2880
  setNotice(void 0);
2881
+ if (opts?.newSession && !requirement) {
2882
+ busyRef.current = false;
2883
+ setRunning(false);
2884
+ setSearchResult(null);
2885
+ setNotice("new session \u2014 type a requirement to begin");
2886
+ return;
2887
+ }
2692
2888
  const earlierTurns = opts?.newSession ? [] : conversationTurns(stateRef.current);
2693
2889
  dispatch({
2694
2890
  type: "turn:start",
@@ -2705,8 +2901,18 @@ function App({
2705
2901
  // An answer only reads as a requirement next to the question it answers,
2706
2902
  // so the leader is handed the turns behind this one as well.
2707
2903
  history: earlierTurns,
2904
+ latestWork: opts?.newSession ? void 0 : opts?.work ?? latestWorkFromTurns(recordedTurns(stateRef.current)),
2708
2905
  logDir,
2709
- permissions: !config.permissions || config.permissions.mode === "ask" ? { mode: "ask", onRequest: requestPermission } : config.permissions,
2906
+ permissions: permissionMode === "ask" ? { mode: "ask", onRequest: requestPermission } : { mode: permissionMode },
2907
+ // The console's mode is a live decision, so it outranks any per-agent
2908
+ // `permissions` written in the file: otherwise shift+tab would relabel
2909
+ // the header while an agent that declares its own mode kept the old
2910
+ // behaviour. Per-agent modes stay a headless-run concern.
2911
+ agents: config.agents.map((agent) => ({
2912
+ ...agent,
2913
+ permissions: { mode: permissionMode }
2914
+ })),
2915
+ ...config.leader ? { leader: { ...config.leader, permissions: { mode: permissionMode } } } : {},
2710
2916
  questions: config.questions?.onRequest ? config.questions : { onRequest: requestQuestion },
2711
2917
  onCheckpoint: config.onCheckpoint ?? handleCheckpoint,
2712
2918
  // A retry names the agents to redo; a continued conversation carries only
@@ -2771,7 +2977,8 @@ function App({
2771
2977
  requestPermission,
2772
2978
  requestQuestion,
2773
2979
  handleCheckpoint,
2774
- clearInteractions
2980
+ clearInteractions,
2981
+ permissionMode
2775
2982
  ]
2776
2983
  );
2777
2984
  useEffect(() => {
@@ -2791,21 +2998,16 @@ function App({
2791
2998
  return;
2792
2999
  }
2793
3000
  if (closingRef.current) return;
3001
+ setSearchResult(null);
2794
3002
  const restored2 = restoreConsole(config.workDir, id);
2795
3003
  sessionIdRef.current = id;
2796
3004
  persistedTurnRef.current = restored2.persistedTurn;
2797
- const resumable = [...restored2.history].reverse().find((turn) => Object.keys(turnSessions(turn)).length > 0);
2798
- continuationRef.current = resumable ? { turn: resumable.turn, sessions: turnSessions(resumable) } : null;
2799
3005
  dispatch({
2800
3006
  type: "session:load",
2801
3007
  agents: seedAgents(config),
2802
3008
  leaderIds: leaderAgentIds(config),
2803
3009
  history: restored2.history
2804
3010
  });
2805
- const lastTurn = restored2.history[restored2.history.length - 1];
2806
- if (lastTurn) {
2807
- dispatch({ type: "view:open", turn: lastTurn.turn });
2808
- }
2809
3011
  setQueue(restored2.queue);
2810
3012
  queuePausedRef.current = restored2.queue.length > 0;
2811
3013
  setQueuePaused(restored2.queue.length > 0);
@@ -2813,9 +3015,7 @@ function App({
2813
3015
  setAwaitingAnswer(restored2.awaitingAnswer);
2814
3016
  setError(void 0);
2815
3017
  setCancelled(false);
2816
- setNotice(
2817
- restored2.notice ?? (resumable ? `switched session \xB7 continue with your next instruction` : `switched session \xB7 ${restored2.history.length} turn${restored2.history.length === 1 ? "" : "s"}`)
2818
- );
3018
+ setNotice(restored2.notice ?? "switched session \xB7 route your next instruction");
2819
3019
  },
2820
3020
  [config]
2821
3021
  );
@@ -2885,28 +3085,32 @@ function App({
2885
3085
  })();
2886
3086
  return shutdownRef.current;
2887
3087
  }, [config.workDir, exit, cleanup]);
2888
- const query = menuQuery(line.text);
2889
- const matches = useMemo(
2890
- () => query === void 0 ? [] : matchCommands(query, running),
2891
- [query, running]
2892
- );
2893
- const shown = state.view ?? state;
2894
- const arg = argQuery(line.text);
2895
- const argRows = arg ? completeAgents(arg.partial, Object.keys(shown.agents)).map((id) => ({
3088
+ const commandContext = {
3089
+ history: recordedTurns(state).length > 0,
3090
+ retry: retryCandidates(state).length > 0,
3091
+ queue: queue.length,
3092
+ paused: queuePaused,
3093
+ awaitingAnswer,
3094
+ checkpoint: checkpointResolveRef.current !== null
3095
+ };
3096
+ const query = interactions[0]?.kind === "question" ? void 0 : menuQuery(line.text);
3097
+ const matches = query === void 0 ? [] : matchCommands(query, running, commandContext);
3098
+ const arg = interactions[0]?.kind === "question" ? void 0 : argQuery(line.text);
3099
+ const argRows = arg ? completeAgents(arg.partial, Object.keys(state.agents)).map((id) => ({
2896
3100
  key: id,
2897
3101
  label: id,
2898
3102
  // The static role, not the per-turn task title: while completing, the
2899
3103
  // user is asking "which agent is this", not "what is it doing now".
2900
- description: shown.agents[id]?.description ?? shown.agents[id]?.name ?? id
3104
+ description: state.agents[id]?.description ?? state.agents[id]?.name ?? id
2901
3105
  })) : [];
2902
- const allRows = arg ? argRows : paletteRows(matches);
3106
+ const allRows = picker ? picker.rows : arg ? argRows : paletteRows(matches);
2903
3107
  const paletteKey = arg ? `${arg.command.name} ${arg.partial}` : query;
2904
3108
  const paletteTotal = allRows.length;
2905
3109
  const menuIndex = menuCursor.query === paletteKey ? menuCursor.index : 0;
2906
- const selected = paletteTotal === 0 ? 0 : Math.min(menuIndex, paletteTotal - 1);
2907
- const menuOpen = !helpOpen && !sessionsOpen && query !== void 0 && matches.length > 0;
2908
- const argOpen = !helpOpen && !sessionsOpen && arg !== void 0 && argRows.length > 0;
2909
- const paletteOpen = menuOpen || argOpen;
3110
+ const selected = paletteTotal === 0 ? 0 : Math.min(picker?.cursor ?? menuIndex, paletteTotal - 1);
3111
+ const menuOpen = !picker && !operationInput && !helpOpen && !sessionsOpen && query !== void 0 && matches.length > 0;
3112
+ const argOpen = !picker && !operationInput && !helpOpen && !sessionsOpen && arg !== void 0 && argRows.length > 0;
3113
+ const paletteOpen = picker !== null || menuOpen || argOpen;
2910
3114
  const front = interactions[0];
2911
3115
  const menu = front?.kind === "permission" && permissionMenu.id === front.shortId ? permissionMenu : { id: front?.shortId ?? "", cursor: 0, released: false };
2912
3116
  const frontQuestion = front?.kind === "question" ? front : void 0;
@@ -2931,10 +3135,10 @@ function App({
2931
3135
  queueRows: queue.length,
2932
3136
  interactionRows: interactionRows(interactions, Math.max(MIN_COLUMNS, columns)),
2933
3137
  promptRows,
3138
+ details: !!(state.reason || state.routing),
2934
3139
  notice: notice !== void 0 || queuePaused && queue.length > 0,
2935
- viewing: state.view !== void 0,
2936
- hasRail: shown.leaderIds.length > 0 || shown.stages.length > 0,
2937
- railCells: railCellWidths(shown)
3140
+ hasRail: state.leaderIds.length > 0 || state.stages.length > 0,
3141
+ railCells: railCellWidths(state)
2938
3142
  });
2939
3143
  const steerRunningAgents = useCallback(async (text) => {
2940
3144
  const manager = sessionsRef.current;
@@ -2959,11 +3163,11 @@ function App({
2959
3163
  setNotice(describeSteering(reports));
2960
3164
  }, []);
2961
3165
  const runCommand = useCallback(
2962
- (name, args, rawText) => {
3166
+ (name, args) => {
2963
3167
  setLine(EMPTY_LINE);
2964
- if (checkpointResolveRef.current && name !== "continue" && name !== "modify" && name !== "cancel") {
3168
+ if (checkpointResolveRef.current && name !== "continue") {
2965
3169
  setNotice(
2966
- `checkpoint: ${checkpointLabelRef.current} \xB7 /continue /modify <instruction> /cancel`
3170
+ `checkpoint: ${checkpointLabelRef.current} \xB7 /continue \xB7 type feedback \xB7 ctrl+c cancel`
2967
3171
  );
2968
3172
  return;
2969
3173
  }
@@ -2971,111 +3175,86 @@ function App({
2971
3175
  setNotice(unavailableReason(name, running));
2972
3176
  return;
2973
3177
  }
2974
- const shown2 = state.view ?? state;
2975
3178
  if (closingRef.current) return;
2976
- switch (name) {
2977
- case "respond":
2978
- case "skip":
2979
- case "dismiss": {
2980
- const [id, ...jsonParts] = args;
2981
- if (!id || (name === "respond" ? jsonParts.length === 0 : args.length !== 1)) {
2982
- setNotice(
2983
- name === "respond" ? "usage: /respond <question id> <JSON object>" : `usage: /${name} <question id>`
2984
- );
2985
- return;
2986
- }
2987
- const matches2 = interactionsRef.current.filter(
2988
- (entry2) => entry2.request.requestId === id || entry2.shortId === id
3179
+ const choose = (rows2) => {
3180
+ setPicker({ command: name, rows: rows2, cursor: 0 });
3181
+ setNotice(`choose ${name} \xB7 enter confirm \xB7 esc cancel`);
3182
+ };
3183
+ if (args.length === 0) {
3184
+ if (name === "mode") {
3185
+ choose(
3186
+ ["ask", "auto", "deny"].map((mode) => ({
3187
+ key: mode,
3188
+ label: mode,
3189
+ description: mode === "ask" ? "Ask before tools run" : mode === "auto" ? "Allow tools automatically" : "Refuse tool requests"
3190
+ }))
2989
3191
  );
2990
- const entry = matches2.length === 1 ? matches2[0] : void 0;
2991
- if (entry?.kind !== "question") {
2992
- setNotice(`unknown or stale question request: ${id}`);
2993
- return;
2994
- }
2995
- if (name !== "respond") {
2996
- entry.settle({ action: name === "skip" ? "decline" : "cancel" });
2997
- setNotice(`${name === "skip" ? "skipped" : "dismissed"} ${entry.shortId}`);
2998
- return;
2999
- }
3000
- let content;
3001
- try {
3002
- const rawJson = /^\/respond\s+\S+\s+([\s\S]+)$/.exec(rawText ?? "")?.[1] ?? "";
3003
- content = JSON.parse(rawJson);
3004
- } catch {
3005
- setNotice("response must be a valid JSON object");
3192
+ return;
3193
+ }
3194
+ if (name === "focus") {
3195
+ choose(
3196
+ Object.values(state.agents).map((agent) => ({
3197
+ key: agent.id,
3198
+ label: agent.name,
3199
+ description: agent.description ?? agent.id
3200
+ }))
3201
+ );
3202
+ return;
3203
+ }
3204
+ if (name === "retry") {
3205
+ const ids = retryCandidates(state);
3206
+ if (ids.length === 0) {
3207
+ setNotice("nothing failed");
3006
3208
  return;
3007
3209
  }
3008
- if (!validateRuntimeQuestionContent(entry.request.requestedSchema, content)) {
3009
- setNotice(
3010
- "response does not match the question schema; check required fields and choices"
3011
- );
3210
+ choose([
3211
+ { key: "all", label: "All failed / repair agents", description: ids.join(", ") },
3212
+ ...ids.map((id) => ({
3213
+ key: id,
3214
+ label: state.agents[id]?.name ?? id,
3215
+ description: state.agents[id]?.error ?? "Retry this agent"
3216
+ }))
3217
+ ]);
3218
+ return;
3219
+ }
3220
+ if (["replay", "edit", "export"].includes(name)) {
3221
+ const turns = recordedTurns(state).slice().reverse();
3222
+ if (turns.length === 0) {
3223
+ setNotice(`nothing to ${name} yet`);
3012
3224
  return;
3013
3225
  }
3014
- entry.settle({
3015
- action: "accept",
3016
- content
3017
- });
3018
- setNotice(`responded to ${entry.shortId}`);
3226
+ choose(
3227
+ turns.map((turn) => ({
3228
+ key: String(turn.turn),
3229
+ label: `Turn ${turn.turn}`,
3230
+ description: turn.requirement?.replace(/\s+/g, " ") ?? "(no requirement)"
3231
+ }))
3232
+ );
3233
+ return;
3234
+ }
3235
+ if (name === "search" || name === "steer") {
3236
+ setOperationInput(name);
3237
+ setNotice(
3238
+ name === "search" ? "search the focused stream \xB7 enter find \xB7 esc cancel" : "message running agents \xB7 enter send \xB7 esc cancel"
3239
+ );
3019
3240
  return;
3020
3241
  }
3021
- case "approve":
3022
- case "deny": {
3023
- const onScreen = interactionsRef.current[0];
3024
- const byId = (id) => {
3025
- const found = interactionsRef.current.filter(
3026
- (item) => item.request.requestId === id || item.shortId === id
3027
- );
3028
- if (found.length !== 1) setNotice(`unknown or stale permission request: ${id}`);
3029
- return found.length === 1 ? found[0] : void 0;
3030
- };
3031
- let entry;
3032
- let number;
3033
- if (name === "approve" && args.length === 1 && /^\d+$/.test(args[0])) {
3034
- number = args[0];
3035
- entry = onScreen;
3036
- } else if (name === "approve" && args.length === 2) {
3037
- number = args[1];
3038
- entry = byId(args[0]);
3039
- } else if (name === "deny" && args.length === 0) {
3040
- entry = onScreen;
3041
- } else if (name === "deny" && args.length === 1) {
3042
- entry = byId(args[0]);
3242
+ }
3243
+ switch (name) {
3244
+ case "mode": {
3245
+ const m = args[0];
3246
+ if (m === "auto" || m === "ask" || m === "deny") {
3247
+ setPermissionMode(m);
3248
+ setNotice(`permission mode: ${m}`);
3043
3249
  } else {
3044
- setNotice(
3045
- name === "approve" ? "usage: /approve [id] <option number>" : "usage: /deny [id]"
3046
- );
3047
- return;
3048
- }
3049
- if (entry === void 0) {
3050
- if (args.length === 0 || /^\d+$/.test(args[0] ?? "")) {
3051
- setNotice("no permission request on screen");
3052
- }
3053
- return;
3054
- }
3055
- if (entry.kind !== "permission") {
3056
- setNotice("the request on screen is a question, not a permission");
3057
- return;
3058
- }
3059
- if (name === "deny") {
3060
- entry.settle();
3061
- setNotice(`denied ${entry.shortId}`);
3062
- return;
3063
- }
3064
- const option = /^\d+$/.test(number ?? "") ? entry.request.options[Number(number) - 1] : void 0;
3065
- if (!option) {
3066
- setNotice(
3067
- `invalid option for ${entry.shortId}; choose 1\u2013${entry.request.options.length}`
3068
- );
3069
- return;
3250
+ setNotice("usage: /mode auto | ask | deny");
3070
3251
  }
3071
- entry.settle(option.optionId);
3072
- setNotice(`selected ${clean(option.name)} for ${entry.shortId}`);
3073
3252
  return;
3074
3253
  }
3075
3254
  case "new": {
3076
3255
  const text = args.join(" ");
3077
- if (!text) {
3078
- setNotice("usage: /new <requirement> \u2014 starts a new session with fresh agent sessions");
3256
+ if (text) {
3257
+ setNotice("/new opens an empty session \u2014 enter the requirement afterwards");
3079
3258
  return;
3080
3259
  }
3081
3260
  setQueue([]);
@@ -3084,15 +3263,6 @@ function App({
3084
3263
  void startTurn(text, { newSession: true });
3085
3264
  return;
3086
3265
  }
3087
- case "answer": {
3088
- const text = args.join(" ");
3089
- if (!awaitingAnswerRef.current || !text) {
3090
- setNotice("usage: /answer <text> \u2014 only when the leader is waiting for an answer");
3091
- return;
3092
- }
3093
- void startTurn(text);
3094
- return;
3095
- }
3096
3266
  case "steer": {
3097
3267
  const text = args.join(" ");
3098
3268
  if (!text) {
@@ -3103,6 +3273,10 @@ function App({
3103
3273
  return;
3104
3274
  }
3105
3275
  case "pause":
3276
+ if (queueRef.current.length === 0) {
3277
+ setNotice("no queued turns to pause");
3278
+ return;
3279
+ }
3106
3280
  queuePausedRef.current = true;
3107
3281
  setQueuePaused(true);
3108
3282
  setNotice("queue paused \u2014 the active turn continues");
@@ -3120,56 +3294,31 @@ function App({
3120
3294
  setNotice("answer the leader first; queued input is not an answer");
3121
3295
  return;
3122
3296
  }
3297
+ if (queueRef.current.length === 0) {
3298
+ setNotice("no queued turns to resume");
3299
+ return;
3300
+ }
3123
3301
  queuePausedRef.current = false;
3124
3302
  setQueuePaused(false);
3125
3303
  setNotice("queue resumed");
3126
3304
  return;
3127
3305
  }
3128
- case "modify": {
3129
- const instruction = args.join(" ");
3130
- if (!instruction) {
3131
- setNotice("usage: /modify <instruction> \u2014 only at a checkpoint");
3132
- return;
3133
- }
3134
- if (!checkpointResolveRef.current) {
3135
- setNotice("no checkpoint is waiting");
3136
- return;
3137
- }
3138
- const resolve2 = checkpointResolveRef.current;
3139
- checkpointResolveRef.current = null;
3140
- checkpointLabelRef.current = "";
3141
- resolve2({ action: "modify", instruction });
3142
- setNotice("modifying and continuing\u2026");
3143
- return;
3144
- }
3145
- case "agents":
3146
- setNotice(
3147
- `team: ${plannedAgents(config).map((agent) => agent.id).join(", ")}`
3148
- );
3149
- return;
3150
3306
  case "focus": {
3151
3307
  const input = args[0];
3152
3308
  if (!input) {
3153
3309
  setNotice("usage: /focus <agent id>");
3154
3310
  return;
3155
3311
  }
3156
- const resolved = resolveAgentId(input, Object.keys(shown2.agents));
3312
+ const resolved = resolveAgentId(input, Object.keys(state.agents));
3157
3313
  if ("error" in resolved) {
3158
3314
  setNotice(resolved.error);
3159
3315
  return;
3160
3316
  }
3317
+ setSearchResult(null);
3161
3318
  dispatch({ type: "focus:set", agentId: resolved.id });
3162
3319
  setNotice(`focused ${resolved.id}`);
3163
3320
  return;
3164
3321
  }
3165
- case "clear":
3166
- if (!shown2.focusId) {
3167
- setNotice("nothing focused");
3168
- return;
3169
- }
3170
- dispatch({ type: "stream:clear" });
3171
- setNotice(`cleared ${shown2.focusId}`);
3172
- return;
3173
3322
  case "sessions": {
3174
3323
  const list = listSessions(config.workDir);
3175
3324
  setSessionList(list);
@@ -3184,38 +3333,45 @@ function App({
3184
3333
  setNotice(state.verbose ? "verbose off" : "verbose on \u2014 showing tool detail");
3185
3334
  return;
3186
3335
  case "retry": {
3187
- if (state.phase !== "complete" || !state.requirement) {
3336
+ const turn = recordedTurns(state).slice(-1)[0];
3337
+ if (!turn?.requirement) {
3188
3338
  setNotice("nothing to retry yet");
3189
3339
  return;
3190
3340
  }
3341
+ if (args[0] === "all") args = [];
3191
3342
  let ids;
3192
- if (state.acceptance && state.acceptance.status !== "approved" && (!args[0] || args[0] === state.acceptance.reviewer)) {
3343
+ if (turn.acceptance && turn.acceptance.status !== "approved" && (!args[0] || args[0] === turn.acceptance.reviewer)) {
3193
3344
  ids = [
3194
- ...new Set(state.stages.flatMap((stage) => stage.agents.map((agent) => agent.id)))
3195
- ].filter((id) => id !== state.acceptance?.reviewer);
3345
+ ...new Set(turn.stages.flatMap((stage) => stage.agents.map((agent) => agent.id)))
3346
+ ].filter((id) => id !== turn.acceptance?.reviewer);
3196
3347
  } else if (args[0]) {
3197
- const resolved = resolveAgentId(args[0], Object.keys(state.agents));
3348
+ const resolved = resolveAgentId(args[0], Object.keys(turn.agents));
3198
3349
  if ("error" in resolved) {
3199
3350
  setNotice(resolved.error);
3200
3351
  return;
3201
3352
  }
3353
+ if (!retryCandidates(state).includes(resolved.id)) {
3354
+ setNotice(`agent has not failed or does not need repair: ${resolved.id}`);
3355
+ return;
3356
+ }
3202
3357
  ids = [resolved.id];
3203
3358
  } else {
3204
- ids = Object.values(state.agents).filter((agent) => agent.status === "failed").map((agent) => agent.id);
3359
+ ids = Object.values(turn.agents).filter((agent) => agent.status === "failed").map((agent) => agent.id);
3205
3360
  }
3206
3361
  if (ids.length === 0) {
3207
3362
  setNotice("nothing failed");
3208
3363
  return;
3209
3364
  }
3210
3365
  const sessions = Object.fromEntries(
3211
- ids.filter((id) => state.agents[id]?.sessionId).map((id) => [id, state.agents[id].sessionId])
3366
+ ids.filter((id) => turn.agents[id]?.session?.sessionId).map((id) => [id, turn.agents[id].session])
3212
3367
  );
3213
- if (ids.some((id) => state.leaderIds.includes(id))) {
3214
- void startTurn(state.requirement);
3368
+ if (ids.some((id) => turn.leaderIds.includes(id))) {
3369
+ void startTurn(turn.requirement);
3215
3370
  setNotice("retrying leader routing");
3216
3371
  return;
3217
3372
  }
3218
- void startTurn(state.effectiveRequirement ?? state.requirement, {
3373
+ void startTurn(turn.effectiveRequirement ?? turn.requirement, {
3374
+ work: turn.work,
3219
3375
  resume: {
3220
3376
  agents: ids,
3221
3377
  ...Object.keys(sessions).length > 0 ? { sessions } : {}
@@ -3240,8 +3396,8 @@ function App({
3240
3396
  setNotice(`replaying turn ${target.record.turn}`);
3241
3397
  return;
3242
3398
  }
3243
- case "fork": {
3244
- const target = pickTurn(state, args[0], "nothing to fork yet");
3399
+ case "edit": {
3400
+ const target = pickTurn(state, args[0], "nothing to edit yet");
3245
3401
  if ("error" in target) {
3246
3402
  setNotice(target.error);
3247
3403
  return;
@@ -3266,74 +3422,66 @@ function App({
3266
3422
  setNotice("usage: /search <text>");
3267
3423
  return;
3268
3424
  }
3425
+ const matches2 = streamMatches(state, text, layout.streamWidth);
3426
+ if (matches2.length === 0) {
3427
+ setSearchResult(null);
3428
+ setNotice(`no matches for ${text}`);
3429
+ return;
3430
+ }
3431
+ const index = matches2.length - 1;
3432
+ setSearchResult({ query: text, index });
3269
3433
  dispatch({
3270
3434
  type: "stream:search",
3271
3435
  query: text,
3436
+ match: index,
3272
3437
  viewport: layout.logViewport,
3273
3438
  width: layout.streamWidth
3274
3439
  });
3275
- setNotice(`searched for ${text}`);
3440
+ setNotice(
3441
+ "searched for " + text + " \xB7 match " + (index + 1) + "/" + matches2.length + " \xB7 ctrl+n next \xB7 ctrl+p previous"
3442
+ );
3276
3443
  return;
3277
3444
  }
3278
3445
  case "latest":
3446
+ setSearchResult(null);
3279
3447
  dispatch({ type: "stream:latest" });
3280
3448
  setNotice("showing latest output");
3281
3449
  return;
3282
- case "cancel": {
3283
- if (checkpointResolveRef.current) {
3284
- const resolve2 = checkpointResolveRef.current;
3285
- checkpointResolveRef.current = null;
3286
- checkpointLabelRef.current = "";
3287
- resolve2({ action: "cancel" });
3288
- setNotice("checkpoint cancelled");
3289
- return;
3290
- }
3291
- if (!abortRef.current) {
3292
- setNotice("nothing to cancel");
3293
- return;
3294
- }
3295
- const dropped = queue.length;
3296
- abortRef.current.abort();
3297
- clearInteractions();
3298
- setQueue([]);
3299
- setCancelled(true);
3300
- setNotice(dropped > 0 ? `cancelling\u2026 \xB7 dropped ${dropped} queued` : "cancelling\u2026");
3301
- return;
3302
- }
3303
- case "help":
3304
- setNotice(void 0);
3305
- setHelpOpen(true);
3306
- return;
3307
- case "quit":
3308
- void shutdown();
3309
- return;
3310
3450
  default:
3311
3451
  setNotice(`unknown command: /${name}`);
3312
3452
  }
3313
3453
  },
3314
- [
3315
- config,
3316
- shutdown,
3317
- clearInteractions,
3318
- layout.logViewport,
3319
- layout.streamWidth,
3320
- queue.length,
3321
- running,
3322
- state,
3323
- startTurn,
3324
- steerRunningAgents
3325
- ]
3454
+ [config, layout.logViewport, layout.streamWidth, running, state, startTurn, steerRunningAgents]
3326
3455
  );
3327
3456
  const submit = useCallback(() => {
3328
3457
  const text = line.text.trim();
3329
3458
  if (!text || closingRef.current) return;
3459
+ if (operationInput) {
3460
+ const operation = operationInput;
3461
+ setOperationInput(null);
3462
+ if (operation === "steer" && !running) {
3463
+ setLine(EMPTY_LINE);
3464
+ setQueue((current) => [...current, text]);
3465
+ queuePausedRef.current = true;
3466
+ setQueuePaused(true);
3467
+ setNotice("turn finished \u2014 message queued; /continue runs it");
3468
+ return;
3469
+ }
3470
+ runCommand(operation, [text]);
3471
+ return;
3472
+ }
3330
3473
  const command = parseCommand(text);
3331
3474
  if (command) {
3332
- runCommand(command.name, command.args, text);
3475
+ runCommand(command.name, command.args);
3333
3476
  return;
3334
3477
  }
3335
3478
  if (checkpointResolveRef.current) {
3336
- runCommand("modify", [text], text);
3479
+ const resolve2 = checkpointResolveRef.current;
3480
+ checkpointResolveRef.current = null;
3481
+ checkpointLabelRef.current = "";
3482
+ setLine(EMPTY_LINE);
3483
+ resolve2({ action: "modify", instruction: text });
3484
+ setNotice("modifying and continuing\u2026");
3337
3485
  return;
3338
3486
  }
3339
3487
  setLine(EMPTY_LINE);
@@ -3343,7 +3491,7 @@ function App({
3343
3491
  return;
3344
3492
  }
3345
3493
  void startTurn(text);
3346
- }, [line.text, runCommand, running, startTurn]);
3494
+ }, [line.text, operationInput, runCommand, running, startTurn]);
3347
3495
  const insert = useCallback((chunk) => {
3348
3496
  setLine((l) => ({
3349
3497
  text: l.text.slice(0, l.cursor) + chunk + l.text.slice(l.cursor),
@@ -3373,7 +3521,12 @@ function App({
3373
3521
  const spinner = SPINNER[tick % SPINNER.length];
3374
3522
  const handleKey = (char, key) => {
3375
3523
  if (key.ctrl && char === "c") {
3376
- if (helpOpen) {
3524
+ if (picker || operationInput) {
3525
+ setNotice(void 0);
3526
+ setPicker(null);
3527
+ setOperationInput(null);
3528
+ setLine(EMPTY_LINE);
3529
+ } else if (helpOpen) {
3377
3530
  setHelpOpen(false);
3378
3531
  } else if (sessionsOpen) {
3379
3532
  setSessionsOpen(false);
@@ -3438,13 +3591,61 @@ function App({
3438
3591
  }
3439
3592
  return;
3440
3593
  }
3594
+ if (picker) {
3595
+ if (key.escape) {
3596
+ setPicker(null);
3597
+ setLine(EMPTY_LINE);
3598
+ setNotice(void 0);
3599
+ return;
3600
+ }
3601
+ if (key.upArrow || key.downArrow || key.tab) {
3602
+ const delta = key.upArrow || key.tab && key.shift ? -1 : 1;
3603
+ setPicker({
3604
+ ...picker,
3605
+ cursor: (picker.cursor + delta + picker.rows.length) % picker.rows.length
3606
+ });
3607
+ return;
3608
+ }
3609
+ if (key.return) {
3610
+ const row = picker.rows[picker.cursor];
3611
+ setPicker(null);
3612
+ if (row) runCommand(picker.command, [row.key]);
3613
+ return;
3614
+ }
3615
+ if (!(key.ctrl && char === "d")) return;
3616
+ }
3617
+ if (operationInput && key.escape) {
3618
+ setOperationInput(null);
3619
+ setLine(EMPTY_LINE);
3620
+ setNotice(void 0);
3621
+ return;
3622
+ }
3623
+ if (searchResult && key.ctrl && (char === "n" || char === "p")) {
3624
+ const matches2 = streamMatches(state, searchResult.query, layout.streamWidth);
3625
+ if (matches2.length === 0) {
3626
+ setSearchResult(null);
3627
+ setNotice(`no matches for ${searchResult.query}`);
3628
+ return;
3629
+ }
3630
+ const index = (searchResult.index + (char === "n" ? 1 : -1) + matches2.length) % matches2.length;
3631
+ setSearchResult({ ...searchResult, index });
3632
+ dispatch({
3633
+ type: "stream:search",
3634
+ query: searchResult.query,
3635
+ match: index,
3636
+ viewport: layout.logViewport,
3637
+ width: layout.streamWidth
3638
+ });
3639
+ setNotice(`match ${index + 1}/${matches2.length} \xB7 ctrl+n next \xB7 ctrl+p previous`);
3640
+ return;
3641
+ }
3441
3642
  if (key.ctrl && char === "d") {
3442
3643
  if (!line.text) void shutdown();
3443
3644
  return;
3444
3645
  }
3445
3646
  if (key.ctrl && char === "l") {
3446
3647
  dispatch({ type: "stream:clear" });
3447
- setNotice(shown.focusId ? `cleared ${shown.focusId}` : "nothing focused");
3648
+ setNotice(state.focusId ? `cleared ${state.focusId}` : "nothing focused");
3448
3649
  return;
3449
3650
  }
3450
3651
  if (key.ctrl && char === "a") {
@@ -3455,9 +3656,16 @@ function App({
3455
3656
  setLine((l) => ({ ...l, cursor: l.text.length }));
3456
3657
  return;
3457
3658
  }
3458
- const formFront = front?.kind === "question" && !isCommandLine(line.text) ? front : void 0;
3659
+ const formFront = front?.kind === "question" && !operationInput ? front : void 0;
3459
3660
  if (key.tab && formFront === void 0 && !paletteOpen) {
3460
- dispatch({ type: "focus:cycle", delta: key.shift ? -1 : 1 });
3661
+ if (key.shift) {
3662
+ const next = permissionMode === "auto" ? "ask" : "auto";
3663
+ setPermissionMode(next);
3664
+ setNotice(`permission mode: ${next}`);
3665
+ return;
3666
+ }
3667
+ setSearchResult(null);
3668
+ dispatch({ type: "focus:cycle", delta: 1 });
3461
3669
  return;
3462
3670
  }
3463
3671
  if (paletteOpen) {
@@ -3489,7 +3697,7 @@ function App({
3489
3697
  return;
3490
3698
  }
3491
3699
  }
3492
- if (front?.kind === "permission" && line.text === "" && !menu.released) {
3700
+ if (!operationInput && front?.kind === "permission" && line.text === "" && !menu.released) {
3493
3701
  const total = permissionChoices(front.request).length;
3494
3702
  if (key.upArrow || key.downArrow) {
3495
3703
  setPermissionMenu({
@@ -3525,8 +3733,12 @@ function App({
3525
3733
  if (draft !== void 0) setLine({ text: draft, cursor: draft.length });
3526
3734
  };
3527
3735
  const moveFocus = (index, state2 = committed) => {
3528
- const next = focusField(questionFields, state2, index);
3529
- save(next.state, next.draft);
3736
+ const target = (index + total + 3) % (total + 3);
3737
+ if (target >= total) save({ ...state2, field: target }, "");
3738
+ else {
3739
+ const next = focusField(questionFields, state2, target);
3740
+ save(next.state, next.draft);
3741
+ }
3530
3742
  };
3531
3743
  const submit2 = (state2) => {
3532
3744
  if (!formComplete(questionFields, state2)) {
@@ -3548,15 +3760,29 @@ function App({
3548
3760
  setNotice(`answered ${front2.shortId}`);
3549
3761
  };
3550
3762
  const advance = (state2) => {
3551
- if (form.field >= total - 1) submit2(state2);
3552
- else moveFocus(form.field + 1, state2);
3763
+ moveFocus(form.field + 1, state2);
3553
3764
  };
3554
3765
  if (field === void 0) {
3555
- if (key.return) submit2(committed);
3766
+ const action = Math.max(0, form.field - total);
3767
+ if (key.tab || key.leftArrow || key.rightArrow || key.upArrow || key.downArrow) {
3768
+ const delta = key.leftArrow || key.upArrow || key.tab && key.shift ? -1 : 1;
3769
+ moveFocus(form.field + delta);
3770
+ return;
3771
+ }
3772
+ if (key.return) {
3773
+ if (action === 0) submit2(committed);
3774
+ else {
3775
+ front2.settle({ action: action === 1 ? "decline" : "cancel" });
3776
+ setFormEntry({ id: "", state: initialForm() });
3777
+ setLine(EMPTY_LINE);
3778
+ setNotice(action === 1 ? "question skipped" : "question cancelled");
3779
+ }
3780
+ }
3556
3781
  return;
3557
3782
  }
3558
3783
  if (key.tab) {
3559
- moveFocus(key.shift ? form.field - 1 : form.field + 1);
3784
+ const next = field.kind === "choice" ? commitField(questionFields, committed) : committed;
3785
+ moveFocus(key.shift ? form.field - 1 : form.field + 1, next);
3560
3786
  return;
3561
3787
  }
3562
3788
  if (field.choices.length > 0 && (key.upArrow || key.downArrow)) {
@@ -3660,12 +3886,8 @@ function App({
3660
3886
  setError(void 0);
3661
3887
  return;
3662
3888
  }
3663
- if (state.view) {
3664
- dispatch({ type: "view:close" });
3665
- return;
3666
- }
3667
3889
  if (front !== void 0) {
3668
- setPermissionMenu({ id: front.shortId, cursor: void 0, released: true });
3890
+ setPermissionMenu({ id: front.shortId, cursor: 0, released: !menu.released });
3669
3891
  }
3670
3892
  setLine(EMPTY_LINE);
3671
3893
  setNotice(void 0);
@@ -3684,17 +3906,42 @@ function App({
3684
3906
  const handlerRef = useRef(handleKey);
3685
3907
  handlerRef.current = handleKey;
3686
3908
  useInput((char, key) => handlerRef.current(char, key));
3687
- const keys = helpOpen ? "esc close" : sessionsOpen ? "\u2191\u2193 select \xB7 enter open \xB7 d delete \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 tab complete \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 tab complete \xB7 enter run \xB7 esc close" : running ? checkpointResolveRef.current ? `/continue \xB7 /modify <instruction> \xB7 /cancel` : interactions.length > 0 ? layout.interactionRows === 0 ? (
3688
- // Too short to dock the bar, so the commands are the only way in.
3689
- "decide with /approve 1 \xB7 /deny \xB7 /respond"
3690
- ) : interactions[0].kind === "question" ? "tab field \xB7 \u2191\u2193 pick \xB7 enter next \xB7 /dismiss" : menu.released ? "/approve 1 and /deny still reach it" : "\u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type" : "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";
3691
- const placeholder = frontQuestion !== void 0 ? formField === void 0 ? "enter to answer" : editingField !== void 0 ? `${editingField.title}${editingField.required ? " *" : ""} \u2014 enter for the next field` : `${formField.title} \u2014 \u2191\u2193 to pick \xB7 enter for the next field` : front?.kind === "permission" && !menu.released ? "choose above \xB7 esc to type here instead" : checkpointResolveRef.current ? `checkpoint: ${checkpointLabelRef.current} \u2014 type feedback or /continue` : running ? RUNNING_PLACEHOLDER : awaitingAnswer ? "answer the leader\u2026 \xB7 /task starts a new task" : INPUT_PLACEHOLDER;
3909
+ const keys = picker ? "\u2191\u2193 select \xB7 enter confirm \xB7 esc cancel" : operationInput ? "enter submit \xB7 esc cancel" : helpOpen ? "esc close" : sessionsOpen ? "\u2191\u2193 select \xB7 enter open \xB7 d delete \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 tab complete \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 tab complete \xB7 enter run \xB7 esc close" : running ? checkpointResolveRef.current ? `/continue \xB7 type feedback \xB7 ctrl+c cancel` : interactions.length > 0 ? layout.interactionRows === 0 ? (
3910
+ // On short terminals the decision is shown in the body.
3911
+ "\u2191\u2193 choose \xB7 enter confirm \xB7 esc type \xB7 ctrl+c cancel"
3912
+ ) : interactions[0].kind === "question" ? "tab field / actions \xB7 \u2191\u2193 pick \xB7 enter confirm" : menu.released ? "esc return to permission options" : "\u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type" : "enter queues \xB7 ctrl+c cancel" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 shift+tab mode \xB7 / commands \xB7 ? help";
3913
+ const placeholder = picker ? "choose an option above\u2026" : operationInput ? operationInput === "search" ? "search text\u2026" : "message for running agents\u2026" : frontQuestion !== void 0 ? formField === void 0 ? "enter to answer" : editingField !== void 0 ? `${editingField.title}${editingField.required ? " *" : ""} \u2014 enter for the next field` : `${formField.title} \u2014 \u2191\u2193 to pick \xB7 enter for the next field` : front?.kind === "permission" && !menu.released ? "choose above \xB7 esc to type here instead" : checkpointResolveRef.current ? `checkpoint: ${checkpointLabelRef.current} \u2014 type feedback or /continue` : running ? RUNNING_PLACEHOLDER : awaitingAnswer ? "answer the leader\u2026 \xB7 /new starts a new session" : INPUT_PLACEHOLDER;
3914
+ const renderInteraction = (maxRows) => {
3915
+ if (!front) return null;
3916
+ return front.kind === "permission" ? /* @__PURE__ */ jsx6(
3917
+ PermissionBar,
3918
+ {
3919
+ request: front.request,
3920
+ others: interactions.slice(1).flatMap((entry) => entry.kind === "permission" ? [entry.request] : []),
3921
+ cursor: menu.cursor,
3922
+ released: menu.released,
3923
+ width: layout.columns,
3924
+ maxRows
3925
+ }
3926
+ ) : /* @__PURE__ */ jsx6(
3927
+ QuestionForm,
3928
+ {
3929
+ request: front.request,
3930
+ others: interactions.slice(1).flatMap((entry) => entry.kind === "question" ? [entry.request] : []),
3931
+ fields: questionFields,
3932
+ form,
3933
+ draft: line.text,
3934
+ width: layout.columns,
3935
+ maxRows
3936
+ }
3937
+ );
3938
+ };
3692
3939
  return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: layout.rows, children: [
3693
3940
  /* @__PURE__ */ jsx6(
3694
3941
  Header,
3695
3942
  {
3696
3943
  name: state.name || config.name,
3697
- right: state.view ? `turn ${shown.turn} \xB7 viewing` : `turn ${shown.turn} \xB7 ${running ? formatDuration(turnElapsed(state, now) ?? 0) : state.phase === "complete" ? "complete" : "ready"}`
3944
+ right: `turn ${state.turn} \xB7 ${running ? formatDuration(turnElapsed(state, now) ?? 0) : state.phase === "complete" ? "complete" : "ready"} \xB7 ${permissionMode}`
3698
3945
  }
3699
3946
  ),
3700
3947
  /* @__PURE__ */ jsx6(Rule, { width: layout.columns }),
@@ -3703,9 +3950,9 @@ function App({
3703
3950
  // The rail wraps, so it can be taller than one row. Cap it at the height
3704
3951
  // the budget reserved and clip, rather than letting the extra rows push
3705
3952
  // the chrome below it off the screen.
3706
- /* @__PURE__ */ jsx6(Box6, { marginTop: 1, height: layout.railRows, overflow: "hidden", flexShrink: 0, children: /* @__PURE__ */ jsx6(PipelineRail, { turn: shown, spinner }) })
3953
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, height: layout.railRows, overflow: "hidden", flexShrink: 0, children: /* @__PURE__ */ jsx6(PipelineRail, { turn: state, spinner }) })
3707
3954
  ) : null,
3708
- shown.reason ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: ` reason: ${shown.reason}` }) : null,
3955
+ state.reason || state.routing ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: (state.routing ? " route: " + state.routing.mode + " \xB7 " + state.routing.workId + (state.routing.reroutes ? " \xB7 rerouted" : "") : "") + (state.reason ? " reason: " + state.reason : "") }) : null,
3709
3956
  /* @__PURE__ */ jsx6(
3710
3957
  Box6,
3711
3958
  {
@@ -3724,20 +3971,20 @@ function App({
3724
3971
  maxRows: layout.bodyHeight,
3725
3972
  now
3726
3973
  }
3727
- ) : layout.welcome ? /* @__PURE__ */ jsx6(WelcomePanel, { turn: state, width: layout.columns }) : error ? (
3974
+ ) : front && layout.interactionRows === 0 && !paletteOpen ? renderInteraction(layout.bodyHeight) : layout.welcome ? /* @__PURE__ */ jsx6(WelcomePanel, { turn: state, width: layout.columns }) : error ? (
3728
3975
  // The error wins over a reply: a run that stopped with an error is the
3729
3976
  // most urgent thing on screen, and a stale answer underneath it would
3730
3977
  // read as the outcome. The status line keeps a one-line version too.
3731
3978
  /* @__PURE__ */ jsx6(ErrorPanel, { message: error, width: layout.columns })
3732
- ) : shown.reply ? /* @__PURE__ */ jsx6(ReplyPanel, { reply: shown.reply, width: layout.columns }) : /* @__PURE__ */ jsxs6(Box6, { height: layout.bodyHeight, flexShrink: 0, flexDirection: "row", children: [
3979
+ ) : state.reply ? /* @__PURE__ */ jsx6(ReplyPanel, { reply: state.reply, width: layout.columns }) : /* @__PURE__ */ jsxs6(Box6, { height: layout.bodyHeight, flexShrink: 0, flexDirection: "row", children: [
3733
3980
  layout.showTeam ? /* @__PURE__ */ jsxs6(Fragment3, { children: [
3734
- /* @__PURE__ */ jsx6(TeamPanel, { turn: shown, width: layout.teamWidth, now, spinner }),
3981
+ /* @__PURE__ */ jsx6(TeamPanel, { turn: state, width: layout.teamWidth, now, spinner }),
3735
3982
  /* @__PURE__ */ jsx6(Divider, {})
3736
3983
  ] }) : null,
3737
3984
  /* @__PURE__ */ jsx6(
3738
3985
  StreamPanel,
3739
3986
  {
3740
- turn: shown,
3987
+ state,
3741
3988
  height: layout.bodyHeight,
3742
3989
  width: layout.streamWidth,
3743
3990
  now,
@@ -3747,7 +3994,6 @@ function App({
3747
3994
  ] })
3748
3995
  }
3749
3996
  ),
3750
- state.view ? /* @__PURE__ */ jsx6(Text6, { color: COLOR.warn, wrap: "truncate-end", children: ` viewing turn ${shown.turn} \xB7 ${shown.requirement ?? "(no requirement)"} \xB7 esc to return` }) : null,
3751
3997
  notice || queuePaused && queue.length > 0 ? /* @__PURE__ */ jsxs6(Text6, { dimColor: true, wrap: "truncate-end", children: [
3752
3998
  " ",
3753
3999
  notice ?? (awaitingAnswer ? "queue paused \u2014 answer the leader first" : "queue paused \u2014 /continue resumes it")
@@ -3762,26 +4008,7 @@ function App({
3762
4008
  overflow: "hidden",
3763
4009
  flexShrink: 0,
3764
4010
  flexDirection: "column",
3765
- children: front.kind === "permission" ? /* @__PURE__ */ jsx6(
3766
- PermissionBar,
3767
- {
3768
- request: front.request,
3769
- others: interactions.slice(1).flatMap((entry) => entry.kind === "permission" ? [entry.request] : []),
3770
- cursor: menu.cursor,
3771
- released: menu.released,
3772
- width: layout.columns
3773
- }
3774
- ) : /* @__PURE__ */ jsx6(
3775
- QuestionForm,
3776
- {
3777
- request: front.request,
3778
- others: interactions.slice(1).flatMap((entry) => entry.kind === "question" ? [entry.request] : []),
3779
- fields: questionFields,
3780
- form,
3781
- draft: line.text,
3782
- width: layout.columns
3783
- }
3784
- )
4011
+ children: renderInteraction(layout.interactionRows)
3785
4012
  }
3786
4013
  )
3787
4014
  ] }),
@@ -3872,6 +4099,7 @@ export {
3872
4099
  conversationTurns,
3873
4100
  createInitialState,
3874
4101
  initialState,
4102
+ latestWorkFromTurns,
3875
4103
  leaderAgentIds,
3876
4104
  measureRailRows,
3877
4105
  plannedAgents,
@@ -3881,8 +4109,9 @@ export {
3881
4109
  seedAgents,
3882
4110
  stageStatus,
3883
4111
  startTui,
4112
+ streamMatches,
3884
4113
  stripAnsi,
3885
4114
  tuiReducer,
3886
4115
  turnElapsed,
3887
- visibleLog
4116
+ visibleTimeline
3888
4117
  };