@335g/pi-herdr-fleet 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +419 -0
- package/README.md +442 -0
- package/approvals.ts +748 -0
- package/audit.ts +153 -0
- package/clean.ts +256 -0
- package/fork.ts +216 -0
- package/herdr-client.ts +300 -0
- package/index.ts +420 -0
- package/package.json +45 -0
- package/recipes.ts +172 -0
- package/review.ts +515 -0
- package/runs.ts +437 -0
- package/scopes.ts +134 -0
- package/worktree.ts +573 -0
package/approvals.ts
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* approvals: the approval broker.
|
|
3
|
+
*
|
|
4
|
+
* herdr already knows which panes are waiting on a human (`agent_status ===
|
|
5
|
+
* "blocked"`). This module keeps that list, fetches the question each blocked
|
|
6
|
+
* pane is asking, and answers it from an overlay on the pane the human is
|
|
7
|
+
* actually looking at.
|
|
8
|
+
*
|
|
9
|
+
* herdr stays the source of truth: the list is built from `session.snapshot`
|
|
10
|
+
* and corrected by `pane.agent_status_changed`. Nothing is remembered across a
|
|
11
|
+
* reconnect, and a failed answer is never retried — herdr's timeout is not
|
|
12
|
+
* proof the input never arrived.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type Theme, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import {
|
|
17
|
+
type Component,
|
|
18
|
+
Editor,
|
|
19
|
+
type EditorTheme,
|
|
20
|
+
type Focusable,
|
|
21
|
+
Key,
|
|
22
|
+
type TUI,
|
|
23
|
+
matchesKey,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
visibleWidth,
|
|
26
|
+
wrapTextWithAnsi,
|
|
27
|
+
} from "@earendil-works/pi-tui";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
type AgentStatus,
|
|
31
|
+
type HerdrClient,
|
|
32
|
+
type HerdrEvent,
|
|
33
|
+
type Outcome,
|
|
34
|
+
type Snapshot,
|
|
35
|
+
type SubscribeEvent,
|
|
36
|
+
type SubscriptionHandle,
|
|
37
|
+
err,
|
|
38
|
+
} from "./herdr-client.ts";
|
|
39
|
+
|
|
40
|
+
export interface BlockedPane {
|
|
41
|
+
pane_id: string;
|
|
42
|
+
workspace_id: string;
|
|
43
|
+
agent?: string | null;
|
|
44
|
+
name?: string | null;
|
|
45
|
+
state_labels?: Record<string, string>;
|
|
46
|
+
/** What the pane is asking, read once when it became blocked. */
|
|
47
|
+
question?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The types this broker subscribes to besides the per-pane status changes.
|
|
52
|
+
*
|
|
53
|
+
* The first two change the pane set, and so the subscription set. The rest are
|
|
54
|
+
* the audit log's (§6): herdr keeps no history, so the fleet's lifecycle is
|
|
55
|
+
* written down as it happens. The names are herdr's schema's own subscription
|
|
56
|
+
* names, and a set with a name it does not know is refused whole.
|
|
57
|
+
*
|
|
58
|
+
* The high-frequency types are absent on purpose: `pane.output_changed` has no
|
|
59
|
+
* subscription at all, `pane.output_matched` fires only on a match, and nothing
|
|
60
|
+
* here probes pane output.
|
|
61
|
+
*/
|
|
62
|
+
const LIFECYCLE_EVENTS = [
|
|
63
|
+
"pane.created",
|
|
64
|
+
"pane.closed",
|
|
65
|
+
"workspace.created",
|
|
66
|
+
"workspace.closed",
|
|
67
|
+
"worktree.created",
|
|
68
|
+
"worktree.removed",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export class ApprovalBroker {
|
|
72
|
+
private readonly client: HerdrClient;
|
|
73
|
+
private readonly onNewBlocked: (entry: BlockedPane) => void;
|
|
74
|
+
private readonly onEvent: ((event: HerdrEvent) => void) | undefined;
|
|
75
|
+
private readonly blocked = new Map<string, BlockedPane>();
|
|
76
|
+
private handle: SubscriptionHandle | undefined;
|
|
77
|
+
private coveredPanes = "";
|
|
78
|
+
/**
|
|
79
|
+
* The pane set whose subscription herdr refused last time. A set is refused
|
|
80
|
+
* for one of two reasons: a pane that vanished between the snapshot and the
|
|
81
|
+
* subscribe (the rebuilt set omits it), or an event type this herdr does not
|
|
82
|
+
* know (the rebuilt set is identical). Only the second is permanent.
|
|
83
|
+
*/
|
|
84
|
+
private refusedCovered: string | undefined;
|
|
85
|
+
/** New-blocked notifications only make sense once the first snapshot landed. */
|
|
86
|
+
private primed = false;
|
|
87
|
+
private stopped = true;
|
|
88
|
+
private lastError: string | undefined;
|
|
89
|
+
private onChange: (() => void) | undefined;
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
client: HerdrClient,
|
|
93
|
+
onNewBlocked: (entry: BlockedPane) => void,
|
|
94
|
+
/**
|
|
95
|
+
* Every event on the subscription, before it is routed. The audit log (§6)
|
|
96
|
+
* is built on this rather than on a second subscription: the pane-scoped
|
|
97
|
+
* status events only arrive on a connection that subscribed per pane, and
|
|
98
|
+
* the pane list is the broker's.
|
|
99
|
+
*/
|
|
100
|
+
onEvent?: (event: HerdrEvent) => void,
|
|
101
|
+
) {
|
|
102
|
+
this.client = client;
|
|
103
|
+
this.onNewBlocked = onNewBlocked;
|
|
104
|
+
this.onEvent = onEvent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
entries(): BlockedPane[] {
|
|
108
|
+
return [...this.blocked.values()];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The last transport or herdr failure worth showing the user. */
|
|
112
|
+
error(): string | undefined {
|
|
113
|
+
return this.lastError;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setOnChange(onChange: (() => void) | undefined): void {
|
|
117
|
+
this.onChange = onChange;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async start(): Promise<void> {
|
|
121
|
+
this.stopped = false;
|
|
122
|
+
await this.refresh();
|
|
123
|
+
this.primed = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
stop(): void {
|
|
127
|
+
this.stopped = true;
|
|
128
|
+
this.handle?.close();
|
|
129
|
+
this.handle = undefined;
|
|
130
|
+
this.coveredPanes = "";
|
|
131
|
+
this.refusedCovered = undefined;
|
|
132
|
+
this.blocked.clear();
|
|
133
|
+
this.primed = false;
|
|
134
|
+
this.lastError = undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async sendText(paneId: string, text: string): Promise<Outcome<void>> {
|
|
138
|
+
const result = await this.client.paneSendInput(paneId, text);
|
|
139
|
+
this.lastError = result.ok ? undefined : result.error;
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async sendKeys(paneId: string, keys: string[]): Promise<Outcome<void>> {
|
|
144
|
+
if (keys.length === 0) return err("no keys to send");
|
|
145
|
+
const result = await this.client.paneSendKeys(paneId, keys);
|
|
146
|
+
this.lastError = result.ok ? undefined : result.error;
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Rebuild the pane set and the blocked list from herdr's own state. */
|
|
151
|
+
private async refresh(): Promise<void> {
|
|
152
|
+
if (this.stopped) return;
|
|
153
|
+
const snapshot = await this.client.snapshot();
|
|
154
|
+
if (!snapshot.ok) {
|
|
155
|
+
this.lastError = snapshot.error;
|
|
156
|
+
this.notifyChange();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.lastError = undefined;
|
|
160
|
+
this.apply(snapshot.value);
|
|
161
|
+
this.notifyChange();
|
|
162
|
+
this.resubscribe(snapshot.value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private apply(snapshot: Snapshot): void {
|
|
166
|
+
const self = this.client.selfPaneId();
|
|
167
|
+
const previous = new Map(this.blocked);
|
|
168
|
+
this.blocked.clear();
|
|
169
|
+
for (const agent of snapshot.agents) {
|
|
170
|
+
if (agent.pane_id === self || agent.agent_status !== "blocked") continue;
|
|
171
|
+
this.setBlocked(
|
|
172
|
+
{
|
|
173
|
+
pane_id: agent.pane_id,
|
|
174
|
+
workspace_id: agent.workspace_id,
|
|
175
|
+
agent: agent.agent ?? agent.display_agent,
|
|
176
|
+
name: agent.name,
|
|
177
|
+
state_labels: agent.state_labels,
|
|
178
|
+
// The snapshot does not carry the question; keep one already read.
|
|
179
|
+
question: previous.get(agent.pane_id)?.question,
|
|
180
|
+
},
|
|
181
|
+
previous.has(agent.pane_id),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
// A pane that left the list was answered or died; either way it is done here.
|
|
185
|
+
for (const paneId of previous.keys()) {
|
|
186
|
+
if (!this.blocked.has(paneId)) this.clearBlocked(paneId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The single place a row appears, so a notification cannot be missed by a routing bug. */
|
|
191
|
+
private setBlocked(entry: BlockedPane, wasKnown: boolean): void {
|
|
192
|
+
this.blocked.set(entry.pane_id, entry);
|
|
193
|
+
this.notifyChange();
|
|
194
|
+
if (entry.question === undefined) void this.loadQuestion(entry.pane_id);
|
|
195
|
+
// The first snapshot is not news.
|
|
196
|
+
if (!wasKnown && this.primed) this.onNewBlocked(entry);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private clearBlocked(paneId: string): void {
|
|
200
|
+
if (this.blocked.delete(paneId)) this.notifyChange();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The subscription set is fixed per connection, so a changed pane set is a new connection. */
|
|
204
|
+
private resubscribe(snapshot: Snapshot): void {
|
|
205
|
+
const paneIds = snapshot.panes.map((pane) => pane.pane_id);
|
|
206
|
+
const covered = paneIds.join(",");
|
|
207
|
+
if (this.handle && covered === this.coveredPanes) return;
|
|
208
|
+
this.coveredPanes = covered;
|
|
209
|
+
this.handle?.close();
|
|
210
|
+
this.handle = this.client.subscribe(
|
|
211
|
+
[
|
|
212
|
+
...LIFECYCLE_EVENTS.map((type) => ({ type })),
|
|
213
|
+
...paneIds.map((pane_id) => ({ type: "pane.agent_status_changed", pane_id })),
|
|
214
|
+
],
|
|
215
|
+
(event) => this.onSubscribeEvent(event),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private onSubscribeEvent(event: SubscribeEvent): void {
|
|
220
|
+
if (this.stopped) return;
|
|
221
|
+
if (event.kind === "resync") {
|
|
222
|
+
// The stream was rebuilt: trust herdr over anything derived from the old one.
|
|
223
|
+
if (event.reason === "refused") {
|
|
224
|
+
// The set that failed is the one in `coveredPanes`; dropping it makes
|
|
225
|
+
// the resubscribe below open a fresh set from the new snapshot. If that
|
|
226
|
+
// fresh set is the same one, the cause is the set itself — a type this
|
|
227
|
+
// herdr does not know — and retrying it would refuse forever.
|
|
228
|
+
const refusedSet = this.coveredPanes;
|
|
229
|
+
this.handle?.close();
|
|
230
|
+
this.handle = undefined;
|
|
231
|
+
this.coveredPanes = "";
|
|
232
|
+
if (this.refusedCovered === refusedSet) {
|
|
233
|
+
this.lastError = "herdr refused the same subscription set twice; not retrying it";
|
|
234
|
+
if (event.snapshot.ok) this.apply(event.snapshot.value);
|
|
235
|
+
this.notifyChange();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.refusedCovered = refusedSet;
|
|
239
|
+
}
|
|
240
|
+
if (event.snapshot.ok) {
|
|
241
|
+
this.lastError = undefined;
|
|
242
|
+
this.apply(event.snapshot.value);
|
|
243
|
+
this.resubscribe(event.snapshot.value);
|
|
244
|
+
} else {
|
|
245
|
+
this.lastError = event.snapshot.error;
|
|
246
|
+
}
|
|
247
|
+
this.notifyChange();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { event: name, data } = event.event;
|
|
252
|
+
// A live event proves the accepted set was the rebuilt one, so the next
|
|
253
|
+
// refusal gets its own retry.
|
|
254
|
+
this.refusedCovered = undefined;
|
|
255
|
+
this.onEvent?.(event.event);
|
|
256
|
+
if (name === "pane.agent_status_changed") {
|
|
257
|
+
this.onStatusChanged(data);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// Lifecycle events arrive as `pane.created` or the schema's underscored
|
|
261
|
+
// `pane_created`; both land here. The pane inventory and its state live in
|
|
262
|
+
// herdr anyway, so one snapshot is the cheapest correct answer, and a
|
|
263
|
+
// dropped event costs nothing but that snapshot.
|
|
264
|
+
if ((name === "pane.closed" || name === "pane_closed") && typeof data.pane_id === "string") {
|
|
265
|
+
this.clearBlocked(data.pane_id);
|
|
266
|
+
}
|
|
267
|
+
void this.refresh();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private onStatusChanged(data: Record<string, any>): void {
|
|
271
|
+
const paneId = data.pane_id;
|
|
272
|
+
if (typeof paneId !== "string" || paneId === this.client.selfPaneId()) return;
|
|
273
|
+
if (data.agent_status !== "blocked") {
|
|
274
|
+
this.clearBlocked(paneId);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.setBlocked(
|
|
278
|
+
{
|
|
279
|
+
pane_id: paneId,
|
|
280
|
+
workspace_id: typeof data.workspace_id === "string" ? data.workspace_id : "",
|
|
281
|
+
agent: data.display_agent ?? data.agent ?? undefined,
|
|
282
|
+
state_labels: data.state_labels,
|
|
283
|
+
},
|
|
284
|
+
this.blocked.has(paneId),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async loadQuestion(paneId: string): Promise<void> {
|
|
289
|
+
// `detection` is herdr's own reading of the prompt UI; it is empty for an
|
|
290
|
+
// agent herdr cannot classify, so the rendered viewport is the fallback.
|
|
291
|
+
let read = await this.client.agentRead(paneId, "detection");
|
|
292
|
+
if (read.ok && read.value.trim() === "") read = await this.client.agentRead(paneId, "visible");
|
|
293
|
+
this.lastError = read.ok ? undefined : read.error;
|
|
294
|
+
const entry = this.blocked.get(paneId);
|
|
295
|
+
if (!entry) return;
|
|
296
|
+
if (read.ok) entry.question = read.value.trim();
|
|
297
|
+
this.notifyChange();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private notifyChange(): void {
|
|
301
|
+
this.onChange?.();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------- strings
|
|
306
|
+
|
|
307
|
+
export interface Strings {
|
|
308
|
+
title: string;
|
|
309
|
+
count(count: number): string;
|
|
310
|
+
empty: string;
|
|
311
|
+
above(lines: number): string;
|
|
312
|
+
listKeys: string;
|
|
313
|
+
detailKeys: string;
|
|
314
|
+
answer: string;
|
|
315
|
+
answerHint: string;
|
|
316
|
+
noResend: string;
|
|
317
|
+
sent: string;
|
|
318
|
+
sending: string;
|
|
319
|
+
noQuestion: string;
|
|
320
|
+
blockedNotification(who: string): string;
|
|
321
|
+
unknownSubcommand(name: string): string;
|
|
322
|
+
state(entry: BlockedPane): string;
|
|
323
|
+
recipeUsage: string;
|
|
324
|
+
recipeSaving(name: string): string;
|
|
325
|
+
recipeSaved(name: string, panes: number, path: string): string;
|
|
326
|
+
recipeApplied(name: string, panes: number): string;
|
|
327
|
+
recipeNone: string;
|
|
328
|
+
recipeList(recipes: { name: string; panes: number }[]): string;
|
|
329
|
+
worktreeUsage: string;
|
|
330
|
+
worktreeCreating(branch: string): string;
|
|
331
|
+
worktreeCreated(branch: string, path: string, workspaceId: string, env: string): string;
|
|
332
|
+
worktreeWarningPrefix: string;
|
|
333
|
+
fleetWarningPrefix: string;
|
|
334
|
+
forkUsage: string;
|
|
335
|
+
forkCreating(branch: string): string;
|
|
336
|
+
forkCreated(branch: string, path: string, workspaceId: string, state: string): string;
|
|
337
|
+
forkNoStart: string;
|
|
338
|
+
forkRunning(paneId: string, agent: string, prepare: string): string;
|
|
339
|
+
forkNoInstall: string;
|
|
340
|
+
forkInstalled(command: string): string;
|
|
341
|
+
forkInstallFailed(command: string, error: string): string;
|
|
342
|
+
reviewUsage: string;
|
|
343
|
+
reviewStarting(branch: string): string;
|
|
344
|
+
reviewStarted(branch: string, paneId: string, agent: string, material: string): string;
|
|
345
|
+
reviewMaterial(diff: number, messages: number): string;
|
|
346
|
+
statusNone: string;
|
|
347
|
+
statusHeader: string;
|
|
348
|
+
statusLine(row: { branch: string; scope: string; state: string; verdict: string }): string;
|
|
349
|
+
stateWorking: string;
|
|
350
|
+
stateUnreviewed: string;
|
|
351
|
+
stateMerged: string;
|
|
352
|
+
stateCleaned: string;
|
|
353
|
+
mergeUsage: string;
|
|
354
|
+
mergeStarting(branch: string): string;
|
|
355
|
+
mergeDone(branch: string, output: string): string;
|
|
356
|
+
cleanUsage: string;
|
|
357
|
+
cleanStarting(branch: string): string;
|
|
358
|
+
cleanDone(branch: string, worktree: boolean, branchDeleted: boolean, panes: number): string;
|
|
359
|
+
verdictRecorded(branch: string, verdict: string, findings: number): string;
|
|
360
|
+
notACheckout: string;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const JA: Strings = {
|
|
364
|
+
title: "fleet",
|
|
365
|
+
count: (count) => `承認待ち ${count} 件`,
|
|
366
|
+
empty: "承認待ちの pane はありません",
|
|
367
|
+
above: (lines) => `↑ ${lines} 行`,
|
|
368
|
+
listKeys: "↑↓ 選択 · Enter 開く · Esc 閉じる",
|
|
369
|
+
detailKeys: "Enter テキスト送信 · ctrl+k 生キー送信 · PgUp/PgDn · Esc 戻る",
|
|
370
|
+
answer: "回答",
|
|
371
|
+
answerHint: "テキスト、または生キー(esc / 1 / up …)",
|
|
372
|
+
noResend: "失敗しても自動では再送しません",
|
|
373
|
+
sent: "送信しました",
|
|
374
|
+
sending: "送信中…",
|
|
375
|
+
noQuestion: "(質問文を取得中…)",
|
|
376
|
+
blockedNotification: (who) => `${who} が承認待ちです`,
|
|
377
|
+
unknownSubcommand: (name) => `不明なサブコマンド: ${name}`,
|
|
378
|
+
state: (entry) => entry.state_labels?.blocked ?? "blocked",
|
|
379
|
+
recipeUsage: "使い方: /fleet recipe save <name> | apply <name> [--start] | ls",
|
|
380
|
+
recipeSaving: (name) => `レシピ ${name} を保存中…`,
|
|
381
|
+
recipeSaved: (name, panes, path) => `レシピ ${name} を保存しました(${panes} pane · ${path})`,
|
|
382
|
+
recipeApplied: (name, panes) => `レシピ ${name} を新しい tab に適用しました(${panes} pane)`,
|
|
383
|
+
recipeNone: "レシピはまだありません",
|
|
384
|
+
recipeList: (recipes) => `レシピ: ${recipes.map((recipe) => `${recipe.name} (${recipe.panes})`).join(", ")}`,
|
|
385
|
+
worktreeUsage: "使い方: /fleet worktree create <branch> [--base <ref>] [--label <text>]",
|
|
386
|
+
worktreeCreating: (branch) => `worktree ${branch} を作成中…`,
|
|
387
|
+
worktreeCreated: (branch, path, workspaceId, env) => `worktree ${branch} を作成しました(${workspaceId} · ${path})— ${env}`,
|
|
388
|
+
worktreeWarningPrefix: "worktree の環境:",
|
|
389
|
+
fleetWarningPrefix: "fleet:",
|
|
390
|
+
forkUsage: '使い方: /fleet fork <branch> --task "<text>" [--base <ref>] [--scope implementation] [--no-install] [--no-start]',
|
|
391
|
+
forkCreating: (branch) => `fork ${branch} を準備中…`,
|
|
392
|
+
forkCreated: (branch, path, workspaceId, state) => `fork ${branch}(${workspaceId} · ${path})— ${state}`,
|
|
393
|
+
forkNoStart: "--no-start のため pane も agent も作成していません",
|
|
394
|
+
forkRunning: (paneId, agent, prepare) => `pane ${paneId} · agent ${agent} · ${prepare}`,
|
|
395
|
+
forkNoInstall: "install なし(lockfile が無いか --no-install)",
|
|
396
|
+
forkInstalled: (command) => `${command} 完了`,
|
|
397
|
+
forkInstallFailed: (command, error) => `${command} 失敗(${error})`,
|
|
398
|
+
reviewUsage: '使い方: /fleet review <branch> --task "<text>" [--base <ref>]',
|
|
399
|
+
reviewStarting: (branch) => `review ${branch} を準備中…`,
|
|
400
|
+
reviewStarted: (branch, paneId, agent, material) => `review ${branch} を開始しました(pane ${paneId} · agent ${agent} · ${material})`,
|
|
401
|
+
reviewMaterial: (diff, messages) => `diff ${diff} 文字 · 作者セッション ${messages} 通`,
|
|
402
|
+
statusNone: "実行記録はまだありません",
|
|
403
|
+
statusHeader: "branch · scope · 状態 · verdict",
|
|
404
|
+
statusLine: (row) => `${row.branch} · ${row.scope} · ${row.state} · ${row.verdict}`,
|
|
405
|
+
stateWorking: "作業中",
|
|
406
|
+
stateUnreviewed: "未レビュー",
|
|
407
|
+
stateMerged: "マージ済み",
|
|
408
|
+
stateCleaned: "clean 済み",
|
|
409
|
+
mergeUsage: "使い方: /fleet merge <branch> [--force]",
|
|
410
|
+
mergeStarting: (branch) => `merge ${branch} を実行中…`,
|
|
411
|
+
mergeDone: (branch, output) => `merge ${branch} 完了${output ? ` — ${output}` : ""}`,
|
|
412
|
+
cleanUsage: "使い方: /fleet clean <branch> [--force]",
|
|
413
|
+
cleanStarting: (branch) => `clean ${branch} を実行中…`,
|
|
414
|
+
cleanDone: (branch, worktree, branchDeleted, panes) =>
|
|
415
|
+
`clean ${branch} 完了(worktree ${worktree ? "削除" : "なし"} · branch ${branchDeleted ? "削除" : "なし"} · pane ${panes})`,
|
|
416
|
+
verdictRecorded: (branch, verdict, findings) => `verdict ${branch} を記録しました(${verdict} · findings ${findings})`,
|
|
417
|
+
notACheckout: "fleet: git のチェックアウトの中ではありません",
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const EN: Strings = {
|
|
421
|
+
title: "fleet",
|
|
422
|
+
count: (count) => `${count} waiting`,
|
|
423
|
+
empty: "No pane is waiting for approval",
|
|
424
|
+
above: (lines) => `↑ ${lines} lines`,
|
|
425
|
+
listKeys: "↑↓ select · Enter open · Esc close",
|
|
426
|
+
detailKeys: "Enter send text · ctrl+k send keys · PgUp/PgDn · Esc back",
|
|
427
|
+
answer: "Answer",
|
|
428
|
+
answerHint: "text, or raw keys (esc / 1 / up ...)",
|
|
429
|
+
noResend: "A failure is never retried automatically",
|
|
430
|
+
sent: "Sent",
|
|
431
|
+
sending: "Sending...",
|
|
432
|
+
noQuestion: "(reading the question...)",
|
|
433
|
+
blockedNotification: (who) => `${who} is waiting for approval`,
|
|
434
|
+
unknownSubcommand: (name) => `Unknown subcommand: ${name}`,
|
|
435
|
+
state: (entry) => entry.state_labels?.blocked ?? "blocked",
|
|
436
|
+
recipeUsage: "Usage: /fleet recipe save <name> | apply <name> [--start] | ls",
|
|
437
|
+
recipeSaving: (name) => `Saving recipe ${name}...`,
|
|
438
|
+
recipeSaved: (name, panes, path) => `Saved recipe ${name} (${panes} panes · ${path})`,
|
|
439
|
+
recipeApplied: (name, panes) => `Applied recipe ${name} as a new tab (${panes} panes)`,
|
|
440
|
+
recipeNone: "No recipes yet",
|
|
441
|
+
recipeList: (recipes) => `Recipes: ${recipes.map((recipe) => `${recipe.name} (${recipe.panes})`).join(", ")}`,
|
|
442
|
+
worktreeUsage: "Usage: /fleet worktree create <branch> [--base <ref>] [--label <text>]",
|
|
443
|
+
worktreeCreating: (branch) => `Creating worktree ${branch}...`,
|
|
444
|
+
worktreeCreated: (branch, path, workspaceId, env) => `Created worktree ${branch} (${workspaceId} · ${path}) — ${env}`,
|
|
445
|
+
worktreeWarningPrefix: "worktree environment:",
|
|
446
|
+
fleetWarningPrefix: "fleet:",
|
|
447
|
+
forkUsage: 'Usage: /fleet fork <branch> --task "<text>" [--base <ref>] [--scope implementation] [--no-install] [--no-start]',
|
|
448
|
+
forkCreating: (branch) => `Preparing fork ${branch}...`,
|
|
449
|
+
forkCreated: (branch, path, workspaceId, state) => `Forked ${branch} (${workspaceId} · ${path}) — ${state}`,
|
|
450
|
+
forkNoStart: "--no-start: no pane and no agent were created",
|
|
451
|
+
forkRunning: (paneId, agent, prepare) => `pane ${paneId} · agent ${agent} · ${prepare}`,
|
|
452
|
+
forkNoInstall: "no install (no lockfile, or --no-install)",
|
|
453
|
+
forkInstalled: (command) => `${command} finished`,
|
|
454
|
+
forkInstallFailed: (command, error) => `${command} failed (${error})`,
|
|
455
|
+
reviewUsage: 'Usage: /fleet review <branch> --task "<text>" [--base <ref>]',
|
|
456
|
+
reviewStarting: (branch) => `Preparing review of ${branch}...`,
|
|
457
|
+
reviewStarted: (branch, paneId, agent, material) => `Reviewing ${branch} (pane ${paneId} · agent ${agent} · ${material})`,
|
|
458
|
+
reviewMaterial: (diff, messages) => `diff ${diff} characters · ${messages} author messages`,
|
|
459
|
+
statusNone: "No run has been recorded yet",
|
|
460
|
+
statusHeader: "branch · scope · state · verdict",
|
|
461
|
+
statusLine: (row) => `${row.branch} · ${row.scope} · ${row.state} · ${row.verdict}`,
|
|
462
|
+
stateWorking: "working",
|
|
463
|
+
stateUnreviewed: "unreviewed",
|
|
464
|
+
stateMerged: "merged",
|
|
465
|
+
stateCleaned: "cleaned",
|
|
466
|
+
mergeUsage: "Usage: /fleet merge <branch> [--force]",
|
|
467
|
+
mergeStarting: (branch) => `Merging ${branch}...`,
|
|
468
|
+
mergeDone: (branch, output) => `Merged ${branch}${output ? ` — ${output}` : ""}`,
|
|
469
|
+
cleanUsage: "Usage: /fleet clean <branch> [--force]",
|
|
470
|
+
cleanStarting: (branch) => `Cleaning ${branch}...`,
|
|
471
|
+
cleanDone: (branch, worktree, branchDeleted, panes) =>
|
|
472
|
+
`Cleaned ${branch} (worktree ${worktree ? "removed" : "none"} · branch ${branchDeleted ? "deleted" : "none"} · ${panes} panes)`,
|
|
473
|
+
verdictRecorded: (branch, verdict, findings) => `Recorded ${verdict} for ${branch} (${findings} findings)`,
|
|
474
|
+
notACheckout: "fleet: not inside a git checkout",
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
export function strings(): Strings {
|
|
478
|
+
const locale = process.env.LC_ALL ?? process.env.LANG ?? "";
|
|
479
|
+
return /^ja/i.test(locale) ? JA : EN;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------- overlay
|
|
483
|
+
|
|
484
|
+
type Mode = "list" | "detail";
|
|
485
|
+
|
|
486
|
+
function label(entry: BlockedPane, t: Strings): string {
|
|
487
|
+
return entry.name ?? entry.agent ?? entry.pane_id;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export class FleetOverlay implements Component, Focusable {
|
|
491
|
+
private readonly broker: ApprovalBroker;
|
|
492
|
+
private readonly t: Strings;
|
|
493
|
+
private tui!: TUI;
|
|
494
|
+
private theme!: Theme;
|
|
495
|
+
private done!: () => void;
|
|
496
|
+
private editor: Editor | undefined;
|
|
497
|
+
private mode: Mode = "list";
|
|
498
|
+
private selected = 0;
|
|
499
|
+
private scroll = 0;
|
|
500
|
+
private pending = false;
|
|
501
|
+
private error: string | undefined;
|
|
502
|
+
private status: string | undefined;
|
|
503
|
+
private focusedState = false;
|
|
504
|
+
private editorHeight = 3;
|
|
505
|
+
|
|
506
|
+
constructor(broker: ApprovalBroker, t: Strings) {
|
|
507
|
+
this.broker = broker;
|
|
508
|
+
this.t = t;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
get focused(): boolean {
|
|
512
|
+
return this.focusedState;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// IME preedit follows the inner editor, so it has to own the focus flag.
|
|
516
|
+
set focused(value: boolean) {
|
|
517
|
+
this.focusedState = value;
|
|
518
|
+
if (this.editor) this.editor.focused = value && this.mode === "detail";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
attach(tui: TUI, theme: Theme, done: () => void): void {
|
|
522
|
+
this.tui = tui;
|
|
523
|
+
this.theme = theme;
|
|
524
|
+
this.done = done;
|
|
525
|
+
if (!this.editor) {
|
|
526
|
+
const editorTheme: EditorTheme = {
|
|
527
|
+
borderColor: (text) => this.theme.fg("borderMuted", text),
|
|
528
|
+
selectList: getSelectListTheme(),
|
|
529
|
+
};
|
|
530
|
+
this.editor = new Editor(tui, editorTheme, { paddingX: 1 });
|
|
531
|
+
this.editor.disableSubmit = true;
|
|
532
|
+
this.editor.onChange = () => this.tui.requestRender();
|
|
533
|
+
}
|
|
534
|
+
this.focused = true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private current(): BlockedPane | undefined {
|
|
538
|
+
return this.broker.entries()[this.selected];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
invalidate(): void {}
|
|
542
|
+
|
|
543
|
+
handleInput(data: string): void {
|
|
544
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
545
|
+
if (this.mode === "detail") {
|
|
546
|
+
this.mode = "list";
|
|
547
|
+
this.error = undefined;
|
|
548
|
+
this.scroll = 0;
|
|
549
|
+
this.focused = true;
|
|
550
|
+
this.tui.requestRender();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
this.done();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (this.pending) return;
|
|
557
|
+
if (this.mode === "list") this.handleListInput(data);
|
|
558
|
+
else this.handleDetailInput(data);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private handleListInput(data: string): void {
|
|
562
|
+
const entries = this.broker.entries();
|
|
563
|
+
if (matchesKey(data, Key.up)) {
|
|
564
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
565
|
+
} else if (matchesKey(data, Key.down)) {
|
|
566
|
+
this.selected = Math.min(Math.max(0, entries.length - 1), this.selected + 1);
|
|
567
|
+
} else if (/^[1-9]$/.test(data)) {
|
|
568
|
+
const index = Number(data) - 1;
|
|
569
|
+
if (index >= entries.length) return;
|
|
570
|
+
this.selected = index;
|
|
571
|
+
this.openDetail();
|
|
572
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
573
|
+
if (entries.length === 0) return;
|
|
574
|
+
this.openDetail();
|
|
575
|
+
} else {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
this.tui.requestRender();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private openDetail(): void {
|
|
582
|
+
this.mode = "detail";
|
|
583
|
+
this.scroll = 0;
|
|
584
|
+
this.error = undefined;
|
|
585
|
+
this.status = undefined;
|
|
586
|
+
this.editor?.setText("");
|
|
587
|
+
this.focused = true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private handleDetailInput(data: string): void {
|
|
591
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
592
|
+
this.scrollBy(Math.max(3, this.bodyHeight() - 2));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
596
|
+
this.scrollBy(-Math.max(3, this.bodyHeight() - 2));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (matchesKey(data, Key.ctrl("k"))) {
|
|
600
|
+
void this.send("keys");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (matchesKey(data, Key.enter) && !matchesKey(data, Key.shift("enter"))) {
|
|
604
|
+
void this.send("text");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
this.editor?.handleInput(data);
|
|
608
|
+
this.tui.requestRender();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private scrollBy(lines: number): void {
|
|
612
|
+
const total = this.questionLines(this.contentWidth()).length;
|
|
613
|
+
const max = Math.max(0, total - this.bodyHeight());
|
|
614
|
+
this.scroll = Math.min(max, Math.max(0, this.scroll + lines));
|
|
615
|
+
this.tui.requestRender();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private async send(route: "keys" | "text"): Promise<void> {
|
|
619
|
+
const entry = this.current();
|
|
620
|
+
if (!entry) {
|
|
621
|
+
this.mode = "list";
|
|
622
|
+
this.tui.requestRender();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const input = (this.editor?.getText() ?? "").trim();
|
|
626
|
+
if (route === "text" && input === "") return;
|
|
627
|
+
// An empty ctrl+k is a bare Enter, which is what most approval dialogs want.
|
|
628
|
+
const keys = input === "" ? ["enter"] : input.split(/\s+/);
|
|
629
|
+
|
|
630
|
+
this.pending = true;
|
|
631
|
+
this.error = undefined;
|
|
632
|
+
this.status = this.t.sending;
|
|
633
|
+
this.tui.requestRender();
|
|
634
|
+
|
|
635
|
+
const result =
|
|
636
|
+
route === "keys" ? await this.broker.sendKeys(entry.pane_id, keys) : await this.broker.sendText(entry.pane_id, input);
|
|
637
|
+
|
|
638
|
+
this.pending = false;
|
|
639
|
+
if (result.ok) {
|
|
640
|
+
// The row stays until herdr reports the new status: a write that
|
|
641
|
+
// succeeded is not proof the agent moved on.
|
|
642
|
+
this.status = this.t.sent;
|
|
643
|
+
this.mode = "list";
|
|
644
|
+
this.editor?.setText("");
|
|
645
|
+
this.focused = true;
|
|
646
|
+
} else {
|
|
647
|
+
this.status = undefined;
|
|
648
|
+
this.error = result.error;
|
|
649
|
+
}
|
|
650
|
+
this.tui.requestRender();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ------------------------------------------------------------ render
|
|
654
|
+
|
|
655
|
+
render(width: number): string[] {
|
|
656
|
+
const inner = Math.max(24, width - 2);
|
|
657
|
+
const entries = this.broker.entries();
|
|
658
|
+
this.selected = entries.length === 0 ? 0 : Math.min(this.selected, entries.length - 1);
|
|
659
|
+
|
|
660
|
+
const header = [
|
|
661
|
+
this.theme.bold(this.theme.fg("accent", this.t.title)),
|
|
662
|
+
this.theme.fg("dim", this.t.count(entries.length)),
|
|
663
|
+
].join(this.theme.fg("dim", " · "));
|
|
664
|
+
|
|
665
|
+
const body = this.mode === "detail" ? this.questionLines(inner - 4) : this.listLines(entries, inner - 4);
|
|
666
|
+
const bodyHeight = this.bodyHeight();
|
|
667
|
+
const maxScroll = Math.max(0, body.length - bodyHeight);
|
|
668
|
+
if (this.scroll > maxScroll) this.scroll = maxScroll;
|
|
669
|
+
const start = Math.max(0, body.length - bodyHeight - this.scroll);
|
|
670
|
+
|
|
671
|
+
const lines: string[] = [this.rule(inner, "╭", "╮")];
|
|
672
|
+
lines.push(this.frame(` ${truncateToWidth(header, inner - 4)}`, inner));
|
|
673
|
+
lines.push(this.rule(inner, "├", "┤"));
|
|
674
|
+
const visible = body.slice(start, start + bodyHeight);
|
|
675
|
+
for (const line of visible) lines.push(this.frame(` ${line}`, inner));
|
|
676
|
+
for (let i = visible.length; i < bodyHeight; i++) lines.push(this.frame("", inner));
|
|
677
|
+
|
|
678
|
+
if (this.mode === "detail") {
|
|
679
|
+
const hint = this.theme.fg("dim", `${this.t.answer}: ${this.t.answerHint}`);
|
|
680
|
+
lines.push(this.frame(` ${truncateToWidth(hint, inner - 4)}`, inner));
|
|
681
|
+
const editorLines = this.editor?.render(inner - 2) ?? [];
|
|
682
|
+
this.editorHeight = Math.max(1, editorLines.length);
|
|
683
|
+
for (const line of editorLines) lines.push(this.frame(` ${line}`, inner));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (start > 0) lines.push(this.frame(` ${this.theme.fg("warning", this.t.above(start))}`, inner));
|
|
687
|
+
const notice = this.error
|
|
688
|
+
? this.theme.fg("error", this.error)
|
|
689
|
+
: this.status
|
|
690
|
+
? this.theme.fg(this.status === this.t.sent ? "success" : "muted", this.status)
|
|
691
|
+
: this.broker.error()
|
|
692
|
+
? this.theme.fg("warning", this.broker.error()!)
|
|
693
|
+
: undefined;
|
|
694
|
+
for (const line of notice ? wrapTextWithAnsi(notice, inner - 4) : []) lines.push(this.frame(` ${line}`, inner));
|
|
695
|
+
|
|
696
|
+
const keys = this.mode === "detail" && !this.error ? `${this.t.detailKeys} · ${this.t.noResend}` : this.mode === "detail" ? this.t.detailKeys : this.t.listKeys;
|
|
697
|
+
lines.push(this.frame(` ${truncateToWidth(this.theme.fg("dim", keys), inner - 4)}`, inner));
|
|
698
|
+
lines.push(this.rule(inner, "╰", "╯"));
|
|
699
|
+
return lines;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private listLines(entries: BlockedPane[], width: number): string[] {
|
|
703
|
+
if (entries.length === 0) return [this.theme.fg("dim", this.t.empty)];
|
|
704
|
+
return entries.map((entry, index) => {
|
|
705
|
+
const marker = index === this.selected ? this.theme.fg("accent", ">") : " ";
|
|
706
|
+
const parts = [
|
|
707
|
+
this.theme.fg("dim", `${entry.workspace_id} `),
|
|
708
|
+
this.theme.bold(label(entry, this.t)),
|
|
709
|
+
this.theme.fg("dim", ` ${entry.pane_id}`),
|
|
710
|
+
this.theme.fg("warning", ` ${this.t.state(entry)}`),
|
|
711
|
+
].join("");
|
|
712
|
+
return truncateToWidth(`${marker} ${parts}`, width);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private questionLines(width: number): string[] {
|
|
717
|
+
const entry = this.current();
|
|
718
|
+
if (!entry) return [];
|
|
719
|
+
return [
|
|
720
|
+
this.theme.fg("dim", `${label(entry, this.t)} · ${entry.pane_id}`),
|
|
721
|
+
"",
|
|
722
|
+
...wrapTextWithAnsi(entry.question ?? this.t.noQuestion, width),
|
|
723
|
+
];
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private bodyHeight(): number {
|
|
727
|
+
const chrome = this.mode === "detail" ? this.editorHeight + 7 : 6;
|
|
728
|
+
return Math.max(3, this.height() - chrome);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private height(): number {
|
|
732
|
+
const rows = this.mode === "detail" ? Math.floor(this.tui.terminal.rows * 0.7) : Math.min(16, 8 + this.broker.entries().length);
|
|
733
|
+
return Math.max(10, rows);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private contentWidth(): number {
|
|
737
|
+
return Math.max(24, this.tui.terminal.columns - 6);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private rule(width: number, left: string, right: string): string {
|
|
741
|
+
return this.theme.fg("borderMuted", left + "─".repeat(Math.max(0, width)) + right);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private frame(line: string, width: number): string {
|
|
745
|
+
const border = this.theme.fg("borderMuted", "│");
|
|
746
|
+
return border + line + " ".repeat(Math.max(0, width - visibleWidth(line))) + border;
|
|
747
|
+
}
|
|
748
|
+
}
|