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