@roodriigoooo/pi-docket 0.4.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/CHANGELOG.md +132 -0
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/assets/docket_logo.jpeg +0 -0
- package/docs/adr/0001-bundle-first-checkpoints.md +21 -0
- package/docs/adr/0002-rename-to-docket.md +44 -0
- package/docs/architecture.md +101 -0
- package/docs/bundle-guidelines.md +39 -0
- package/docs/configuration.md +191 -0
- package/docs/releases/0.4.0.md +93 -0
- package/extensions/artifact-catalog.ts +467 -0
- package/extensions/background-work.ts +510 -0
- package/extensions/checkpoint-commands.ts +147 -0
- package/extensions/checkpoint-lifecycle.ts +195 -0
- package/extensions/checkpoint-selector.ts +162 -0
- package/extensions/checkpoint-store.ts +230 -0
- package/extensions/checkpoint-summarizer.ts +141 -0
- package/extensions/docket-command-grammar.ts +319 -0
- package/extensions/docket-command-router.ts +626 -0
- package/extensions/docket-config.ts +88 -0
- package/extensions/docket-extension-surface.ts +43 -0
- package/extensions/docket-navigator.ts +585 -0
- package/extensions/docket.README.md +46 -0
- package/extensions/docket.ts +2965 -0
- package/extensions/event-log.ts +121 -0
- package/extensions/git-context.ts +44 -0
- package/extensions/loaded-artifact-context.ts +228 -0
- package/extensions/search-index.ts +140 -0
- package/extensions/types.ts +40 -0
- package/extensions/worker-activity.ts +402 -0
- package/extensions/worker-changes.ts +180 -0
- package/extensions/worker-commands.ts +251 -0
- package/extensions/worker-dock-cache.ts +147 -0
- package/extensions/worker-events.ts +87 -0
- package/extensions/worker-eviction.ts +55 -0
- package/extensions/worker-guardrails.md +125 -0
- package/extensions/worker-kinds/patcher.md +23 -0
- package/extensions/worker-kinds/scout.md +17 -0
- package/extensions/worker-kinds.ts +280 -0
- package/extensions/worker-result.ts +193 -0
- package/extensions/worker-store.ts +621 -0
- package/extensions/worker-summary-embed.ts +98 -0
- package/package.json +53 -0
|
@@ -0,0 +1,2965 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docket — session artifacts as first-class objects.
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* /docket open inbox
|
|
6
|
+
* /docket answers [query] browse assistant/worker answers
|
|
7
|
+
* /docket log audit timeline grouped by episode
|
|
8
|
+
* /docket search <query> ranked artifact search
|
|
9
|
+
* /docket save [flags] [note]
|
|
10
|
+
* /docket load [id|last|w<N>]
|
|
11
|
+
* /docket list
|
|
12
|
+
* /docket delete [id|last|w<N>]
|
|
13
|
+
* /docket ref <artifact-id>
|
|
14
|
+
* /docket inject-full <artifact-id>
|
|
15
|
+
* /docket copy <artifact-id>
|
|
16
|
+
*
|
|
17
|
+
* Save flags:
|
|
18
|
+
* --once, --summarize, --model, --max-output
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
22
|
+
import fs from "node:fs/promises";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { StringEnum, Type } from "@mariozechner/pi-ai";
|
|
26
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, MessageRenderer } from "@mariozechner/pi-coding-agent";
|
|
27
|
+
import { DynamicBorder, getLanguageFromPath, highlightCode, isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
28
|
+
import type { ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
29
|
+
import {
|
|
30
|
+
Box,
|
|
31
|
+
Container,
|
|
32
|
+
Key,
|
|
33
|
+
Spacer,
|
|
34
|
+
Text,
|
|
35
|
+
matchesKey,
|
|
36
|
+
truncateToWidth,
|
|
37
|
+
visibleWidth,
|
|
38
|
+
type Component,
|
|
39
|
+
type TUI,
|
|
40
|
+
} from "@mariozechner/pi-tui";
|
|
41
|
+
import { deriveWorkerState, DOCK_PULSE_INTERVAL_MS, heartbeatArtifactSignature, HEARTBEAT_ARTIFACT_CAP, isPromptDockWorker, namespaceWorkerArtifacts, workerActivityChip, workerPulseGlyph, workerDisplayName, workerDoneClarificationQuestion, workerHeartbeatPatch, workerLaunchDetail, workerLaunchSubject, workerMascotLines, workerProtocolMessage, workerProtocolPatch, workerProtocolResultText, workerQuestions, workerShortLabel, workerSourceLabel, workerStatusArtifact, workerSummaryName, workerTodoProgress, workerTodosPatch, type WorkerDerivedState, type WorkerDoneInput, type WorkerProtocolState, type WorkerStatus, type WorkerTodoInput } from "./background-work.js";
|
|
42
|
+
import { artifactFilePath, createArtifactCatalog, formatArtifact, type ArtifactCatalog } from "./artifact-catalog.js";
|
|
43
|
+
import { createCheckpointCommands, type ResumeAction, type ResumeMode, type ResumeSelection } from "./checkpoint-commands.js";
|
|
44
|
+
import { createCheckpointLifecycle } from "./checkpoint-lifecycle.js";
|
|
45
|
+
import { createCheckpointStore, type CheckpointSummary } from "./checkpoint-store.js";
|
|
46
|
+
import { gitSnapshotLabel, readGitSnapshot } from "./git-context.js";
|
|
47
|
+
import { createLoadedArtifactContext, type Chip, type ChipToggleResult } from "./loaded-artifact-context.js";
|
|
48
|
+
import { loadConfig } from "./docket-config.js";
|
|
49
|
+
import { parseDocketCommand, parseDocketWorkerShellCommand, docketUsage, DOCKET_COMMANDS } from "./docket-command-grammar.js";
|
|
50
|
+
import { createDocketCommandRouter, type LoadPickerMode, type LoadPickerSelection, type ParallelWorkAction, type ParallelWorkEntry, type DocketBrowserAction, type DocketVerdictAction } from "./docket-command-router.js";
|
|
51
|
+
import { availableSources, episodesFromItems, handleNavigatorIntent, initialNavigatorState, navigatorSourceLabel, navigatorViewModel, reviewCategoryLabel, sameNavigatorSource, type EpisodeSummary, type NavigatorAction, type NavigatorIntent, type NavigatorMode, type NavigatorSource, type NavigatorState, type ReviewActionId, type ReviewBucket, type ReviewCategory, type ReviewItem, type ReviewQueueState, type ReviewReasonId } from "./docket-navigator.js";
|
|
52
|
+
import type { Artifact, ArtifactKind, CheckpointIndexEntry } from "./types.js";
|
|
53
|
+
import { createWorkerCommands, workerAge, workerCompletionCandidates } from "./worker-commands.js";
|
|
54
|
+
import { dockRowsForRender, workerActivityPreviewLines, workerActivityRows, workerActivityTotals, type DockRow, type WorkerActivityRow } from "./worker-activity.js";
|
|
55
|
+
import { workerChangeSetArtifact, promoteWorkerChangeSet } from "./worker-changes.js";
|
|
56
|
+
import { workerResultHeadline, workerResultReport, workerResultText } from "./worker-result.js";
|
|
57
|
+
import { createWorkerStore, isSharedSessionTarget, projectKey, readWorkerStatusSync, sharedSessionExists, DOCKET_WORKER_ENV, workerInProject, workerProjectKey } from "./worker-store.js";
|
|
58
|
+
import { WorkerSnapshotCache, watchWorkersRoot, type Unwatcher } from "./worker-dock-cache.js";
|
|
59
|
+
import { appendWorkerEventSync, type WorkerEvent } from "./worker-events.js";
|
|
60
|
+
import { formatReadyEmbedMessage } from "./worker-summary-embed.js";
|
|
61
|
+
import { dockIdleHideMs, isDockIdleEvictable, pruneAfterMs, selectPrunableWorkers } from "./worker-eviction.js";
|
|
62
|
+
import { createWorkerKindRegistry, workerKindGuardrailsAppendix, DEFAULT_KIND_NAME, type WorkerKind } from "./worker-kinds.js";
|
|
63
|
+
import { installDocketExtensionSurface, type DocketExtensionSurfaceInternals } from "./docket-extension-surface.js";
|
|
64
|
+
|
|
65
|
+
async function runCommand(command: string, args: string[], input?: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const child = spawn(command, args);
|
|
68
|
+
let stdout = "";
|
|
69
|
+
let stderr = "";
|
|
70
|
+
child.stdout.on("data", (data) => (stdout += data.toString("utf8")));
|
|
71
|
+
child.stderr.on("data", (data) => (stderr += data.toString("utf8")));
|
|
72
|
+
child.on("error", reject);
|
|
73
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
74
|
+
child.stdin.end(input ?? "");
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function copyToClipboard(text: string): Promise<boolean> {
|
|
79
|
+
const candidates = process.platform === "darwin" ? [["pbcopy", []]] : [["wl-copy", []], ["xclip", ["-selection", "clipboard"]]];
|
|
80
|
+
for (const [cmd, args] of candidates as Array<[string, string[]]>) {
|
|
81
|
+
try {
|
|
82
|
+
const result = await runCommand(cmd, args, text);
|
|
83
|
+
if (result.code === 0) return true;
|
|
84
|
+
} catch {
|
|
85
|
+
// try next clipboard command
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shellSingleQuote(value: string): string {
|
|
92
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toolEventTarget(event: { toolName?: string; input?: Record<string, unknown> }): string | undefined {
|
|
96
|
+
const input = event.input;
|
|
97
|
+
if (!input || typeof input !== "object") return undefined;
|
|
98
|
+
const candidates = ["file_path", "path", "filePath", "file", "target", "url", "pattern"] as const;
|
|
99
|
+
for (const key of candidates) {
|
|
100
|
+
const value = (input as Record<string, unknown>)[key];
|
|
101
|
+
if (typeof value === "string" && value.length > 0) {
|
|
102
|
+
const trimmed = value.replace(/^\/Users\/[^/]+\//, "~/").trim();
|
|
103
|
+
return trimmed.length > 48 ? `…${trimmed.slice(-47)}` : trimmed;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const command = (input as Record<string, unknown>).command;
|
|
107
|
+
if (typeof command === "string" && command.length > 0) {
|
|
108
|
+
const first = command.split(/\s+/)[0] ?? "";
|
|
109
|
+
return first || undefined;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class DocketTextViewer implements Component {
|
|
115
|
+
private offset = 0;
|
|
116
|
+
private column = 0;
|
|
117
|
+
private lines: string[];
|
|
118
|
+
private cachedWidth?: number;
|
|
119
|
+
private cachedLines?: string[];
|
|
120
|
+
private viewportHeight = 34;
|
|
121
|
+
|
|
122
|
+
constructor(private tui: TUI, private theme: any, private title: string, text: string, private done: () => void) {
|
|
123
|
+
this.lines = text.split("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
handleInput(data: string): void {
|
|
127
|
+
const maxOffset = Math.max(0, this.lines.length - this.viewportHeight);
|
|
128
|
+
const half = Math.max(1, Math.floor(this.viewportHeight / 2));
|
|
129
|
+
const page = Math.max(1, this.viewportHeight - 2);
|
|
130
|
+
if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
|
|
131
|
+
this.done();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const before = this.offset;
|
|
135
|
+
const beforeColumn = this.column;
|
|
136
|
+
if (data === "j" || matchesKey(data, Key.down)) this.offset = Math.min(maxOffset, this.offset + 1);
|
|
137
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.offset = Math.max(0, this.offset - 1);
|
|
138
|
+
else if (data === "J") this.offset = Math.min(maxOffset, this.offset + 5);
|
|
139
|
+
else if (data === "K") this.offset = Math.max(0, this.offset - 5);
|
|
140
|
+
else if (data === "d" || matchesKey(data, Key.ctrl("d"))) this.offset = Math.min(maxOffset, this.offset + half);
|
|
141
|
+
else if (data === "u" || matchesKey(data, Key.ctrl("u"))) this.offset = Math.max(0, this.offset - half);
|
|
142
|
+
else if (data === " " || matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) this.offset = Math.min(maxOffset, this.offset + page);
|
|
143
|
+
else if (data === "b" || matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("b"))) this.offset = Math.max(0, this.offset - page);
|
|
144
|
+
else if (data === "g") this.offset = 0;
|
|
145
|
+
else if (data === "G") this.offset = maxOffset;
|
|
146
|
+
else if (data === "h" || matchesKey(data, Key.left)) this.column = Math.max(0, this.column - 8);
|
|
147
|
+
else if (data === "l" || matchesKey(data, Key.right)) this.column += 8;
|
|
148
|
+
else if (data === "0") this.column = 0;
|
|
149
|
+
if (this.offset === before && this.column === beforeColumn) return;
|
|
150
|
+
this.invalidate();
|
|
151
|
+
this.tui.requestRender();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
invalidate(): void {
|
|
155
|
+
this.cachedWidth = undefined;
|
|
156
|
+
this.cachedLines = undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
render(width: number): string[] {
|
|
160
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
161
|
+
const container = new Box(2, 1, docketCardBg(this.theme));
|
|
162
|
+
const innerWidth = Math.max(20, width - 4);
|
|
163
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
164
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
165
|
+
const outerBorder = (s: string) => this.theme.fg("borderAccent", s);
|
|
166
|
+
const headerLeft = ` ${accent(this.theme.bold("docket · inspect"))} ${dim(this.title)} `;
|
|
167
|
+
const headerRight = ` ${dim(`${Math.min(this.offset + 1, this.lines.length)}-${Math.min(this.offset + 34, this.lines.length)}/${this.lines.length} · col ${this.column}`)} `;
|
|
168
|
+
container.addChild(new Text(fitBorder(headerLeft, headerRight, innerWidth, outerBorder, TOP_CORNERS), 0, 0));
|
|
169
|
+
for (const line of this.lines.slice(this.offset, this.offset + 34)) {
|
|
170
|
+
const visible = this.column > 0 ? [...line].slice(this.column).join("") : line;
|
|
171
|
+
container.addChild(new Text(truncateToWidth(visible, innerWidth - 2), 1, 0));
|
|
172
|
+
}
|
|
173
|
+
container.addChild(new Text(dim("j/k line · h/l horizontal · 0 left · Space/b page · g/G top/bottom · q close"), 1, 0));
|
|
174
|
+
container.addChild(new Text(fitBorder("", "", innerWidth, outerBorder, BOTTOM_CORNERS), 0, 0));
|
|
175
|
+
this.cachedLines = container.render(width);
|
|
176
|
+
this.cachedWidth = width;
|
|
177
|
+
return this.cachedLines;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
class DocketFileViewer implements Component {
|
|
182
|
+
private offset = 0;
|
|
183
|
+
private column = 0;
|
|
184
|
+
private viewportHeight = 30;
|
|
185
|
+
private cachedWidth?: number;
|
|
186
|
+
private cachedLines?: string[];
|
|
187
|
+
|
|
188
|
+
constructor(
|
|
189
|
+
private tui: TUI,
|
|
190
|
+
private theme: any,
|
|
191
|
+
private filePath: string,
|
|
192
|
+
private language: string | undefined,
|
|
193
|
+
private lines: string[],
|
|
194
|
+
private done: () => void,
|
|
195
|
+
) {}
|
|
196
|
+
|
|
197
|
+
handleInput(data: string): void {
|
|
198
|
+
const maxOffset = Math.max(0, this.lines.length - this.viewportHeight);
|
|
199
|
+
if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
|
|
200
|
+
this.done();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const half = Math.max(1, Math.floor(this.viewportHeight / 2));
|
|
204
|
+
const page = Math.max(1, this.viewportHeight - 2);
|
|
205
|
+
const before = this.offset;
|
|
206
|
+
const beforeColumn = this.column;
|
|
207
|
+
if (data === "j" || matchesKey(data, Key.down)) this.offset = Math.min(maxOffset, this.offset + 1);
|
|
208
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.offset = Math.max(0, this.offset - 1);
|
|
209
|
+
else if (data === "J") this.offset = Math.min(maxOffset, this.offset + 5);
|
|
210
|
+
else if (data === "K") this.offset = Math.max(0, this.offset - 5);
|
|
211
|
+
else if (data === "d" || matchesKey(data, Key.ctrl("d"))) this.offset = Math.min(maxOffset, this.offset + half);
|
|
212
|
+
else if (data === "u" || matchesKey(data, Key.ctrl("u"))) this.offset = Math.max(0, this.offset - half);
|
|
213
|
+
else if (data === " " || matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) this.offset = Math.min(maxOffset, this.offset + page);
|
|
214
|
+
else if (data === "b" || matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("b"))) this.offset = Math.max(0, this.offset - page);
|
|
215
|
+
else if (data === "g") this.offset = 0;
|
|
216
|
+
else if (data === "G") this.offset = maxOffset;
|
|
217
|
+
else if (data === "h" || matchesKey(data, Key.left)) this.column = Math.max(0, this.column - 8);
|
|
218
|
+
else if (data === "l" || matchesKey(data, Key.right)) this.column += 8;
|
|
219
|
+
else if (data === "0") this.column = 0;
|
|
220
|
+
if (this.offset === before && this.column === beforeColumn) return;
|
|
221
|
+
this.invalidate();
|
|
222
|
+
this.tui.requestRender();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
invalidate(): void {
|
|
226
|
+
this.cachedWidth = undefined;
|
|
227
|
+
this.cachedLines = undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
render(width: number): string[] {
|
|
231
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
232
|
+
const container = new Box(2, 1, docketCardBg(this.theme));
|
|
233
|
+
const innerWidth = Math.max(20, width - 4);
|
|
234
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
235
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
236
|
+
const muted = (s: string) => this.theme.fg("muted", s);
|
|
237
|
+
const outerBorder = (s: string) => this.theme.fg("borderAccent", s);
|
|
238
|
+
|
|
239
|
+
const lineNumWidth = Math.max(3, String(this.lines.length).length);
|
|
240
|
+
const last = Math.min(this.offset + this.viewportHeight, this.lines.length);
|
|
241
|
+
const visible = this.lines.slice(this.offset, this.offset + this.viewportHeight).map((line) => this.column > 0 ? [...line].slice(this.column).join("") : line);
|
|
242
|
+
const highlighted = highlightCode(visible.join("\n"), this.language);
|
|
243
|
+
const langTag = this.language ?? "text";
|
|
244
|
+
const headerLeft = ` ${accent(this.theme.bold(this.filePath))} ${dim(langTag)} `;
|
|
245
|
+
const headerRight = ` ${dim(`${Math.min(this.offset + 1, this.lines.length)}-${last}/${this.lines.length} · col ${this.column}`)} `;
|
|
246
|
+
container.addChild(new Text(fitBorder(headerLeft, headerRight, innerWidth, outerBorder, TOP_CORNERS), 0, 0));
|
|
247
|
+
|
|
248
|
+
for (let i = 0; i < visible.length; i++) {
|
|
249
|
+
const lineNo = this.offset + i + 1;
|
|
250
|
+
const numStr = muted(String(lineNo).padStart(lineNumWidth));
|
|
251
|
+
const code = highlighted[i] ?? "";
|
|
252
|
+
container.addChild(new Text(truncateToWidth(`${numStr} ${code}`, innerWidth - 2), 1, 0));
|
|
253
|
+
}
|
|
254
|
+
for (let i = visible.length; i < this.viewportHeight; i++) {
|
|
255
|
+
container.addChild(new Text("", 1, 0));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
container.addChild(new Text(dim("j/k line · h/l horizontal · 0 left · Space/b page · g/G top/bottom · q close"), 1, 0));
|
|
259
|
+
container.addChild(new Text(fitBorder("", "", innerWidth, outerBorder, BOTTOM_CORNERS), 0, 0));
|
|
260
|
+
this.cachedLines = container.render(width);
|
|
261
|
+
this.cachedWidth = width;
|
|
262
|
+
return this.cachedLines;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function showFileViewer(ctx: ExtensionCommandContext, filePath: string): Promise<void> {
|
|
267
|
+
let content: string;
|
|
268
|
+
try {
|
|
269
|
+
const stat = await fs.stat(filePath);
|
|
270
|
+
if (!stat.isFile()) {
|
|
271
|
+
await showTextViewer(ctx, filePath, `[Docket: ${filePath} is not a file]`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
content = await fs.readFile(filePath, "utf8");
|
|
275
|
+
} catch (err) {
|
|
276
|
+
await showTextViewer(ctx, filePath, `[Docket could not read ${filePath}: ${String(err)}]`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const language = getLanguageFromPath(filePath);
|
|
280
|
+
await ctx.ui.custom<void>(
|
|
281
|
+
(tui, theme, _kb, done) => new DocketFileViewer(tui, theme, filePath, language, content.split("\n"), done),
|
|
282
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "92%", minWidth: 84, maxHeight: "95%", margin: 1 } },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function showTextViewer(ctx: ExtensionCommandContext, title: string, text: string): Promise<void> {
|
|
287
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => new DocketTextViewer(tui, theme, title, text, done), {
|
|
288
|
+
overlay: true,
|
|
289
|
+
overlayOptions: { anchor: "center", width: "90%", minWidth: 90, maxHeight: "95%", margin: 1 },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function showArtifactViewer(ctx: ExtensionCommandContext, catalog: ArtifactCatalog, artifact: Artifact): Promise<void> {
|
|
294
|
+
if (artifact.kind === "file" && !artifactHasDiff(artifact)) {
|
|
295
|
+
const filePath = artifactFilePath(artifact, ctx.cwd);
|
|
296
|
+
if (filePath) {
|
|
297
|
+
await showFileViewer(ctx, filePath);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const inspected = await catalog.inspect(artifact);
|
|
302
|
+
await showTextViewer(ctx, inspected.title, inspected.text);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function relativeTime(timestamp?: number): string {
|
|
306
|
+
if (!timestamp) return "";
|
|
307
|
+
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
|
308
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
309
|
+
const minutes = Math.floor(seconds / 60);
|
|
310
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
311
|
+
const hours = Math.floor(minutes / 60);
|
|
312
|
+
if (hours < 24) return `${hours}h ago`;
|
|
313
|
+
const days = Math.floor(hours / 24);
|
|
314
|
+
if (days < 7) return `${days}d ago`;
|
|
315
|
+
return new Date(timestamp).toLocaleDateString();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function kindLabel(kind: ArtifactKind): string {
|
|
319
|
+
const labels: Record<ArtifactKind, string> = { command: "cmd", error: "error", file: "file", code: "code", prompt: "prompt", response: "answer", checkpoint: "restore" };
|
|
320
|
+
return labels[kind];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function colorKind(theme: any, kind: ArtifactKind, text: string): string {
|
|
324
|
+
if (kind === "error") return theme.fg("error", text);
|
|
325
|
+
if (kind === "command") return theme.fg("success", text);
|
|
326
|
+
if (kind === "file") return theme.fg("toolDiffAdded", text);
|
|
327
|
+
if (kind === "code") return theme.fg("warning", text);
|
|
328
|
+
if (kind === "checkpoint") return theme.fg("accent", text);
|
|
329
|
+
if (kind === "prompt") return theme.fg("customMessageLabel", text);
|
|
330
|
+
return theme.fg("muted", text);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
type BorderOptions = {
|
|
334
|
+
fill?: (s: string) => string;
|
|
335
|
+
left?: string;
|
|
336
|
+
right?: string;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
function fitBorder(left: string, right: string, width: number, border: (s: string) => string, options: BorderOptions = {}): string {
|
|
340
|
+
const cornerL = options.left ?? "─";
|
|
341
|
+
const cornerR = options.right ?? "─";
|
|
342
|
+
const fill = options.fill ?? border;
|
|
343
|
+
if (width <= 0) return "";
|
|
344
|
+
if (width === 1) return border(cornerL);
|
|
345
|
+
let leftText = left;
|
|
346
|
+
let rightText = right;
|
|
347
|
+
const fixedWidth = 2;
|
|
348
|
+
const minimumGap = leftText || rightText ? 3 : 0;
|
|
349
|
+
while (fixedWidth + visibleWidth(leftText) + visibleWidth(rightText) + minimumGap > width && visibleWidth(rightText) > 0) {
|
|
350
|
+
rightText = truncateToWidth(rightText, Math.max(0, visibleWidth(rightText) - 1), "");
|
|
351
|
+
}
|
|
352
|
+
while (fixedWidth + visibleWidth(leftText) + visibleWidth(rightText) + minimumGap > width && visibleWidth(leftText) > 0) {
|
|
353
|
+
leftText = truncateToWidth(leftText, Math.max(0, visibleWidth(leftText) - 1), "");
|
|
354
|
+
}
|
|
355
|
+
const gapWidth = Math.max(0, width - fixedWidth - visibleWidth(leftText) - visibleWidth(rightText));
|
|
356
|
+
return `${border(cornerL)}${leftText}${fill("─".repeat(gapWidth))}${rightText}${border(cornerR)}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function padAnsi(text: string, width: number): string {
|
|
360
|
+
return `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function wrapPlainText(text: string, width: number, maxLines = Infinity): string[] {
|
|
364
|
+
const limit = Math.max(12, width);
|
|
365
|
+
const out: string[] = [];
|
|
366
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
367
|
+
let line = raw.trim();
|
|
368
|
+
if (!line) {
|
|
369
|
+
out.push("");
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
while (visibleWidth(line) > limit && out.length < maxLines) {
|
|
373
|
+
let slice = truncateToWidth(line, limit, "");
|
|
374
|
+
const breakAt = slice.lastIndexOf(" ");
|
|
375
|
+
if (breakAt > limit * 0.45) slice = slice.slice(0, breakAt);
|
|
376
|
+
out.push(slice.trimEnd());
|
|
377
|
+
line = line.slice(slice.length).trimStart();
|
|
378
|
+
}
|
|
379
|
+
if (out.length < maxLines) out.push(line);
|
|
380
|
+
}
|
|
381
|
+
if (out.length > maxLines) return out.slice(0, maxLines);
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const TOP_CORNERS: BorderOptions = { left: "╭", right: "╮" };
|
|
386
|
+
const BOTTOM_CORNERS: BorderOptions = { left: "╰", right: "╯" };
|
|
387
|
+
|
|
388
|
+
function docketCardBg(theme: any): (s: string) => string {
|
|
389
|
+
return (s: string) => theme.bg("customMessageBg", s);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function activePill(theme: any, label: string): string {
|
|
393
|
+
return theme.fg("accent", theme.bold(` ${label} `));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function inactivePill(theme: any, label: string): string {
|
|
397
|
+
return theme.fg("dim", ` ${label} `);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function filterBar(theme: any, active: string): string {
|
|
401
|
+
const filters: Array<{ value: string; label: string }> = [
|
|
402
|
+
{ value: "all", label: "all" },
|
|
403
|
+
{ value: "error", label: "err" },
|
|
404
|
+
{ value: "command", label: "cmd" },
|
|
405
|
+
{ value: "file", label: "file" },
|
|
406
|
+
{ value: "code", label: "code" },
|
|
407
|
+
{ value: "prompt", label: "user" },
|
|
408
|
+
{ value: "response", label: "ai" },
|
|
409
|
+
{ value: "checkpoint", label: "ckpt" },
|
|
410
|
+
];
|
|
411
|
+
return filters.map((filter) => filter.value === active ? activePill(theme, filter.label) : inactivePill(theme, filter.label)).join(" ");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function sourceBar(theme: any, sources: NavigatorSource[], active: NavigatorSource): string {
|
|
415
|
+
if (sources.length <= 1) return "";
|
|
416
|
+
return sources
|
|
417
|
+
.map((source) => sameNavigatorSource(source, active) ? activePill(theme, navigatorSourceLabel(source)) : inactivePill(theme, navigatorSourceLabel(source)))
|
|
418
|
+
.join(" ");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function modeBar(theme: any, active: NavigatorMode): string {
|
|
422
|
+
const modes: Array<{ value: NavigatorMode; label: string }> = [
|
|
423
|
+
{ value: "review", label: "inbox" },
|
|
424
|
+
{ value: "answers", label: "answers" },
|
|
425
|
+
{ value: "log", label: "log" },
|
|
426
|
+
];
|
|
427
|
+
return modes.map((mode) => mode.value === active ? activePill(theme, mode.label) : inactivePill(theme, mode.label)).join(" ");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function artifactMeta(artifact: Artifact): Record<string, unknown> {
|
|
431
|
+
return artifact.meta ?? {};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function artifactHasDiff(artifact: Artifact): boolean {
|
|
435
|
+
const diff = artifactMeta(artifact).diff;
|
|
436
|
+
return typeof diff === "string" && diff.length > 0;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function bucketName(bucket: ReviewBucket | undefined, mode: NavigatorMode): string {
|
|
440
|
+
if (bucket === "needs") return "next";
|
|
441
|
+
if (bucket === "pinned") return "pinned";
|
|
442
|
+
if (bucket === "recent") return "recent";
|
|
443
|
+
return mode === "answers" ? "answer" : "item";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function bucketGlyph(bucket: ReviewBucket | undefined, mode: NavigatorMode): string {
|
|
447
|
+
if (bucket === "needs") return "◆";
|
|
448
|
+
if (bucket === "pinned") return "●";
|
|
449
|
+
if (bucket === "recent") return "✓";
|
|
450
|
+
return mode === "answers" ? "✦" : "·";
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function colorBucket(theme: any, bucket: ReviewBucket | undefined, mode: NavigatorMode, text: string): string {
|
|
454
|
+
if (bucket === "needs") return theme.fg("warning", text);
|
|
455
|
+
if (bucket === "pinned") return theme.fg("accent", text);
|
|
456
|
+
if (bucket === "recent") return theme.fg("success", text);
|
|
457
|
+
return mode === "answers" ? theme.fg("accent", text) : theme.fg("muted", text);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function reviewReasonLabel(reasonId: ReviewReasonId | undefined): string | undefined {
|
|
461
|
+
if (reasonId === "pinned") return "pinned";
|
|
462
|
+
if (reasonId === "done") return "recently reviewed";
|
|
463
|
+
if (reasonId === "workerNeedsInput") return "worker waiting";
|
|
464
|
+
if (reasonId === "workerFailed") return "worker failed";
|
|
465
|
+
if (reasonId === "workerReady") return "worker ready";
|
|
466
|
+
if (reasonId === "workerChangeSet") return "worker changes";
|
|
467
|
+
if (reasonId === "error") return "needs attention";
|
|
468
|
+
if (reasonId === "changedFile") return "changed file";
|
|
469
|
+
if (reasonId === "createdFile") return "created file";
|
|
470
|
+
if (reasonId === "failedCommand") return "failed command";
|
|
471
|
+
if (reasonId === "workerAnswer") return "worker answer";
|
|
472
|
+
if (reasonId === "workerOutput") return "worker output";
|
|
473
|
+
if (reasonId === "assistantAnswer") return "assistant answer";
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function reviewActionLabel(action: ReviewActionId, item: ReviewItem): string {
|
|
478
|
+
const artifact = item.artifact;
|
|
479
|
+
if (action === "openVerdict") return "Verdict";
|
|
480
|
+
if (action === "tellWorker") return "Tell worker";
|
|
481
|
+
if (action === "promoteWorker") return "Promote";
|
|
482
|
+
if (action === "openFile") return "Open file";
|
|
483
|
+
if (action === "attachReference") return "Attach";
|
|
484
|
+
if (action === "injectFull") return "Full";
|
|
485
|
+
if (action === "copyArtifact") return "Copy";
|
|
486
|
+
if (action === "pin") return "Pin";
|
|
487
|
+
if (action === "markDone") return "Done";
|
|
488
|
+
if (item.reasonId === "workerFailed" || item.reasonId === "error" || item.reasonId === "failedCommand") return "Inspect failure";
|
|
489
|
+
if (item.reasonId === "workerChangeSet") return "Review diff";
|
|
490
|
+
if (item.reasonId === "workerReady") return "View answer";
|
|
491
|
+
if (artifact.kind === "file" && artifactHasDiff(artifact)) return "Review diff";
|
|
492
|
+
if (artifact.kind === "command") return "Inspect output";
|
|
493
|
+
if (artifact.kind === "response") return "View answer";
|
|
494
|
+
if (artifact.kind === "code") return "View code";
|
|
495
|
+
if (artifact.kind === "checkpoint") return "Open checkpoint";
|
|
496
|
+
return "Open";
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function selectedActionHints(item: ReviewItem, pinned: boolean, done: boolean): string[] {
|
|
500
|
+
const artifact = item.artifact;
|
|
501
|
+
const hints = [`enter ${reviewActionLabel(item.primaryAction, item).toLowerCase()}`];
|
|
502
|
+
if (item.primaryAction !== "openVerdict" && item.actions.includes("openVerdict")) hints.push("Enter verdict");
|
|
503
|
+
if (item.actions.includes("promoteWorker")) hints.push("P promote");
|
|
504
|
+
if (item.actions.includes("tellWorker")) hints.push("t tell");
|
|
505
|
+
if (item.actions.includes("openFile")) hints.push("o open");
|
|
506
|
+
hints.push("a attach", "I full", "y copy", pinned ? "p unpin" : "p pin", done ? "x restore" : "x done", "v preview");
|
|
507
|
+
return artifact ? hints : [];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function bucketCounts(items: ReviewItem[]): Record<ReviewBucket, number> {
|
|
511
|
+
const counts: Record<ReviewBucket, number> = { needs: 0, pinned: 0, recent: 0 };
|
|
512
|
+
for (const item of items) {
|
|
513
|
+
if (item.bucket) counts[item.bucket]++;
|
|
514
|
+
}
|
|
515
|
+
return counts;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function categoryCounts(items: ReviewItem[]): Map<ReviewCategory, number> {
|
|
519
|
+
const counts = new Map<ReviewCategory, number>();
|
|
520
|
+
for (const item of items) {
|
|
521
|
+
if (item.category) counts.set(item.category, (counts.get(item.category) ?? 0) + 1);
|
|
522
|
+
}
|
|
523
|
+
return counts;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function categoryColor(theme: any, category: ReviewCategory | undefined, text: string): string {
|
|
527
|
+
if (category === "needs-decision") return theme.fg("warning", text);
|
|
528
|
+
if (category === "failed-blocked") return theme.fg("error", text);
|
|
529
|
+
if (category === "ready-for-review") return theme.fg("success", text);
|
|
530
|
+
if (category === "patch-proposed") return theme.fg("warning", text);
|
|
531
|
+
if (category === "checkpoint-available") return theme.fg("accent", text);
|
|
532
|
+
if (category === "pinned") return theme.fg("accent", text);
|
|
533
|
+
if (category === "recent") return theme.fg("success", text);
|
|
534
|
+
return theme.fg("muted", text);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function chipColor(theme: any, chip: string | undefined, text: string): string {
|
|
538
|
+
if (!chip) return theme.fg("muted", text);
|
|
539
|
+
if (chip === "needs reply") return theme.fg("warning", text);
|
|
540
|
+
if (chip === "failed" || chip === "error") return theme.fg("error", text);
|
|
541
|
+
if (chip === "ready" || chip === "ready · open todos") return theme.fg("success", text);
|
|
542
|
+
if (chip === "answer" || chip === "code") return theme.fg("accent", text);
|
|
543
|
+
if (chip === "changed" || chip === "new file") return theme.fg("toolDiffAdded", text);
|
|
544
|
+
if (chip === "stale") return theme.fg("dim", text);
|
|
545
|
+
return theme.fg("muted", text);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
type InboxButton = { key: string; label: string };
|
|
549
|
+
|
|
550
|
+
function workerChangeSetLines(artifact: Artifact): string[] {
|
|
551
|
+
if (artifact.meta?.workerChangeSet !== true || !Array.isArray(artifact.meta.changedFiles)) return [];
|
|
552
|
+
return artifact.meta.changedFiles.slice(0, 5).map((entry) => {
|
|
553
|
+
if (!entry || typeof entry !== "object") return undefined;
|
|
554
|
+
const file = entry as { path?: unknown; additions?: unknown; deletions?: unknown };
|
|
555
|
+
if (typeof file.path !== "string") return undefined;
|
|
556
|
+
const adds = typeof file.additions === "number" ? file.additions : 0;
|
|
557
|
+
const dels = typeof file.deletions === "number" ? file.deletions : 0;
|
|
558
|
+
return `${file.path} +${adds}/-${dels}`;
|
|
559
|
+
}).filter((line): line is string => line !== undefined);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function inboxButtons(item: ReviewItem, done: boolean): InboxButton[] {
|
|
563
|
+
const primaryLabel = reviewActionLabel(item.primaryAction, item);
|
|
564
|
+
const buttons: InboxButton[] = [{ key: "Enter", label: primaryLabel }];
|
|
565
|
+
const seen = new Set<ReviewActionId>([item.primaryAction]);
|
|
566
|
+
const order: Array<{ id: ReviewActionId; key: string; label: string }> = [
|
|
567
|
+
{ id: "openVerdict", key: "Enter", label: "Verdict" },
|
|
568
|
+
{ id: "promoteWorker", key: "P", label: "Promote" },
|
|
569
|
+
{ id: "inspect", key: "d", label: "Diff" },
|
|
570
|
+
{ id: "tellWorker", key: "c", label: "Continue" },
|
|
571
|
+
{ id: "attachReference", key: "a", label: "Attach" },
|
|
572
|
+
{ id: "copyArtifact", key: "y", label: "Copy" },
|
|
573
|
+
{ id: "markDone", key: "Space", label: done ? "Restore" : "Done" },
|
|
574
|
+
];
|
|
575
|
+
for (const entry of order) {
|
|
576
|
+
if (seen.has(entry.id)) continue;
|
|
577
|
+
if (!item.actions.includes(entry.id)) continue;
|
|
578
|
+
buttons.push({ key: entry.key, label: entry.label });
|
|
579
|
+
seen.add(entry.id);
|
|
580
|
+
}
|
|
581
|
+
return buttons;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function navigatorModeLabel(mode: NavigatorMode): string {
|
|
585
|
+
if (mode === "review") return "inbox";
|
|
586
|
+
if (mode === "answers") return "answers";
|
|
587
|
+
return "log";
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function plural(count: number, singular: string, pluralLabel = `${singular}s`): string {
|
|
591
|
+
return `${count} ${count === 1 ? singular : pluralLabel}`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function docketStatusLine(mode: NavigatorMode, items: ReviewItem[], artifacts: Artifact[]): string {
|
|
595
|
+
if (artifacts.length === 0) return "quiet until something needs attention";
|
|
596
|
+
if (mode === "answers") return plural(items.length, "answer");
|
|
597
|
+
if (mode === "log") return plural(items.length, "artifact");
|
|
598
|
+
const counts = bucketCounts(items);
|
|
599
|
+
const parts: string[] = [];
|
|
600
|
+
if (counts.needs > 0) parts.push(`${counts.needs} needs attention`);
|
|
601
|
+
if (counts.pinned > 0) parts.push(plural(counts.pinned, "pinned", "pinned"));
|
|
602
|
+
if (parts.length > 0) return parts.join(" · ");
|
|
603
|
+
if (counts.recent > 0) return `✓ all clear · ${plural(counts.recent, "recent item")}`;
|
|
604
|
+
return "✓ all clear";
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
type EmptyDocketMessage = {
|
|
608
|
+
title: string;
|
|
609
|
+
body: string;
|
|
610
|
+
actions: string[];
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
function emptyDocketMessage(state: NavigatorState, hasArtifacts: boolean): EmptyDocketMessage {
|
|
614
|
+
if (!hasArtifacts) {
|
|
615
|
+
return {
|
|
616
|
+
title: "No session activity yet",
|
|
617
|
+
body: "Docket fills as you work: commands, file changes, errors, answers, and checkpoints become browsable here.",
|
|
618
|
+
actions: ["ask agent to inspect a file", "run a command", "load a checkpoint or worker"],
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
if (state.mode === "review") {
|
|
622
|
+
return {
|
|
623
|
+
title: "All clear",
|
|
624
|
+
body: "Docket will surface changed files, failures, pinned items, and worker output when they need attention.",
|
|
625
|
+
actions: ["press tab for answers", "press / to search", "pin useful items with p"],
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
if (state.mode === "answers") {
|
|
629
|
+
return {
|
|
630
|
+
title: "No answers yet",
|
|
631
|
+
body: "Answers stay quiet until assistant or worker conclusions exist for this source/filter.",
|
|
632
|
+
actions: ["press tab for log", "press / to search", "cycle filters with f"],
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const filter = state.filter === "all" ? "" : `${kindLabel(state.filter)} `;
|
|
636
|
+
return {
|
|
637
|
+
title: `No ${filter}artifacts here`,
|
|
638
|
+
body: "This view is filtered. Your activity may still exist in another source, kind, or mode.",
|
|
639
|
+
actions: ["press f to change filter", "press s to switch source", "press 1 for inbox"],
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
class DocketView implements Component {
|
|
644
|
+
private container: Container | Box = new Container();
|
|
645
|
+
private state: NavigatorState;
|
|
646
|
+
private cachedWidth?: number;
|
|
647
|
+
private cachedLines?: string[];
|
|
648
|
+
private showHelp = false;
|
|
649
|
+
|
|
650
|
+
constructor(
|
|
651
|
+
private tui: TUI,
|
|
652
|
+
private theme: any,
|
|
653
|
+
private artifacts: Artifact[],
|
|
654
|
+
private pinnedRefs: Set<string>,
|
|
655
|
+
private completedRefs: Set<string>,
|
|
656
|
+
initialMode: NavigatorMode,
|
|
657
|
+
private fullText: (artifact: Artifact) => string,
|
|
658
|
+
private done: (result: DocketBrowserAction | null) => void,
|
|
659
|
+
) {
|
|
660
|
+
const sources = availableSources(artifacts);
|
|
661
|
+
const source = sources.find((candidate) => candidate.kind === "all") ?? sources.find((candidate) => candidate.kind === "current") ?? sources[0] ?? initialNavigatorState().source;
|
|
662
|
+
this.state = { ...initialNavigatorState(), source, mode: initialMode };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private queueState(): ReviewQueueState {
|
|
666
|
+
return { pinnedRefs: this.pinnedRefs, doneRefs: this.completedRefs };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
handleInput(data: string): void {
|
|
670
|
+
if (data === "?") {
|
|
671
|
+
this.showHelp = !this.showHelp;
|
|
672
|
+
this.invalidate();
|
|
673
|
+
this.tui.requestRender();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const intent = this.intentForInput(data);
|
|
677
|
+
if (!intent) return;
|
|
678
|
+
const transition = handleNavigatorIntent(this.state, this.artifacts, this.queueState(), intent);
|
|
679
|
+
this.state = transition.state;
|
|
680
|
+
if (transition.action) this.finish(transition.action);
|
|
681
|
+
this.invalidate();
|
|
682
|
+
this.tui.requestRender();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private intentForInput(data: string): NavigatorIntent | undefined {
|
|
686
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") return { kind: "close" };
|
|
687
|
+
if (data === "j" || matchesKey(data, Key.down)) return { kind: "move", by: 1 };
|
|
688
|
+
if (data === "k" || matchesKey(data, Key.up)) return { kind: "move", by: -1 };
|
|
689
|
+
if (data === "g") return { kind: "top" };
|
|
690
|
+
if (data === "G") return { kind: "bottom" };
|
|
691
|
+
if (data === "/") return { kind: "search" };
|
|
692
|
+
if (data === "1") return { kind: "setMode", mode: "review" };
|
|
693
|
+
if (data === "2") return { kind: "setMode", mode: "answers" };
|
|
694
|
+
if (data === "3") return { kind: "setMode", mode: "log" };
|
|
695
|
+
if (data === "\t" || matchesKey(data, Key.tab)) return { kind: "cycleMode" };
|
|
696
|
+
if (data === "s") return { kind: "cycleSource" };
|
|
697
|
+
if (matchesKey(data, Key.enter)) return { kind: "activatePrimary" };
|
|
698
|
+
if (data === " " || data === "x") return { kind: "runAction", action: "markDone" };
|
|
699
|
+
if (data === "c") {
|
|
700
|
+
const sel = navigatorViewModel(this.state, this.artifacts, this.queueState()).selectedItem;
|
|
701
|
+
if (sel && sel.actions.includes("tellWorker")) return { kind: "runAction", action: "tellWorker" };
|
|
702
|
+
return { kind: "createCheckpoint" };
|
|
703
|
+
}
|
|
704
|
+
if (data === "P") return { kind: "runAction", action: "promoteWorker" };
|
|
705
|
+
if (data === "d") return { kind: "runAction", action: "inspect" };
|
|
706
|
+
if (data === "a") return { kind: "runAction", action: "attachReference" };
|
|
707
|
+
if (data === "y") return { kind: "runAction", action: "copyArtifact" };
|
|
708
|
+
// Advanced (in help): pin, preview, full inject, open file, filter, legacy aliases
|
|
709
|
+
if (data === "v") return { kind: "toggleDetail" };
|
|
710
|
+
if (data === "p") return { kind: "runAction", action: "pin" };
|
|
711
|
+
if (data === "I") return { kind: "runAction", action: "injectFull" };
|
|
712
|
+
if (data === "o") return { kind: "runAction", action: "openFile" };
|
|
713
|
+
if (data === "f") return { kind: "cycleFilter" };
|
|
714
|
+
if (data === "t") return { kind: "runAction", action: "tellWorker" };
|
|
715
|
+
if (data === "r" || data === "i") return { kind: "runAction", action: "attachReference" };
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private finish(action: NavigatorAction): void {
|
|
720
|
+
if (action.action === "close") {
|
|
721
|
+
this.done(null);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (action.action === "search") {
|
|
725
|
+
this.done({ action: "search" });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (action.action === "createCheckpoint") {
|
|
729
|
+
this.done({ action: "save" });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const artifact = action.item.artifact;
|
|
733
|
+
if (action.id === "pin") {
|
|
734
|
+
if (this.pinnedRefs.has(artifact.ref)) this.pinnedRefs.delete(artifact.ref);
|
|
735
|
+
else this.pinnedRefs.add(artifact.ref);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (action.id === "markDone") {
|
|
739
|
+
if (this.completedRefs.has(artifact.ref)) this.completedRefs.delete(artifact.ref);
|
|
740
|
+
else {
|
|
741
|
+
this.pinnedRefs.delete(artifact.ref);
|
|
742
|
+
this.completedRefs.add(artifact.ref);
|
|
743
|
+
}
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (action.id === "inspect") this.done({ action: "inspect", artifact });
|
|
747
|
+
else if (action.id === "openVerdict") this.done({ action: "verdict", artifact });
|
|
748
|
+
else if (action.id === "openFile") this.done({ action: "openFile", artifact });
|
|
749
|
+
else if (action.id === "promoteWorker") this.done({ action: "promoteWorker", artifact });
|
|
750
|
+
else if (action.id === "tellWorker") this.done({ action: "tellWorker", artifact });
|
|
751
|
+
else if (action.id === "attachReference") this.done({ action: "reference", artifact });
|
|
752
|
+
else if (action.id === "injectFull") this.done({ action: "injectFull", artifact });
|
|
753
|
+
else if (action.id === "copyArtifact") this.done({ action: "copy", artifact });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
invalidate(): void {
|
|
757
|
+
this.container.invalidate();
|
|
758
|
+
this.cachedWidth = undefined;
|
|
759
|
+
this.cachedLines = undefined;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private renderInboxCard(item: ReviewItem, width: number, accent: (s: string) => string, dim: (s: string) => string, muted: (s: string) => string): void {
|
|
763
|
+
const artifact = item.artifact;
|
|
764
|
+
const chip = item.statusChip ? ` ${chipColor(this.theme, item.statusChip, `[${item.statusChip}]`)}` : "";
|
|
765
|
+
const headline = `${this.theme.fg("text", this.theme.bold(item.headline))}${chip}`;
|
|
766
|
+
this.container.addChild(new Text(truncateToWidth(headline, width), 1, 0));
|
|
767
|
+
const changeLines = workerChangeSetLines(artifact);
|
|
768
|
+
if (changeLines.length > 0) {
|
|
769
|
+
for (const line of changeLines) this.container.addChild(new Text(truncateToWidth(` ${dim(line)}`, width), 1, 0));
|
|
770
|
+
} else {
|
|
771
|
+
const bullets = item.recommendations.slice(0, 3);
|
|
772
|
+
for (const bullet of bullets) {
|
|
773
|
+
for (const wrapped of wrapPlainText(`• ${bullet}`, width - 2, 2)) {
|
|
774
|
+
this.container.addChild(new Text(truncateToWidth(` ${dim(wrapped)}`, width), 1, 0));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const done = this.completedRefs.has(artifact.ref);
|
|
779
|
+
const buttons = inboxButtons(item, done);
|
|
780
|
+
const buttonLine = buttons.map((button, index) => index === 0 ? accent(`[${button.key} ${button.label}]`) : muted(`[${button.key} ${button.label}]`)).join(" ");
|
|
781
|
+
this.container.addChild(new Text(truncateToWidth(buttonLine, width), 1, 0));
|
|
782
|
+
const time = relativeTime(artifact.timestamp);
|
|
783
|
+
const footer = [item.provenance, time, `@${artifact.id}`].filter(Boolean).join(" · ");
|
|
784
|
+
this.container.addChild(new Text(truncateToWidth(dim(footer), width), 1, 0));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
render(width: number): string[] {
|
|
788
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
789
|
+
const artifacts = this.artifacts;
|
|
790
|
+
this.container = new Box(2, 1, docketCardBg(this.theme));
|
|
791
|
+
const innerWidth = Math.max(20, width - 4);
|
|
792
|
+
const view = navigatorViewModel(this.state, artifacts, this.queueState(), this.state.showDetail ? 7 : 12);
|
|
793
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
794
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
795
|
+
const muted = (s: string) => this.theme.fg("muted", s);
|
|
796
|
+
const outerBorder = (s: string) => this.theme.fg("border", s);
|
|
797
|
+
const dividerBorder = (s: string) => this.theme.fg("borderMuted", s);
|
|
798
|
+
|
|
799
|
+
const sel = view.selectedItem;
|
|
800
|
+
const sources = availableSources(artifacts);
|
|
801
|
+
const sourceLabel = this.state.source;
|
|
802
|
+
const counts = bucketCounts(view.items);
|
|
803
|
+
const headerLeft = ` ${accent(this.theme.bold("docket"))} ${dim("·")} ${accent(navigatorModeLabel(this.state.mode))} `;
|
|
804
|
+
const headerRight = ` ${dim("Esc close")} `;
|
|
805
|
+
this.container.addChild(new Text(fitBorder(headerLeft, headerRight, innerWidth, outerBorder, TOP_CORNERS), 0, 0));
|
|
806
|
+
const position = view.items.length ? `${view.selected + 1}/${view.items.length}` : "";
|
|
807
|
+
const status = [docketStatusLine(this.state.mode, view.items, artifacts), position].filter(Boolean).join(" · ");
|
|
808
|
+
this.container.addChild(new Text(truncateToWidth(` ${muted(status)}`, innerWidth - 2), 1, 0));
|
|
809
|
+
if (this.state.filter !== "all") this.container.addChild(new Text(`${muted("filter")} ${filterBar(this.theme, this.state.filter)}`, 1, 0));
|
|
810
|
+
const sourceLine = sourceBar(this.theme, sources, sourceLabel);
|
|
811
|
+
if (sources.length > 1 && sourceLine) this.container.addChild(new Text(sourceLine, 1, 0));
|
|
812
|
+
this.container.addChild(new DynamicBorder(dividerBorder));
|
|
813
|
+
|
|
814
|
+
const listWidth = Math.max(30, innerWidth);
|
|
815
|
+
if (view.visible.length === 0) {
|
|
816
|
+
const empty = emptyDocketMessage(this.state, artifacts.length > 0);
|
|
817
|
+
const emptyWidth = Math.max(20, listWidth - 2);
|
|
818
|
+
this.container.addChild(new Spacer(1));
|
|
819
|
+
this.container.addChild(new Text(truncateToWidth(` ${accent(this.theme.bold(empty.title))}`, emptyWidth), 1, 0));
|
|
820
|
+
this.container.addChild(new Text(truncateToWidth(` ${muted(empty.body)}`, emptyWidth), 1, 0));
|
|
821
|
+
this.container.addChild(new Text(truncateToWidth(` ${dim(`Try: ${empty.actions.join(" · ")}`)}`, emptyWidth), 1, 0));
|
|
822
|
+
this.container.addChild(new Spacer(1));
|
|
823
|
+
} else if (this.state.mode === "review") {
|
|
824
|
+
const catCounts = categoryCounts(view.items);
|
|
825
|
+
for (let i = 0; i < view.visible.length; i++) {
|
|
826
|
+
const item = view.visible[i];
|
|
827
|
+
if (!item) continue;
|
|
828
|
+
const absolute = view.visibleStart + i;
|
|
829
|
+
const selected = absolute === view.selected;
|
|
830
|
+
const previousCategory = absolute > 0 ? view.items[absolute - 1]?.category : undefined;
|
|
831
|
+
if (item.category && item.category !== previousCategory) {
|
|
832
|
+
const count = catCounts.get(item.category) ?? 0;
|
|
833
|
+
const label = `${reviewCategoryLabel(item.category)} · ${count}`;
|
|
834
|
+
this.container.addChild(new Text(` ${categoryColor(this.theme, item.category, this.theme.bold(label))}`, 1, 0));
|
|
835
|
+
}
|
|
836
|
+
const marker = selected ? accent("▸") : " ";
|
|
837
|
+
const chip = item.statusChip ? ` ${chipColor(this.theme, item.statusChip, `[${item.statusChip}]`)}` : "";
|
|
838
|
+
const headline = selected ? this.theme.bold(this.theme.fg("text", item.headline)) : this.theme.fg("text", item.headline);
|
|
839
|
+
const line = `${marker} ${headline}${chip}`;
|
|
840
|
+
const row = padAnsi(truncateToWidth(line, listWidth - 2), listWidth - 2);
|
|
841
|
+
this.container.addChild(new Text(row, 1, 0));
|
|
842
|
+
}
|
|
843
|
+
} else if (this.state.mode === "log") {
|
|
844
|
+
const episodes = episodesFromItems(view.items);
|
|
845
|
+
const episodeIndex = new Map<string, EpisodeSummary>();
|
|
846
|
+
for (const ep of episodes) episodeIndex.set(ep.id, ep);
|
|
847
|
+
for (let i = 0; i < view.visible.length; i++) {
|
|
848
|
+
const item = view.visible[i];
|
|
849
|
+
if (!item) continue;
|
|
850
|
+
const artifact = item.artifact;
|
|
851
|
+
const absolute = view.visibleStart + i;
|
|
852
|
+
const selected = absolute === view.selected;
|
|
853
|
+
const episodeId = artifact.source ?? "current";
|
|
854
|
+
const previousEpisodeId = absolute > 0 ? (view.items[absolute - 1]?.artifact.source ?? "current") : undefined;
|
|
855
|
+
if (episodeId !== previousEpisodeId) {
|
|
856
|
+
const ep = episodeIndex.get(episodeId);
|
|
857
|
+
if (ep) {
|
|
858
|
+
const task = ep.taskLabel ? ` · ${ep.taskLabel}` : "";
|
|
859
|
+
const head = ` ${accent(this.theme.bold(ep.label))}${dim(`${task} · ${ep.artifactCount} items`)}`;
|
|
860
|
+
this.container.addChild(new Text(truncateToWidth(head, listWidth - 2), 1, 0));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const marker = selected ? accent("▸") : " ";
|
|
864
|
+
const glyphText = bucketGlyph(item.bucket, this.state.mode);
|
|
865
|
+
const time = relativeTime(artifact.timestamp);
|
|
866
|
+
const meta = [kindLabel(artifact.kind), time, `@${artifact.id}`].filter(Boolean).join(" · ");
|
|
867
|
+
const indent = " ";
|
|
868
|
+
const glyph = colorKind(this.theme, artifact.kind, glyphText);
|
|
869
|
+
const title = selected ? this.theme.bold(this.theme.fg("text", artifact.title)) : muted(artifact.title);
|
|
870
|
+
const line = `${marker}${indent}${glyph} ${title} ${dim(meta)}`;
|
|
871
|
+
const row = padAnsi(truncateToWidth(line, listWidth - 2), listWidth - 2);
|
|
872
|
+
this.container.addChild(new Text(row, 1, 0));
|
|
873
|
+
}
|
|
874
|
+
} else {
|
|
875
|
+
for (let i = 0; i < view.visible.length; i++) {
|
|
876
|
+
const item = view.visible[i];
|
|
877
|
+
if (!item) continue;
|
|
878
|
+
const artifact = item.artifact;
|
|
879
|
+
const absolute = view.visibleStart + i;
|
|
880
|
+
const selected = absolute === view.selected;
|
|
881
|
+
const bucket = item.bucket;
|
|
882
|
+
const marker = selected ? accent("▸") : " ";
|
|
883
|
+
const glyphText = bucketGlyph(bucket, this.state.mode);
|
|
884
|
+
const provenance = artifact.source ? `from ${artifact.source}` : "current";
|
|
885
|
+
const meta = [kindLabel(artifact.kind), provenance, relativeTime(artifact.timestamp), `@${artifact.id}`].filter(Boolean).join(" · ");
|
|
886
|
+
const glyph = colorBucket(this.theme, bucket, this.state.mode, glyphText);
|
|
887
|
+
const title = selected ? this.theme.bold(this.theme.fg("text", artifact.title)) : muted(artifact.title);
|
|
888
|
+
const line = `${marker} ${glyph} ${title} ${dim(meta)}`;
|
|
889
|
+
const row = padAnsi(truncateToWidth(line, listWidth - 2), listWidth - 2);
|
|
890
|
+
this.container.addChild(new Text(row, 1, 0));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (sel) {
|
|
895
|
+
const artifact = sel.artifact;
|
|
896
|
+
this.container.addChild(new DynamicBorder(dividerBorder));
|
|
897
|
+
if (this.state.mode === "review") {
|
|
898
|
+
this.renderInboxCard(sel, listWidth - 2, accent, dim, muted);
|
|
899
|
+
} else {
|
|
900
|
+
const primary = reviewActionLabel(sel.primaryAction, sel);
|
|
901
|
+
const focusMeta = [kindLabel(artifact.kind), reviewReasonLabel(sel.reasonId), artifact.source ? `from ${artifact.source}` : "current", relativeTime(artifact.timestamp), `@${artifact.id}`].filter(Boolean).join(" · ");
|
|
902
|
+
this.container.addChild(new Text(truncateToWidth(`${accent(primary)} ${dim("·")} ${muted(artifact.title)}`, listWidth - 2), 1, 0));
|
|
903
|
+
if (focusMeta) this.container.addChild(new Text(truncateToWidth(dim(focusMeta), listWidth - 2), 1, 0));
|
|
904
|
+
const hints = selectedActionHints(sel, this.pinnedRefs.has(artifact.ref), this.completedRefs.has(artifact.ref));
|
|
905
|
+
this.container.addChild(new Text(truncateToWidth(hints.map((hint, index) => index === 0 ? accent(`[${hint}]`) : dim(hint)).join(" · "), listWidth - 2), 1, 0));
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (this.state.showDetail && view.selectedItem) {
|
|
910
|
+
const artifact = view.selectedItem.artifact;
|
|
911
|
+
this.container.addChild(new DynamicBorder(dividerBorder));
|
|
912
|
+
this.container.addChild(new Text(`${accent("preview")} ${muted(artifact.ref)}`, 1, 0));
|
|
913
|
+
const detail = this.fullText(artifact).split("\n").slice(0, 14);
|
|
914
|
+
for (const line of detail) this.container.addChild(new Text(truncateToWidth(dim(line), listWidth - 2), 1, 0));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
this.container.addChild(new DynamicBorder(dividerBorder));
|
|
918
|
+
this.container.addChild(new Text(dim(`↑↓ move · / search · ? more · Esc close`), 1, 0));
|
|
919
|
+
if (this.showHelp) {
|
|
920
|
+
this.container.addChild(new Text(`${muted("Card")} ${dim("Enter primary · c reply/save · Space done · a attach · y copy · d diff · P promote · I inject full · o open file")}`, 1, 0));
|
|
921
|
+
this.container.addChild(new Text(`${muted("Modes")} ${modeBar(this.theme, this.state.mode)} ${dim("· 1 inbox · 2 answers · 3 log · tab cycle")}`, 1, 0));
|
|
922
|
+
this.container.addChild(new Text(`${muted("Source")} ${dim("s switch source · pills above show available scopes")}`, 1, 0));
|
|
923
|
+
this.container.addChild(new Text(`${muted("Filters")} ${dim("f cycle artifact kind")}`, 1, 0));
|
|
924
|
+
this.container.addChild(new Text(`${muted("Advanced")} ${dim("p pin · v preview · t tell · x done")}`, 1, 0));
|
|
925
|
+
}
|
|
926
|
+
this.container.addChild(new Text(fitBorder("", "", innerWidth, outerBorder, BOTTOM_CORNERS), 0, 0));
|
|
927
|
+
this.cachedLines = this.container.render(width);
|
|
928
|
+
this.cachedWidth = width;
|
|
929
|
+
return this.cachedLines;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function showDocketBrowser(
|
|
934
|
+
ctx: ExtensionCommandContext,
|
|
935
|
+
catalog: ArtifactCatalog,
|
|
936
|
+
artifacts: Artifact[],
|
|
937
|
+
pinnedRefs: Set<string>,
|
|
938
|
+
completedRefs: Set<string>,
|
|
939
|
+
initialMode: NavigatorMode = "review",
|
|
940
|
+
): Promise<DocketBrowserAction | null> {
|
|
941
|
+
return ctx.ui.custom((tui, theme, _kb, done) => new DocketView(tui, theme, artifacts, pinnedRefs, completedRefs, initialMode, (artifact) => catalog.fullText(artifact), done), {
|
|
942
|
+
overlay: true,
|
|
943
|
+
overlayOptions: { anchor: "center", width: "88%", minWidth: 84, maxHeight: "90%", margin: 1 },
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
type VerdictVerbId = "accept" | "reject" | "rejectStop" | "chat" | "send";
|
|
948
|
+
export type VerdictVerb = { id: VerdictVerbId; label: string; description: string; send?: string };
|
|
949
|
+
|
|
950
|
+
type VerdictPayload = { lines: string[]; additions: number; deletions: number; hunkCount?: number; hasChangeSet: boolean; intent?: string; risk?: string };
|
|
951
|
+
|
|
952
|
+
function artifactChangedFiles(artifact: Artifact | undefined): Array<{ path?: unknown; additions?: unknown; deletions?: unknown }> {
|
|
953
|
+
return Array.isArray(artifact?.meta?.changedFiles) ? artifact.meta.changedFiles as Array<{ path?: unknown; additions?: unknown; deletions?: unknown }> : [];
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function artifactHunkCount(artifact: Artifact | undefined): number | undefined {
|
|
957
|
+
const hunkCount = artifact?.meta?.hunkCount;
|
|
958
|
+
return typeof hunkCount === "number" && Number.isFinite(hunkCount) ? hunkCount : undefined;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export function diffBar(additions: number, deletions: number, width: number): string {
|
|
962
|
+
const slots = Math.max(1, Math.floor(width));
|
|
963
|
+
const adds = Math.max(0, additions);
|
|
964
|
+
const dels = Math.max(0, deletions);
|
|
965
|
+
const total = adds + dels;
|
|
966
|
+
if (total <= 0) return "░".repeat(slots);
|
|
967
|
+
if (slots === 1) return adds >= dels ? "█" : "░";
|
|
968
|
+
let addSlots = Math.round((adds / total) * slots);
|
|
969
|
+
if (adds > 0 && addSlots === 0) addSlots = 1;
|
|
970
|
+
if (dels > 0 && addSlots === slots) addSlots = slots - 1;
|
|
971
|
+
return `${"█".repeat(addSlots)}${"░".repeat(slots - addSlots)}`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function coloredDiffBar(theme: any, additions: number, deletions: number, width: number): string {
|
|
975
|
+
const bar = diffBar(additions, deletions, width);
|
|
976
|
+
return `[${[...bar].map((char) => char === "█" ? theme.fg("success", char) : theme.fg("error", char)).join("")}]`;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export function verdictVerbs(state: WorkerDerivedState, hasChangeSet: boolean, options: string[] = []): VerdictVerb[] {
|
|
980
|
+
if (state === "needs_input") {
|
|
981
|
+
if (options.length > 0) return [
|
|
982
|
+
...options.map((option): VerdictVerb => ({ id: "send", label: option, description: "send to worker", send: option })),
|
|
983
|
+
{ id: "reject", label: "Steer", description: "something else · stays alive" },
|
|
984
|
+
{ id: "rejectStop", label: "Reject & stop", description: "kill worker + remove workspace" },
|
|
985
|
+
{ id: "chat", label: "Chat", description: "type a reply" },
|
|
986
|
+
];
|
|
987
|
+
return [
|
|
988
|
+
{ id: "accept", label: "Accept", description: "approve · worker continues" },
|
|
989
|
+
{ id: "reject", label: "Reject", description: "redirect · stays alive" },
|
|
990
|
+
{ id: "rejectStop", label: "Reject & stop", description: "kill worker + remove workspace" },
|
|
991
|
+
{ id: "chat", label: "Chat", description: "type a reply" },
|
|
992
|
+
];
|
|
993
|
+
}
|
|
994
|
+
if (state === "failed") return [
|
|
995
|
+
{ id: "accept", label: "Retry", description: "relaunch worker" },
|
|
996
|
+
{ id: "reject", label: "Dismiss", description: "drop from inbox" },
|
|
997
|
+
{ id: "rejectStop", label: "Reject & stop", description: "kill worker + remove workspace" },
|
|
998
|
+
{ id: "chat", label: "Chat", description: "send follow-up" },
|
|
999
|
+
];
|
|
1000
|
+
return [
|
|
1001
|
+
{ id: "accept", label: hasChangeSet ? "Promote" : "Acknowledge", description: hasChangeSet ? "apply diff into your worktree" : "mark reviewed" },
|
|
1002
|
+
{ id: "reject", label: hasChangeSet ? "Discard" : "Dismiss", description: hasChangeSet ? "drop changes · keep worktree" : "drop from inbox" },
|
|
1003
|
+
{ id: "rejectStop", label: "Reject & stop", description: "kill worker + remove workspace" },
|
|
1004
|
+
{ id: "chat", label: "Chat", description: hasChangeSet ? "send back for revision" : "send follow-up" },
|
|
1005
|
+
];
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function workerIntentLine(worker: WorkerStatus): string | undefined {
|
|
1009
|
+
const summary = typeof worker.summary === "string" ? worker.summary : "";
|
|
1010
|
+
const line = summary.split(/\r?\n/).map((part) => part.trim()).find((part) => part.length > 0);
|
|
1011
|
+
return line && line.length > 0 ? line : undefined;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function primaryWorkerQuestion(worker: WorkerStatus) {
|
|
1015
|
+
const questions = workerQuestions(worker);
|
|
1016
|
+
return questions.length ? questions[questions.length - 1] : undefined;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export function workerVerdictPayload(worker: WorkerStatus, changeSet?: Artifact): VerdictPayload {
|
|
1020
|
+
const state = deriveWorkerState(worker);
|
|
1021
|
+
if (state === "needs_input") {
|
|
1022
|
+
const lines = workerQuestions(worker).map((question) => question.text);
|
|
1023
|
+
const risk = primaryWorkerQuestion(worker)?.risk;
|
|
1024
|
+
return { lines: lines.length ? lines : [worker.question ?? "Worker needs input."], additions: 0, deletions: 0, hasChangeSet: false, ...(risk ? { risk } : {}) };
|
|
1025
|
+
}
|
|
1026
|
+
if (state === "failed") return { lines: [worker.lastError ?? "Worker failed."], additions: 0, deletions: 0, hasChangeSet: false };
|
|
1027
|
+
const changedFiles = artifactChangedFiles(changeSet);
|
|
1028
|
+
if (changedFiles.length > 0) {
|
|
1029
|
+
const totals = changedFiles.reduce<{ additions: number; deletions: number }>((acc, file) => {
|
|
1030
|
+
const additions = typeof file.additions === "number" ? file.additions : 0;
|
|
1031
|
+
const deletions = typeof file.deletions === "number" ? file.deletions : 0;
|
|
1032
|
+
return { additions: acc.additions + additions, deletions: acc.deletions + deletions };
|
|
1033
|
+
}, { additions: 0, deletions: 0 });
|
|
1034
|
+
const hunkCount = artifactHunkCount(changeSet);
|
|
1035
|
+
const fileLines = changedFiles.slice(0, 5).map((file) => {
|
|
1036
|
+
const filePath = typeof file.path === "string" ? file.path : "unknown";
|
|
1037
|
+
const additions = typeof file.additions === "number" ? file.additions : 0;
|
|
1038
|
+
const deletions = typeof file.deletions === "number" ? file.deletions : 0;
|
|
1039
|
+
return `${filePath} +${additions}/-${deletions}`;
|
|
1040
|
+
});
|
|
1041
|
+
const intent = workerIntentLine(worker);
|
|
1042
|
+
return { lines: fileLines, additions: totals.additions, deletions: totals.deletions, hunkCount, hasChangeSet: true, ...(intent ? { intent } : {}) };
|
|
1043
|
+
}
|
|
1044
|
+
const lines = [worker.summary, ...(worker.recommended ?? [])].filter((line): line is string => typeof line === "string" && line.trim().length > 0);
|
|
1045
|
+
return { lines: lines.length ? lines : ["Worker ready."], additions: 0, deletions: 0, hasChangeSet: false };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
class DocketVerdictView implements Component {
|
|
1049
|
+
private container: Container | Box = new Container();
|
|
1050
|
+
private selected = 0;
|
|
1051
|
+
private cachedWidth?: number;
|
|
1052
|
+
private cachedLines?: string[];
|
|
1053
|
+
private readonly changeSet?: Artifact;
|
|
1054
|
+
private readonly options: string[];
|
|
1055
|
+
private readonly recommend?: string;
|
|
1056
|
+
private readonly timer?: NodeJS.Timeout;
|
|
1057
|
+
|
|
1058
|
+
constructor(
|
|
1059
|
+
private tui: TUI,
|
|
1060
|
+
private theme: any,
|
|
1061
|
+
private worker: WorkerStatus,
|
|
1062
|
+
changeSet: Artifact | undefined,
|
|
1063
|
+
private done: (result: DocketVerdictAction | null) => void,
|
|
1064
|
+
private remaining = 0,
|
|
1065
|
+
) {
|
|
1066
|
+
this.changeSet = changeSet;
|
|
1067
|
+
const question = primaryWorkerQuestion(worker);
|
|
1068
|
+
this.options = deriveWorkerState(worker) === "needs_input" && question?.options ? question.options : [];
|
|
1069
|
+
this.recommend = question?.recommend;
|
|
1070
|
+
const recommendIndex = this.recommend ? this.options.indexOf(this.recommend) : -1;
|
|
1071
|
+
if (recommendIndex >= 0) this.selected = recommendIndex;
|
|
1072
|
+
const state = deriveWorkerState(worker);
|
|
1073
|
+
if (state === "starting" || state === "thinking") {
|
|
1074
|
+
this.timer = setInterval(() => this.tui.requestRender(), DOCK_PULSE_INTERVAL_MS);
|
|
1075
|
+
this.timer.unref?.();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private finish(result: DocketVerdictAction | null): void {
|
|
1080
|
+
if (this.timer) clearInterval(this.timer);
|
|
1081
|
+
this.done(result);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
handleInput(data: string): void {
|
|
1085
|
+
const state = deriveWorkerState(this.worker);
|
|
1086
|
+
const verbs = verdictVerbs(state, this.changeSet !== undefined, this.options);
|
|
1087
|
+
const max = Math.max(0, verbs.length - 1);
|
|
1088
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || data === "q") {
|
|
1089
|
+
this.finish(null);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.min(max, this.selected + 1);
|
|
1093
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
|
|
1094
|
+
else if (data === "g") this.selected = 0;
|
|
1095
|
+
else if (data === "G") this.selected = max;
|
|
1096
|
+
else if (data === "d" && this.changeSet) {
|
|
1097
|
+
this.finish({ verb: "diff", worker: this.worker, changeSet: this.changeSet });
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
else if (matchesKey(data, Key.enter)) {
|
|
1101
|
+
const verb = verbs[this.selected];
|
|
1102
|
+
if (verb) this.finish({ verb: verb.id, worker: this.worker, ...(this.changeSet ? { changeSet: this.changeSet } : {}), ...(verb.send !== undefined ? { text: verb.send } : {}) });
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
this.invalidate();
|
|
1106
|
+
this.tui.requestRender();
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
invalidate(): void {
|
|
1110
|
+
this.container.invalidate();
|
|
1111
|
+
this.cachedWidth = undefined;
|
|
1112
|
+
this.cachedLines = undefined;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
render(width: number): string[] {
|
|
1116
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
1117
|
+
this.container = new Box(2, 1, docketCardBg(this.theme));
|
|
1118
|
+
const innerWidth = Math.max(20, width - 4);
|
|
1119
|
+
const listWidth = Math.max(30, innerWidth);
|
|
1120
|
+
const state = deriveWorkerState(this.worker);
|
|
1121
|
+
const payload = workerVerdictPayload(this.worker, this.changeSet);
|
|
1122
|
+
const verbs = verdictVerbs(state, payload.hasChangeSet, this.options);
|
|
1123
|
+
this.selected = Math.min(this.selected, Math.max(0, verbs.length - 1));
|
|
1124
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
1125
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
1126
|
+
const muted = (s: string) => this.theme.fg("muted", s);
|
|
1127
|
+
const text = (s: string) => this.theme.fg("text", s);
|
|
1128
|
+
const border = (s: string) => this.theme.fg("border", s);
|
|
1129
|
+
const divider = (s: string) => this.theme.fg("borderMuted", s);
|
|
1130
|
+
const warning = (s: string) => this.theme.fg("warning", s);
|
|
1131
|
+
const stateLabel = state === "ready_open_todos" ? "ready · open todos" : state.replace(/_/g, " ");
|
|
1132
|
+
const active = state === "starting" || state === "thinking";
|
|
1133
|
+
const glyph = active ? workerPulseGlyph() : "●";
|
|
1134
|
+
const label = workerSourceLabel(this.worker);
|
|
1135
|
+
const task = workerSummaryName(this.worker, 28);
|
|
1136
|
+
const headerLeft = ` ${accent(this.theme.bold("docket"))} ${dim("·")} ${accent("verdict")} `;
|
|
1137
|
+
const headerRight = ` ${dim("Esc close")} `;
|
|
1138
|
+
this.container.addChild(new Text(fitBorder(headerLeft, headerRight, innerWidth, border, TOP_CORNERS), 0, 0));
|
|
1139
|
+
const head = `${workerStateColor(this.theme, state, glyph)} ${text(`${label} · ${task}`)} ${muted(`${stateLabel} · ${relativeTime(Date.parse(this.worker.updatedAt))}`)}`;
|
|
1140
|
+
this.container.addChild(new Text(truncateToWidth(` ${head}`, listWidth - 2), 1, 0));
|
|
1141
|
+
this.container.addChild(new Spacer(1));
|
|
1142
|
+
if (payload.hasChangeSet) {
|
|
1143
|
+
if (payload.intent) {
|
|
1144
|
+
for (const wrapped of wrapPlainText(payload.intent, listWidth - 4, 2)) this.container.addChild(new Text(truncateToWidth(` ${text(wrapped)}`, listWidth - 2), 1, 0));
|
|
1145
|
+
this.container.addChild(new Spacer(1));
|
|
1146
|
+
}
|
|
1147
|
+
const hunk = payload.hunkCount === undefined ? "" : ` ${payload.hunkCount} hunk${payload.hunkCount === 1 ? "" : "s"}`;
|
|
1148
|
+
const files = artifactChangedFiles(this.changeSet).length;
|
|
1149
|
+
const stat = `${files} file${files === 1 ? "" : "s"} +${payload.additions} / -${payload.deletions} ${coloredDiffBar(this.theme, payload.additions, payload.deletions, 14)}${hunk}`;
|
|
1150
|
+
this.container.addChild(new Text(truncateToWidth(` ${muted(stat)}`, listWidth - 2), 1, 0));
|
|
1151
|
+
for (const line of payload.lines) this.container.addChild(new Text(truncateToWidth(` ${dim(line)}`, listWidth - 2), 1, 0));
|
|
1152
|
+
} else {
|
|
1153
|
+
if (payload.risk) {
|
|
1154
|
+
for (const wrapped of wrapPlainText(payload.risk, listWidth - 6, 2)) this.container.addChild(new Text(truncateToWidth(` ${warning(`⚠ ${wrapped}`)}`, listWidth - 2), 1, 0));
|
|
1155
|
+
this.container.addChild(new Spacer(1));
|
|
1156
|
+
}
|
|
1157
|
+
for (const line of payload.lines.slice(0, 5)) {
|
|
1158
|
+
for (const wrapped of wrapPlainText(line, listWidth - 4, 3)) this.container.addChild(new Text(truncateToWidth(` ${text(wrapped)}`, listWidth - 2), 1, 0));
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
this.container.addChild(new DynamicBorder(divider));
|
|
1162
|
+
for (let i = 0; i < verbs.length; i++) {
|
|
1163
|
+
const verb = verbs[i]!;
|
|
1164
|
+
const selected = i === this.selected;
|
|
1165
|
+
const marker = selected ? accent("▸") : " ";
|
|
1166
|
+
if (verb.send !== undefined) {
|
|
1167
|
+
const badge = this.recommend && verb.send === this.recommend ? muted(" · recommended") : "";
|
|
1168
|
+
const optionLabel = selected ? accent(this.theme.bold(verb.label)) : text(verb.label);
|
|
1169
|
+
this.container.addChild(new Text(truncateToWidth(` ${marker} ${optionLabel}${badge}`, listWidth - 2), 1, 0));
|
|
1170
|
+
} else {
|
|
1171
|
+
const labelText = selected ? accent(this.theme.bold(verb.label.padEnd(14))) : text(verb.label.padEnd(14));
|
|
1172
|
+
this.container.addChild(new Text(truncateToWidth(` ${marker} ${labelText} ${dim(verb.description)}`, listWidth - 2), 1, 0));
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
this.container.addChild(new DynamicBorder(divider));
|
|
1176
|
+
const diffHint = this.changeSet ? "d full diff · " : "";
|
|
1177
|
+
const exitHint = this.remaining > 0 ? `Esc stop · ${this.remaining} more` : "Esc close";
|
|
1178
|
+
this.container.addChild(new Text(dim(`${diffHint}↑↓ move · Enter select · ${exitHint}`), 1, 0));
|
|
1179
|
+
this.container.addChild(new Text(fitBorder("", "", innerWidth, border, BOTTOM_CORNERS), 0, 0));
|
|
1180
|
+
this.cachedLines = this.container.render(width);
|
|
1181
|
+
this.cachedWidth = width;
|
|
1182
|
+
return this.cachedLines;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function showWorkerVerdict(ctx: ExtensionCommandContext, worker: WorkerStatus, remaining = 0): Promise<DocketVerdictAction | null> {
|
|
1187
|
+
const state = deriveWorkerState(worker);
|
|
1188
|
+
const changeSet = state === "ready" || state === "ready_open_todos" ? workerChangeSetArtifact(worker) : undefined;
|
|
1189
|
+
return ctx.ui.custom<DocketVerdictAction | null>((tui, theme, _kb, done) => new DocketVerdictView(tui, theme, worker, changeSet, done, remaining), {
|
|
1190
|
+
overlay: true,
|
|
1191
|
+
overlayOptions: { anchor: "bottom-center", width: "72%", minWidth: 64, maxHeight: "70%", margin: 1, offsetY: -1 },
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function compactTokens(tokens: number): string {
|
|
1196
|
+
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
class DocketResumeView implements Component {
|
|
1200
|
+
private container: Container | Box = new Container();
|
|
1201
|
+
private selected: number;
|
|
1202
|
+
private cachedWidth?: number;
|
|
1203
|
+
private cachedLines?: string[];
|
|
1204
|
+
|
|
1205
|
+
constructor(
|
|
1206
|
+
private tui: TUI,
|
|
1207
|
+
private theme: any,
|
|
1208
|
+
private summaries: CheckpointSummary[],
|
|
1209
|
+
initialSelected: number,
|
|
1210
|
+
private done: (result: ResumeSelection) => void,
|
|
1211
|
+
private mode: ResumeMode = "resume",
|
|
1212
|
+
) {
|
|
1213
|
+
this.selected = Math.min(Math.max(0, initialSelected), Math.max(0, summaries.length - 1));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
handleInput(data: string): void {
|
|
1217
|
+
if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
|
|
1218
|
+
this.done(null);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.min(this.selected + 1, Math.max(0, this.summaries.length - 1));
|
|
1222
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
|
|
1223
|
+
else if (data === "g") this.selected = 0;
|
|
1224
|
+
else if (data === "G") this.selected = Math.max(0, this.summaries.length - 1);
|
|
1225
|
+
else if (matchesKey(data, Key.enter)) {
|
|
1226
|
+
const action: ResumeAction = this.mode === "delete" ? "delete" : this.mode === "load" ? "load" : "continue";
|
|
1227
|
+
this.finish(action);
|
|
1228
|
+
}
|
|
1229
|
+
else if (data === "p") this.finish("preview");
|
|
1230
|
+
else if (data === "e" && this.mode === "resume") this.finish("edit");
|
|
1231
|
+
else if (data === "d" && this.mode !== "load") this.finish("delete");
|
|
1232
|
+
this.invalidate();
|
|
1233
|
+
this.tui.requestRender();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
private finish(action: ResumeAction): void {
|
|
1237
|
+
const summary = this.summaries[this.selected];
|
|
1238
|
+
if (summary) this.done({ action, summary, index: this.selected });
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
invalidate(): void {
|
|
1242
|
+
this.container.invalidate();
|
|
1243
|
+
this.cachedWidth = undefined;
|
|
1244
|
+
this.cachedLines = undefined;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
render(width: number): string[] {
|
|
1248
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
1249
|
+
this.container = new Box(2, 1, docketCardBg(this.theme));
|
|
1250
|
+
const innerWidth = Math.max(20, width - 4);
|
|
1251
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
1252
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
1253
|
+
const muted = (s: string) => this.theme.fg("muted", s);
|
|
1254
|
+
const outerBorder = (s: string) => this.theme.fg("borderAccent", s);
|
|
1255
|
+
const dividerBorder = (s: string) => this.theme.fg("borderMuted", s);
|
|
1256
|
+
const listWidth = Math.max(30, innerWidth);
|
|
1257
|
+
const start = Math.max(0, Math.min(this.selected - 5, this.summaries.length - 11));
|
|
1258
|
+
const visible = this.summaries.slice(start, start + 11);
|
|
1259
|
+
|
|
1260
|
+
const headerLeft = ` ${accent(this.theme.bold(`docket · ${this.mode}`))} ${dim(`${this.summaries.length} checkpoint${this.summaries.length === 1 ? "" : "s"}`)} `;
|
|
1261
|
+
this.container.addChild(new Text(fitBorder(headerLeft, "", innerWidth, outerBorder, TOP_CORNERS), 0, 0));
|
|
1262
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1263
|
+
const summary = visible[i];
|
|
1264
|
+
if (!summary) continue;
|
|
1265
|
+
const absolute = start + i;
|
|
1266
|
+
const entry = summary.entry;
|
|
1267
|
+
const selected = absolute === this.selected;
|
|
1268
|
+
const marker = selected ? accent("▸") : dim(" ");
|
|
1269
|
+
const id = selected ? accent(this.theme.bold(entry.id.slice(0, 18).padEnd(18))) : muted(entry.id.slice(0, 18).padEnd(18));
|
|
1270
|
+
const mode = entry.consumeOnUse ? `${entry.mode}:once` : entry.mode;
|
|
1271
|
+
const stats = `${compactTokens(summary.estimatedTokens)} tok · ${summary.files} files · ${summary.errors} err · ${summary.commands} cmd`;
|
|
1272
|
+
const git = gitSnapshotLabel(entry.git);
|
|
1273
|
+
const meta = [stats, git].filter(Boolean).join(" · ");
|
|
1274
|
+
const line = `${marker} ${id} ${accent(mode.padEnd(12))} ${dim(relativeTime(Date.parse(entry.createdAt)).padEnd(9))} ${meta} ${muted(entry.note ?? "")}`;
|
|
1275
|
+
this.container.addChild(new Text(truncateToWidth(line, listWidth - 2), 1, 0));
|
|
1276
|
+
}
|
|
1277
|
+
this.container.addChild(new DynamicBorder(dividerBorder));
|
|
1278
|
+
const help = this.mode === "delete"
|
|
1279
|
+
? "j/k move · enter delete · p preview · q close"
|
|
1280
|
+
: this.mode === "load"
|
|
1281
|
+
? "j/k move · enter load · p preview · q close"
|
|
1282
|
+
: "j/k move · enter continue · p preview · e edit · d delete · q close";
|
|
1283
|
+
this.container.addChild(new Text(dim(help), 1, 0));
|
|
1284
|
+
this.container.addChild(new Text(fitBorder("", "", innerWidth, outerBorder, BOTTOM_CORNERS), 0, 0));
|
|
1285
|
+
this.cachedLines = this.container.render(width);
|
|
1286
|
+
this.cachedWidth = width;
|
|
1287
|
+
return this.cachedLines;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
async function showCheckpointResumeSelector(ctx: ExtensionCommandContext, summaries: CheckpointSummary[], selected: number, mode: ResumeMode = "resume"): Promise<ResumeSelection> {
|
|
1292
|
+
return ctx.ui.custom((tui, theme, _kb, done) => new DocketResumeView(tui, theme, summaries, selected, done, mode), {
|
|
1293
|
+
overlay: true,
|
|
1294
|
+
overlayOptions: { anchor: "center", width: "88%", minWidth: 84, maxHeight: "90%", margin: 1 },
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
type ParallelKindFilter = ArtifactKind | "all";
|
|
1299
|
+
type ParallelSource = "all" | string;
|
|
1300
|
+
|
|
1301
|
+
const PARALLEL_KIND_FILTERS: ParallelKindFilter[] = ["all", "error", "response", "file", "command", "checkpoint", "code", "prompt"];
|
|
1302
|
+
|
|
1303
|
+
function workerStateColor(theme: any, state: WorkerDerivedState, text: string): string {
|
|
1304
|
+
if (state === "needs_input" || state === "ready_open_todos") return theme.fg("warning", text);
|
|
1305
|
+
if (state === "ready") return theme.fg("success", text);
|
|
1306
|
+
if (state === "failed") return theme.fg("error", text);
|
|
1307
|
+
if (state === "starting" || state === "thinking") return theme.fg("accent", text);
|
|
1308
|
+
return theme.fg("muted", text);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function artifactInboxRank(kind: ArtifactKind): number {
|
|
1312
|
+
if (kind === "error") return 0;
|
|
1313
|
+
if (kind === "response") return 1;
|
|
1314
|
+
if (kind === "checkpoint") return 2;
|
|
1315
|
+
if (kind === "file") return 3;
|
|
1316
|
+
if (kind === "command") return 4;
|
|
1317
|
+
if (kind === "code") return 5;
|
|
1318
|
+
if (kind === "prompt") return 6;
|
|
1319
|
+
return 7;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function parallelKindLabel(kind: ArtifactKind): string {
|
|
1323
|
+
return kind === "response" ? "answer" : kindLabel(kind);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function parallelKindGlyph(kind: ArtifactKind): string {
|
|
1327
|
+
if (kind === "error") return "!";
|
|
1328
|
+
if (kind === "response") return "✦";
|
|
1329
|
+
if (kind === "file") return "f";
|
|
1330
|
+
if (kind === "command") return "$";
|
|
1331
|
+
if (kind === "checkpoint") return "◆";
|
|
1332
|
+
if (kind === "code") return "{}";
|
|
1333
|
+
return "·";
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function fitColumn(text: string, width: number): string {
|
|
1337
|
+
return padAnsi(truncateToWidth(text, width, ""), width);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function workerActivityRowText(row: WorkerActivityRow, width: number, selected = false, options: { hideAction?: boolean } = {}): string {
|
|
1341
|
+
const marker = selected ? "▸" : "●";
|
|
1342
|
+
if (width < 92) {
|
|
1343
|
+
const tail = options.hideAction ? row.outputLabel : `${row.outputLabel} · ${row.actionHint}`;
|
|
1344
|
+
return truncateToWidth(`${marker} ${row.label} ${row.stateLabel} ${row.taskLabel} · ${tail}`, width, "");
|
|
1345
|
+
}
|
|
1346
|
+
const labelWidth = 4;
|
|
1347
|
+
const statusWidth = 14;
|
|
1348
|
+
const outputWidth = 32;
|
|
1349
|
+
const actionWidth = options.hideAction ? 0 : row.actionHint.length;
|
|
1350
|
+
const fixed = 2 + labelWidth + 2 + statusWidth + 2 + outputWidth + (options.hideAction ? 0 : 2);
|
|
1351
|
+
const taskWidth = Math.max(14, Math.min(40, width - fixed - actionWidth));
|
|
1352
|
+
const cells: string[] = [
|
|
1353
|
+
marker,
|
|
1354
|
+
fitColumn(row.label, labelWidth),
|
|
1355
|
+
fitColumn(row.stateLabel, statusWidth),
|
|
1356
|
+
fitColumn(row.taskLabel, taskWidth),
|
|
1357
|
+
options.hideAction ? row.outputLabel : fitColumn(row.outputLabel, outputWidth),
|
|
1358
|
+
];
|
|
1359
|
+
if (!options.hideAction) cells.push(row.actionHint);
|
|
1360
|
+
return truncateToWidth(cells.join(" "), width, "");
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function renderWorkerActivityRows(theme: any, rows: WorkerActivityRow[], width: number, selectedIndex?: number, options: { hideAction?: boolean } = {}): string[] {
|
|
1364
|
+
return rows.map((row, index) => {
|
|
1365
|
+
const plain = workerActivityRowText(row, width, selectedIndex === index, options);
|
|
1366
|
+
if (selectedIndex === index) return theme.bold(theme.fg("text", plain));
|
|
1367
|
+
return workerStateColor(theme, row.state, plain);
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function dockRowText(row: DockRow, width: number, now: number): string {
|
|
1372
|
+
// Active workers breathe; everyone else (attention, idle) holds a steady dot.
|
|
1373
|
+
const marker = row.state === "thinking" || row.state === "starting" ? workerPulseGlyph(now) : "●";
|
|
1374
|
+
const kindCell = row.kindLabel ? `·${row.kindLabel}` : "";
|
|
1375
|
+
const modelCell = row.modelBadge ? `[${row.modelBadge}]` : "";
|
|
1376
|
+
const labelCell = `${row.label}${kindCell}${modelCell}`;
|
|
1377
|
+
const stateCell = row.state === "thinking" || row.state === "starting" ? "" : row.state === "ready_open_todos" ? "ready/todos" : row.state.replace(/_/g, " ");
|
|
1378
|
+
const docketing = [row.progressLabel, row.ageLabel].filter(Boolean).join(" · ");
|
|
1379
|
+
const left = `${marker} ${labelCell}${stateCell ? ` ${stateCell}` : ""} ${row.taskLabel}`.trim();
|
|
1380
|
+
const right = [docketing, row.chip].filter(Boolean).join(" ");
|
|
1381
|
+
const sep = " ";
|
|
1382
|
+
const rightLen = visibleWidth(right);
|
|
1383
|
+
if (!right) return truncateToWidth(left, width, "");
|
|
1384
|
+
const leftWidth = Math.max(0, width - rightLen - sep.length);
|
|
1385
|
+
const leftFit = truncateToWidth(left, leftWidth, "");
|
|
1386
|
+
const leftPad = padAnsi(leftFit, leftWidth);
|
|
1387
|
+
return `${leftPad}${sep}${right}`;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function renderDockRows(theme: any, rows: DockRow[], width: number, now: number): string[] {
|
|
1391
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
1392
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
1393
|
+
const out: string[] = [];
|
|
1394
|
+
for (const row of rows) {
|
|
1395
|
+
const plain = dockRowText(row, width, now);
|
|
1396
|
+
out.push(row.attention ? workerStateColor(theme, row.state, plain) : dim(plain));
|
|
1397
|
+
if (row.eventLine) {
|
|
1398
|
+
const sub = truncateToWidth(` ${row.eventLine}`, width, "");
|
|
1399
|
+
out.push(muted(sub));
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return out;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const WORKER_PREVIEW_HEADINGS = new Set(["Kind", "Outcome", "Evidence", "Next actions"]);
|
|
1406
|
+
|
|
1407
|
+
function addWorkerActivityPreview(container: Container | Box, theme: any, row: WorkerActivityRow | undefined, width: number): void {
|
|
1408
|
+
if (!row) return;
|
|
1409
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
1410
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
1411
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
1412
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("borderMuted", s)));
|
|
1413
|
+
const lines = workerActivityPreviewLines(row);
|
|
1414
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1415
|
+
const raw = lines[i]!;
|
|
1416
|
+
if (WORKER_PREVIEW_HEADINGS.has(raw)) {
|
|
1417
|
+
container.addChild(new Text(truncateToWidth(accent(theme.bold(raw)), width), 1, 0));
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
const isActionRow = raw.startsWith("[");
|
|
1421
|
+
const maxLines = isActionRow ? 1 : 4;
|
|
1422
|
+
for (const line of wrapPlainText(raw, width, maxLines)) {
|
|
1423
|
+
if (isActionRow) {
|
|
1424
|
+
const colored = line.replace(/\[([^\]]+)\]/g, (_, inner: string, offset: number) => offset === 0 ? accent(`[${inner}]`) : muted(`[${inner}]`));
|
|
1425
|
+
container.addChild(new Text(truncateToWidth(colored, width), 1, 0));
|
|
1426
|
+
} else {
|
|
1427
|
+
container.addChild(new Text(truncateToWidth(` ${dim(line)}`, width), 1, 0));
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
async function readWorkerArtifactsForReview(worker: WorkerStatus): Promise<Artifact[]> {
|
|
1434
|
+
const artifacts = await createWorkerStore().readArtifacts(worker.id);
|
|
1435
|
+
const status = workerStatusArtifact(worker);
|
|
1436
|
+
const changes = worker.state === "ready" || worker.state === "failed" || worker.state === "ended" || worker.state === "needs_input" ? workerChangeSetArtifact(worker) : undefined;
|
|
1437
|
+
return [status, changes, ...artifacts.filter((artifact) => artifact.ref !== status?.ref && artifact.ref !== changes?.ref)].filter((artifact): artifact is Artifact => artifact !== undefined);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function parallelEntries(workers: WorkerStatus[], artifactsByWorker: Map<string, Artifact[]>, source: ParallelSource, filter: ParallelKindFilter, dismissed: Set<string>): ParallelWorkEntry[] {
|
|
1441
|
+
const entries: ParallelWorkEntry[] = [];
|
|
1442
|
+
for (const worker of workers) {
|
|
1443
|
+
if (source !== "all" && source !== worker.id) continue;
|
|
1444
|
+
for (const artifact of namespaceWorkerArtifacts(worker, artifactsByWorker.get(worker.id) ?? [])) {
|
|
1445
|
+
if (filter !== "all" && artifact.kind !== filter) continue;
|
|
1446
|
+
if (dismissed.has(`${worker.id}:${artifact.ref}`)) continue;
|
|
1447
|
+
entries.push({ worker, artifact });
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return entries.sort((a, b) => {
|
|
1451
|
+
const rank = artifactInboxRank(a.artifact.kind) - artifactInboxRank(b.artifact.kind);
|
|
1452
|
+
if (rank !== 0) return rank;
|
|
1453
|
+
return (b.artifact.timestamp ?? Date.parse(b.worker.updatedAt)) - (a.artifact.timestamp ?? Date.parse(a.worker.updatedAt));
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function readWorkersWithArtifacts(store = createWorkerStore(), projectRoot?: string): Promise<{ workers: WorkerStatus[]; artifactsByWorker: Map<string, Artifact[]> }> {
|
|
1458
|
+
const workers = await store.list({ ...(projectRoot ? { projectRoot } : {}) });
|
|
1459
|
+
const artifactsByWorker = new Map<string, Artifact[]>();
|
|
1460
|
+
await Promise.all(workers.map(async (worker) => {
|
|
1461
|
+
artifactsByWorker.set(worker.id, await readWorkerArtifactsForReview(worker));
|
|
1462
|
+
}));
|
|
1463
|
+
return { workers, artifactsByWorker };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function renderParallelWorkList(workers: WorkerStatus[], artifactsByWorker: Map<string, Artifact[]>, options: { groupByProject?: boolean } = {}): string {
|
|
1467
|
+
const entries = parallelEntries(workers, artifactsByWorker, "all", "all", new Set());
|
|
1468
|
+
if (workers.length === 0) return "No Docket workers";
|
|
1469
|
+
const header = `${workers.length} workers · ${entries.length} artifacts`;
|
|
1470
|
+
if (!options.groupByProject) {
|
|
1471
|
+
const lines = entries.slice(0, 20).map((entry) => `${workerSourceLabel(entry.worker)}\t${entry.artifact.kind}\t${entry.artifact.displayId}\t${entry.artifact.title}`);
|
|
1472
|
+
return [header, ...lines].join("\n");
|
|
1473
|
+
}
|
|
1474
|
+
const projects = [...new Set(workers.map(workerProjectKey))].sort();
|
|
1475
|
+
const lines: string[] = [header];
|
|
1476
|
+
for (const project of projects) {
|
|
1477
|
+
lines.push(`project: ${project}`);
|
|
1478
|
+
for (const entry of entries.filter((candidate) => workerProjectKey(candidate.worker) === project).slice(0, 20)) {
|
|
1479
|
+
lines.push(`${workerSourceLabel(entry.worker)}\t${entry.artifact.kind}\t${entry.artifact.displayId}\t${entry.artifact.title}`);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return lines.join("\n");
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
class DocketParallelWorkView implements Component {
|
|
1486
|
+
private container: Container | Box = new Container();
|
|
1487
|
+
private selected = 0;
|
|
1488
|
+
private showHelp = false;
|
|
1489
|
+
private cachedWidth?: number;
|
|
1490
|
+
private cachedLines?: string[];
|
|
1491
|
+
|
|
1492
|
+
constructor(
|
|
1493
|
+
private tui: TUI,
|
|
1494
|
+
private theme: any,
|
|
1495
|
+
private workers: WorkerStatus[],
|
|
1496
|
+
private artifactsByWorker: Map<string, Artifact[]>,
|
|
1497
|
+
private done: (result: ParallelWorkAction) => void,
|
|
1498
|
+
private groupByProject = false,
|
|
1499
|
+
) {}
|
|
1500
|
+
|
|
1501
|
+
private entries(): ParallelWorkEntry[] {
|
|
1502
|
+
return parallelEntries(this.workers, this.artifactsByWorker, "all", "all", new Set());
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
private activityRows(): WorkerActivityRow[] {
|
|
1506
|
+
const rows = workerActivityRows(this.workers, this.artifactsByWorker);
|
|
1507
|
+
if (!this.groupByProject) return rows;
|
|
1508
|
+
return [...rows].sort((a, b) => workerProjectKey(a.worker).localeCompare(workerProjectKey(b.worker)));
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
private selectedWorker(): WorkerStatus | undefined {
|
|
1512
|
+
return this.activityRows()[this.selected]?.worker;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
private selectNext(): void {
|
|
1516
|
+
const max = Math.max(0, this.activityRows().length - 1);
|
|
1517
|
+
this.selected = Math.min(max, this.selected + 1);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
handleInput(data: string): void {
|
|
1521
|
+
const rows = this.activityRows();
|
|
1522
|
+
const max = Math.max(0, rows.length - 1);
|
|
1523
|
+
if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
|
|
1524
|
+
this.done(null);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.min(max, this.selected + 1);
|
|
1528
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
|
|
1529
|
+
else if (data === "g") this.selected = 0;
|
|
1530
|
+
else if (data === "G") this.selected = max;
|
|
1531
|
+
else if (matchesKey(data, Key.tab)) this.selectNext();
|
|
1532
|
+
else if (data === "?") this.showHelp = !this.showHelp;
|
|
1533
|
+
else if (matchesKey(data, Key.enter)) {
|
|
1534
|
+
const worker = this.selectedWorker();
|
|
1535
|
+
if (worker) this.done({ action: "details", worker });
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
else if (data === "l") {
|
|
1539
|
+
const worker = this.selectedWorker();
|
|
1540
|
+
if (worker) this.done({ action: "load", worker });
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
else if (data === "c" || data === "t") {
|
|
1544
|
+
const worker = this.selectedWorker();
|
|
1545
|
+
if (worker) this.done({ action: "tell", worker });
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
else if (data === "a") {
|
|
1549
|
+
const worker = this.selectedWorker();
|
|
1550
|
+
if (worker) this.done({ action: "copyAttach", worker });
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
else if (data === "x") {
|
|
1554
|
+
const worker = this.selectedWorker();
|
|
1555
|
+
if (worker) this.done({ action: "stop", worker });
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
this.invalidate();
|
|
1559
|
+
this.tui.requestRender();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
invalidate(): void {
|
|
1563
|
+
this.container.invalidate();
|
|
1564
|
+
this.cachedWidth = undefined;
|
|
1565
|
+
this.cachedLines = undefined;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
render(width: number): string[] {
|
|
1569
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
1570
|
+
this.container = new Box(2, 1, docketCardBg(this.theme));
|
|
1571
|
+
const innerWidth = Math.max(20, width - 4);
|
|
1572
|
+
const listWidth = Math.max(30, innerWidth);
|
|
1573
|
+
const entries = this.entries();
|
|
1574
|
+
const activityRows = this.activityRows();
|
|
1575
|
+
this.selected = Math.min(this.selected, Math.max(0, activityRows.length - 1));
|
|
1576
|
+
const selectedRow = activityRows[this.selected];
|
|
1577
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
1578
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
1579
|
+
const muted = (s: string) => this.theme.fg("muted", s);
|
|
1580
|
+
const border = (s: string) => this.theme.fg("border", s);
|
|
1581
|
+
const divider = (s: string) => this.theme.fg("borderMuted", s);
|
|
1582
|
+
const workerCounts = workerActivityTotals(activityRows);
|
|
1583
|
+
const status = [
|
|
1584
|
+
workerCounts.waiting ? `${workerCounts.waiting} waiting` : undefined,
|
|
1585
|
+
workerCounts.failed ? `${workerCounts.failed} failed` : undefined,
|
|
1586
|
+
workerCounts.readyOpenTodos ? `${workerCounts.readyOpenTodos} ready/open todos` : undefined,
|
|
1587
|
+
workerCounts.ready ? `${workerCounts.ready} ready` : undefined,
|
|
1588
|
+
workerCounts.active ? `${workerCounts.active} active` : undefined,
|
|
1589
|
+
].filter(Boolean).join(" · ") || plural(this.workers.length, "worker");
|
|
1590
|
+
|
|
1591
|
+
this.container.addChild(new Text(fitBorder(` ${accent(this.theme.bold("docket"))} ${dim("·")} ${accent("workers")} `, ` ${dim("Esc close")} `, innerWidth, border, TOP_CORNERS), 0, 0));
|
|
1592
|
+
const todoStatus = workerCounts.todos ? ` · todos ${workerCounts.completedTodos}/${workerCounts.todos}` : "";
|
|
1593
|
+
const artifactStatus = entries.length ? ` · ${entries.length} items` : "";
|
|
1594
|
+
this.container.addChild(new Text(truncateToWidth(` ${muted(status)}${dim(todoStatus)}${dim(artifactStatus)}`, innerWidth - 2), 1, 0));
|
|
1595
|
+
this.container.addChild(new DynamicBorder(divider));
|
|
1596
|
+
|
|
1597
|
+
if (activityRows.length === 0) {
|
|
1598
|
+
const mascotWorker = this.workers[0];
|
|
1599
|
+
this.container.addChild(new Spacer(1));
|
|
1600
|
+
for (const line of workerMascotLines(mascotWorker)) this.container.addChild(new Text(` ${accent(line)}`, 1, 0));
|
|
1601
|
+
this.container.addChild(new Text(fitBorder(` ${accent(this.theme.bold("No parallel work yet"))} `, "", listWidth - 2, divider, TOP_CORNERS), 1, 0));
|
|
1602
|
+
this.container.addChild(new Text(truncateToWidth(` ${muted("Spawn a side investigation when you want evidence without interrupting current flow.")}`, listWidth - 2), 1, 0));
|
|
1603
|
+
this.container.addChild(new Text(truncateToWidth(` ${dim("Try: /docket spawn <task>")}`, listWidth - 2), 1, 0));
|
|
1604
|
+
this.container.addChild(new Text(fitBorder("", "", listWidth - 2, divider, BOTTOM_CORNERS), 1, 0));
|
|
1605
|
+
this.container.addChild(new Spacer(1));
|
|
1606
|
+
} else {
|
|
1607
|
+
if (listWidth >= 92) this.container.addChild(new Text(dim(` ${fitColumn("worker", 4)} ${fitColumn("status", 14)} ${fitColumn("task", Math.max(14, Math.min(40, listWidth - 64)))} ${fitColumn("result", 32)} action`), 1, 0));
|
|
1608
|
+
const renderedRows = renderWorkerActivityRows(this.theme, activityRows, listWidth - 2, this.selected);
|
|
1609
|
+
let previousProject: string | undefined;
|
|
1610
|
+
for (let i = 0; i < activityRows.length; i++) {
|
|
1611
|
+
const row = activityRows[i]!;
|
|
1612
|
+
if (this.groupByProject) {
|
|
1613
|
+
const project = workerProjectKey(row.worker);
|
|
1614
|
+
if (project !== previousProject) {
|
|
1615
|
+
previousProject = project;
|
|
1616
|
+
this.container.addChild(new Text(truncateToWidth(` ${muted("project:")} ${dim(project)}`, listWidth - 2), 1, 0));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
this.container.addChild(new Text(renderedRows[i]!, 1, 0));
|
|
1620
|
+
}
|
|
1621
|
+
addWorkerActivityPreview(this.container, this.theme, selectedRow, listWidth - 2);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
this.container.addChild(new DynamicBorder(divider));
|
|
1625
|
+
this.container.addChild(new Text(dim("↑↓ move · Enter details · c continue · a attach · l load · ? more · Esc close"), 1, 0));
|
|
1626
|
+
if (this.showHelp) {
|
|
1627
|
+
this.container.addChild(new Text(`${muted("Flow")} ${dim("rows stay collapsed; selected preview is informational; nothing enters context until loaded")}`, 1, 0));
|
|
1628
|
+
this.container.addChild(new Text(`${muted("Advanced")} ${dim("Tab switch worker · x stop worker (destructive)")}`, 1, 0));
|
|
1629
|
+
}
|
|
1630
|
+
this.container.addChild(new Text(fitBorder("", "", innerWidth, border, BOTTOM_CORNERS), 0, 0));
|
|
1631
|
+
this.cachedLines = this.container.render(width);
|
|
1632
|
+
this.cachedWidth = width;
|
|
1633
|
+
return this.cachedLines;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
async function showParallelWorkDashboard(ctx: ExtensionCommandContext, workers: WorkerStatus[], artifactsByWorker: Map<string, Artifact[]>, groupByProject = false): Promise<ParallelWorkAction> {
|
|
1639
|
+
return ctx.ui.custom((tui, theme, _kb, done) => new DocketParallelWorkView(tui, theme, workers, artifactsByWorker, done, groupByProject), {
|
|
1640
|
+
overlay: true,
|
|
1641
|
+
overlayOptions: { anchor: "center", width: "88%", minWidth: 84, maxHeight: "90%", margin: 1 },
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
class DocketLoadPicker implements Component {
|
|
1646
|
+
private container: Container | Box = new Container();
|
|
1647
|
+
private mode: LoadPickerMode;
|
|
1648
|
+
private checkpointIndex = 0;
|
|
1649
|
+
private workerIndex = 0;
|
|
1650
|
+
private cachedWidth?: number;
|
|
1651
|
+
private cachedLines?: string[];
|
|
1652
|
+
|
|
1653
|
+
constructor(
|
|
1654
|
+
private tui: TUI,
|
|
1655
|
+
private theme: any,
|
|
1656
|
+
private checkpoints: CheckpointSummary[],
|
|
1657
|
+
private workers: WorkerStatus[],
|
|
1658
|
+
initialMode: LoadPickerMode,
|
|
1659
|
+
private done: (result: LoadPickerSelection) => void,
|
|
1660
|
+
) {
|
|
1661
|
+
this.mode = this.canonicalMode(initialMode);
|
|
1662
|
+
this.checkpointIndex = Math.max(0, this.checkpoints.length - 1);
|
|
1663
|
+
this.workerIndex = Math.max(0, this.workers.length - 1);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
private canonicalMode(requested: LoadPickerMode): LoadPickerMode {
|
|
1667
|
+
if (requested === "worker" && this.workers.length === 0 && this.checkpoints.length > 0) return "checkpoint";
|
|
1668
|
+
if (requested === "checkpoint" && this.checkpoints.length === 0 && this.workers.length > 0) return "worker";
|
|
1669
|
+
return requested;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
private currentMax(): number {
|
|
1673
|
+
return Math.max(0, (this.mode === "checkpoint" ? this.checkpoints.length : this.workers.length) - 1);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
private currentIndex(): number {
|
|
1677
|
+
return this.mode === "checkpoint" ? this.checkpointIndex : this.workerIndex;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
private setIndex(value: number): void {
|
|
1681
|
+
const max = this.currentMax();
|
|
1682
|
+
const clamped = Math.max(0, Math.min(value, max));
|
|
1683
|
+
if (this.mode === "checkpoint") this.checkpointIndex = clamped;
|
|
1684
|
+
else this.workerIndex = clamped;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
private toggleMode(target?: LoadPickerMode): void {
|
|
1688
|
+
const next = target ?? (this.mode === "checkpoint" ? "worker" : "checkpoint");
|
|
1689
|
+
if (next === "checkpoint" && this.checkpoints.length === 0) return;
|
|
1690
|
+
if (next === "worker" && this.workers.length === 0) return;
|
|
1691
|
+
this.mode = next;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
handleInput(data: string): void {
|
|
1695
|
+
if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
|
|
1696
|
+
this.done(null);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (data === "j" || matchesKey(data, Key.down)) this.setIndex(this.currentIndex() + 1);
|
|
1700
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.setIndex(this.currentIndex() - 1);
|
|
1701
|
+
else if (data === "g") this.setIndex(0);
|
|
1702
|
+
else if (data === "G") this.setIndex(this.currentMax());
|
|
1703
|
+
else if (matchesKey(data, Key.tab)) this.toggleMode();
|
|
1704
|
+
else if (data === "1") this.toggleMode("checkpoint");
|
|
1705
|
+
else if (data === "2") this.toggleMode("worker");
|
|
1706
|
+
else if (matchesKey(data, Key.enter)) this.finishLoad();
|
|
1707
|
+
else if (data === "p" && this.mode === "checkpoint") this.finishPreview();
|
|
1708
|
+
this.invalidate();
|
|
1709
|
+
this.tui.requestRender();
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
private finishLoad(): void {
|
|
1713
|
+
if (this.mode === "checkpoint") {
|
|
1714
|
+
const summary = this.checkpoints[this.checkpointIndex];
|
|
1715
|
+
if (summary) this.done({ kind: "checkpoint", action: "load", summary });
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const worker = this.workers[this.workerIndex];
|
|
1719
|
+
if (worker) this.done({ kind: "worker", action: "load", worker });
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
private finishPreview(): void {
|
|
1723
|
+
const summary = this.checkpoints[this.checkpointIndex];
|
|
1724
|
+
if (summary) this.done({ kind: "checkpoint", action: "preview", summary });
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
invalidate(): void {
|
|
1728
|
+
this.container.invalidate();
|
|
1729
|
+
this.cachedWidth = undefined;
|
|
1730
|
+
this.cachedLines = undefined;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
render(width: number): string[] {
|
|
1734
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
1735
|
+
this.container = new Box(2, 1, docketCardBg(this.theme));
|
|
1736
|
+
const innerWidth = Math.max(20, width - 4);
|
|
1737
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
1738
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
1739
|
+
const muted = (s: string) => this.theme.fg("muted", s);
|
|
1740
|
+
const outerBorder = (s: string) => this.theme.fg("borderAccent", s);
|
|
1741
|
+
const dividerBorder = (s: string) => this.theme.fg("borderMuted", s);
|
|
1742
|
+
|
|
1743
|
+
const headerLeft = ` ${accent(this.theme.bold("docket · load"))} ${dim("pick a source")} `;
|
|
1744
|
+
this.container.addChild(new Text(fitBorder(headerLeft, "", innerWidth, outerBorder, TOP_CORNERS), 0, 0));
|
|
1745
|
+
|
|
1746
|
+
const tabCk = `[1] checkpoints (${this.checkpoints.length})`;
|
|
1747
|
+
const tabWk = `[2] workers (${this.workers.length})`;
|
|
1748
|
+
const tabLine = `${this.mode === "checkpoint" ? accent(this.theme.bold(tabCk)) : muted(tabCk)} ${this.mode === "worker" ? accent(this.theme.bold(tabWk)) : muted(tabWk)}`;
|
|
1749
|
+
this.container.addChild(new Text(tabLine, 1, 0));
|
|
1750
|
+
this.container.addChild(new DynamicBorder(dividerBorder));
|
|
1751
|
+
|
|
1752
|
+
const listWidth = Math.max(30, innerWidth);
|
|
1753
|
+
if (this.mode === "checkpoint") this.renderCheckpoints(listWidth, accent, dim, muted);
|
|
1754
|
+
else this.renderWorkers(listWidth, accent, dim, muted);
|
|
1755
|
+
|
|
1756
|
+
this.container.addChild(new DynamicBorder(dividerBorder));
|
|
1757
|
+
const help = this.mode === "checkpoint"
|
|
1758
|
+
? "j/k move · tab/1/2 switch · enter load · p preview · q close"
|
|
1759
|
+
: "j/k move · tab/1/2 switch · enter load · q close";
|
|
1760
|
+
this.container.addChild(new Text(dim(help), 1, 0));
|
|
1761
|
+
this.container.addChild(new Text(fitBorder("", "", innerWidth, outerBorder, BOTTOM_CORNERS), 0, 0));
|
|
1762
|
+
this.cachedLines = this.container.render(width);
|
|
1763
|
+
this.cachedWidth = width;
|
|
1764
|
+
return this.cachedLines;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
private renderCheckpoints(listWidth: number, accent: (s: string) => string, dim: (s: string) => string, muted: (s: string) => string): void {
|
|
1768
|
+
if (this.checkpoints.length === 0) {
|
|
1769
|
+
this.container.addChild(new Text(muted("no checkpoints — press 2 for workers"), 2, 0));
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
const start = Math.max(0, Math.min(this.checkpointIndex - 5, this.checkpoints.length - 11));
|
|
1773
|
+
const visible = this.checkpoints.slice(start, start + 11);
|
|
1774
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1775
|
+
const summary = visible[i];
|
|
1776
|
+
if (!summary) continue;
|
|
1777
|
+
const absolute = start + i;
|
|
1778
|
+
const entry = summary.entry;
|
|
1779
|
+
const selected = absolute === this.checkpointIndex;
|
|
1780
|
+
const marker = selected ? accent("▸") : dim(" ");
|
|
1781
|
+
const id = selected ? accent(this.theme.bold(entry.id.slice(0, 18).padEnd(18))) : muted(entry.id.slice(0, 18).padEnd(18));
|
|
1782
|
+
const mode = entry.consumedAt ? `${entry.mode}:consumed` : entry.consumeOnUse ? `${entry.mode}:once` : entry.mode;
|
|
1783
|
+
const stats = `${compactTokens(summary.estimatedTokens)} tok · ${summary.files} files`;
|
|
1784
|
+
const git = gitSnapshotLabel(entry.git);
|
|
1785
|
+
const meta = [stats, git].filter(Boolean).join(" · ");
|
|
1786
|
+
const line = `${marker} ${id} ${accent(mode.padEnd(14))} ${dim(relativeTime(Date.parse(entry.createdAt)).padEnd(9))} ${meta} ${muted(entry.note ?? "")}`;
|
|
1787
|
+
this.container.addChild(new Text(truncateToWidth(line, listWidth - 2), 1, 0));
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
private renderWorkers(listWidth: number, accent: (s: string) => string, dim: (s: string) => string, muted: (s: string) => string): void {
|
|
1792
|
+
if (this.workers.length === 0) {
|
|
1793
|
+
this.container.addChild(new Text(muted("no workers — /docket spawn <task>, then 2"), 2, 0));
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
const start = Math.max(0, Math.min(this.workerIndex - 5, this.workers.length - 11));
|
|
1797
|
+
const visible = this.workers.slice(start, start + 11);
|
|
1798
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1799
|
+
const worker = visible[i];
|
|
1800
|
+
if (!worker) continue;
|
|
1801
|
+
const absolute = start + i;
|
|
1802
|
+
const selected = absolute === this.workerIndex;
|
|
1803
|
+
const marker = selected ? accent("▸") : dim(" ");
|
|
1804
|
+
const label = workerShortLabel(worker.index).padEnd(4);
|
|
1805
|
+
const id = selected ? accent(this.theme.bold(label)) : muted(label);
|
|
1806
|
+
const stateColor = worker.state === "active" ? "success" : worker.state === "error" ? "error" : "muted";
|
|
1807
|
+
const state = this.theme.fg(stateColor, (worker.state ?? "?").padEnd(8));
|
|
1808
|
+
const artifacts = `${worker.artifactCount ?? "?"} art`.padEnd(8);
|
|
1809
|
+
const age = workerAge(worker.updatedAt).padEnd(8);
|
|
1810
|
+
const git = gitSnapshotLabel(worker.git);
|
|
1811
|
+
const summary = workerSummaryName(worker, 48);
|
|
1812
|
+
const line = `${marker} ${id} ${state} ${dim(artifacts)} ${dim(age)} ${git ? dim(`${git} `) : ""}${selected ? this.theme.bold(this.theme.fg("text", summary)) : muted(summary)}`;
|
|
1813
|
+
this.container.addChild(new Text(truncateToWidth(line, listWidth - 2), 1, 0));
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
async function showLoadPicker(ctx: ExtensionCommandContext, checkpoints: CheckpointSummary[], workers: WorkerStatus[], initialMode: LoadPickerMode): Promise<LoadPickerSelection> {
|
|
1819
|
+
return ctx.ui.custom<LoadPickerSelection>((tui, theme, _kb, done) => new DocketLoadPicker(tui, theme, checkpoints, workers, initialMode, done), {
|
|
1820
|
+
overlay: true,
|
|
1821
|
+
overlayOptions: { anchor: "center", width: "88%", minWidth: 84, maxHeight: "90%", margin: 1 },
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function renderArtifactList(artifacts: Artifact[]): string {
|
|
1826
|
+
if (artifacts.length === 0) return "No Docket artifacts";
|
|
1827
|
+
return artifacts.map((a) => `${a.displayId}\t${a.ref}\t${a.kind}\t${a.title}\t${a.subtitle}`).join("\n");
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const DOCKET_CHECKPOINT_CONTEXT_TYPE = "docket:checkpoint-context";
|
|
1831
|
+
const DOCKET_CHECKPOINT_WIDGET_ID = "docket-loaded-checkpoint";
|
|
1832
|
+
|
|
1833
|
+
type LoadedCheckpoint = {
|
|
1834
|
+
id: string;
|
|
1835
|
+
mode: CheckpointIndexEntry["mode"];
|
|
1836
|
+
note?: string;
|
|
1837
|
+
consumeOnUse?: boolean;
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
function checkpointContextContent(checkpoint: CheckpointIndexEntry, content: string): string {
|
|
1841
|
+
return [`<<docket-checkpoint ${checkpoint.id}>>`, content.trim(), `<</docket-checkpoint>>`].join("\n");
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function loadedCheckpointMeta(checkpoint: CheckpointIndexEntry): LoadedCheckpoint {
|
|
1845
|
+
return { id: checkpoint.id, mode: checkpoint.mode, note: checkpoint.note, consumeOnUse: checkpoint.consumeOnUse };
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function loadedCheckpointFromSession(ctx: ExtensionContext): LoadedCheckpoint | undefined {
|
|
1849
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1850
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
1851
|
+
const entry = branch[i] as any;
|
|
1852
|
+
if (entry?.type !== "custom_message" || entry.customType !== DOCKET_CHECKPOINT_CONTEXT_TYPE) continue;
|
|
1853
|
+
const details = entry.details as Partial<LoadedCheckpoint> | undefined;
|
|
1854
|
+
if (typeof details?.id === "string" && typeof details.mode === "string") return details as LoadedCheckpoint;
|
|
1855
|
+
}
|
|
1856
|
+
return undefined;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function setLoadedCheckpointWidget(ctx: ExtensionContext, checkpoint: LoadedCheckpoint | undefined): void {
|
|
1860
|
+
if (!ctx.hasUI) return;
|
|
1861
|
+
if (!checkpoint) {
|
|
1862
|
+
ctx.ui.setWidget(DOCKET_CHECKPOINT_WIDGET_ID, undefined);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
ctx.ui.setWidget(
|
|
1866
|
+
DOCKET_CHECKPOINT_WIDGET_ID,
|
|
1867
|
+
(_tui, theme) => {
|
|
1868
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
1869
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
1870
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
1871
|
+
const once = checkpoint.consumeOnUse ? muted("/once") : "";
|
|
1872
|
+
const note = checkpoint.note ? dim(` · ${truncateToWidth(checkpoint.note, 48)}`) : "";
|
|
1873
|
+
const container = new Container();
|
|
1874
|
+
container.addChild(new Text(`${accent(theme.bold("docket"))} ${dim("·")} ${accent(`@ckpt:${checkpoint.id}`)}${muted(`/${checkpoint.mode}`)}${once} ${dim("loaded in context")}${note}`, 0, 0));
|
|
1875
|
+
return container;
|
|
1876
|
+
},
|
|
1877
|
+
{ placement: "aboveEditor" },
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
async function startCheckpointSession(
|
|
1882
|
+
pi: ExtensionAPI,
|
|
1883
|
+
ctx: ExtensionCommandContext,
|
|
1884
|
+
checkpoint: CheckpointIndexEntry,
|
|
1885
|
+
content: string,
|
|
1886
|
+
queueConsume: (checkpoint: CheckpointIndexEntry) => void,
|
|
1887
|
+
): Promise<void> {
|
|
1888
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
1889
|
+
const checkpointMeta = loadedCheckpointMeta(checkpoint);
|
|
1890
|
+
const result = await ctx.newSession({
|
|
1891
|
+
parentSession,
|
|
1892
|
+
setup: async (sessionManager) => {
|
|
1893
|
+
sessionManager.appendCustomMessageEntry(DOCKET_CHECKPOINT_CONTEXT_TYPE, checkpointContextContent(checkpoint, content), false, checkpointMeta);
|
|
1894
|
+
},
|
|
1895
|
+
withSession: async (replacementCtx) => {
|
|
1896
|
+
setLoadedCheckpointWidget(replacementCtx, checkpointMeta);
|
|
1897
|
+
if (checkpoint.consumeOnUse) {
|
|
1898
|
+
queueConsume(checkpoint);
|
|
1899
|
+
replacementCtx.ui.notify(`Docket loaded checkpoint ${checkpoint.id} (consume on session end)`, "info");
|
|
1900
|
+
} else {
|
|
1901
|
+
replacementCtx.ui.notify(`Docket loaded checkpoint ${checkpoint.id}`, "info");
|
|
1902
|
+
}
|
|
1903
|
+
},
|
|
1904
|
+
});
|
|
1905
|
+
if (result.cancelled) notifyDocket(pi, ctx, "Docket continue cancelled", "info");
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
async function confirmDeleteCheckpoint(ctx: ExtensionCommandContext, checkpoint: CheckpointIndexEntry): Promise<boolean> {
|
|
1909
|
+
if (!ctx.hasUI) return true;
|
|
1910
|
+
return ctx.ui.confirm("Delete Docket checkpoint?", `Delete checkpoint ${checkpoint.id}? This cannot be undone.`);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
type QueueConsume = (checkpoint: CheckpointIndexEntry) => void;
|
|
1914
|
+
|
|
1915
|
+
type CompletionCandidate = { value: string; label: string };
|
|
1916
|
+
|
|
1917
|
+
async function checkpointAndWorkerCandidates(subcommand: string, projectRoot?: string): Promise<CompletionCandidate[]> {
|
|
1918
|
+
const workerOnly = subcommand === "tell" || subcommand === "verdict";
|
|
1919
|
+
const wantWorkers = subcommand === "load" || subcommand === "unload" || subcommand === "delete" || workerOnly;
|
|
1920
|
+
const wantCheckpoints = subcommand !== "unload" && !workerOnly;
|
|
1921
|
+
const out: CompletionCandidate[] = [];
|
|
1922
|
+
|
|
1923
|
+
if (wantCheckpoints) {
|
|
1924
|
+
try {
|
|
1925
|
+
const store = createCheckpointStore();
|
|
1926
|
+
const list = await store.list({ includeConsumed: true });
|
|
1927
|
+
const recent = list.slice(-10).reverse();
|
|
1928
|
+
if (recent.length > 0) out.push({ value: "last", label: `last → ${recent[0]!.id}` });
|
|
1929
|
+
for (const entry of recent) {
|
|
1930
|
+
const tag = entry.consumedAt ? ":consumed" : entry.consumeOnUse ? ":once" : "";
|
|
1931
|
+
out.push({ value: entry.id, label: `${entry.id} ${entry.mode}${tag} ${entry.note ?? ""}`.trim() });
|
|
1932
|
+
}
|
|
1933
|
+
} catch { /* ignore */ }
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
if (wantWorkers) out.push(...await workerCompletionCandidates(createWorkerStore(), { ...(projectRoot ? { projectRoot } : {}) }));
|
|
1937
|
+
|
|
1938
|
+
if (subcommand === "unload") out.unshift({ value: "all", label: "all drop every loaded slot" });
|
|
1939
|
+
|
|
1940
|
+
return out;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
type DocketMessageKind = "help" | "list" | "notice" | "action" | "success" | "warning" | "error" | "usage";
|
|
1944
|
+
|
|
1945
|
+
type DocketMessageDetails = { kind: DocketMessageKind; heading?: string; subject?: string; workerId?: string; docket?: { kind: ArtifactKind; title: string; subtitle?: string } };
|
|
1946
|
+
|
|
1947
|
+
const KIND_GLYPH: Record<DocketMessageKind, string> = {
|
|
1948
|
+
help: "?",
|
|
1949
|
+
list: "≡",
|
|
1950
|
+
notice: "·",
|
|
1951
|
+
action: "▸",
|
|
1952
|
+
success: "✓",
|
|
1953
|
+
warning: "!",
|
|
1954
|
+
error: "✗",
|
|
1955
|
+
usage: "?",
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1958
|
+
const KIND_COLOR: Record<DocketMessageKind, ThemeColor> = {
|
|
1959
|
+
help: "accent",
|
|
1960
|
+
list: "customMessageLabel",
|
|
1961
|
+
notice: "muted",
|
|
1962
|
+
action: "accent",
|
|
1963
|
+
success: "success",
|
|
1964
|
+
warning: "warning",
|
|
1965
|
+
usage: "warning",
|
|
1966
|
+
error: "error",
|
|
1967
|
+
};
|
|
1968
|
+
|
|
1969
|
+
function emitText(pi: ExtensionAPI, _ctx: ExtensionCommandContext, text: string, kind: DocketMessageKind = "notice", heading?: string, subject?: string): void {
|
|
1970
|
+
pi.sendMessage(
|
|
1971
|
+
{ customType: "docket", content: text, display: true, details: { kind, heading, subject } satisfies DocketMessageDetails },
|
|
1972
|
+
{ triggerTurn: false },
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function notifyDocket(pi: ExtensionAPI, ctx: ExtensionCommandContext, text: string, level: "info" | "warning" | "error" = "info"): void {
|
|
1977
|
+
if (ctx.hasUI) ctx.ui.notify(text, level);
|
|
1978
|
+
else pi.sendMessage({ customType: "docket", content: text, display: true, details: { kind: level === "error" ? "error" : "notice" } satisfies DocketMessageDetails }, { triggerTurn: false });
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function announceAction(pi: ExtensionAPI, _ctx: ExtensionCommandContext, subject: string, detail?: string, kind: DocketMessageKind = "action", docket?: DocketMessageDetails["docket"], meta: Pick<DocketMessageDetails, "workerId"> = {}): void {
|
|
1982
|
+
pi.sendMessage(
|
|
1983
|
+
{
|
|
1984
|
+
customType: "docket",
|
|
1985
|
+
content: detail ?? "",
|
|
1986
|
+
display: true,
|
|
1987
|
+
details: { kind, subject, heading: `docket · ${kind}`, ...(docket ? { docket } : {}), ...meta } satisfies DocketMessageDetails,
|
|
1988
|
+
},
|
|
1989
|
+
{ triggerTurn: false },
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function docketMessageRenderer(): MessageRenderer<DocketMessageDetails> {
|
|
1994
|
+
return (message, _options, theme) => {
|
|
1995
|
+
const details = (message.details ?? { kind: "notice" }) as DocketMessageDetails;
|
|
1996
|
+
const kind = details.kind ?? "notice";
|
|
1997
|
+
const labelColor: ThemeColor = KIND_COLOR[kind] ?? "muted";
|
|
1998
|
+
const glyph = KIND_GLYPH[kind] ?? "·";
|
|
1999
|
+
const headingText = details.heading ?? `docket · ${kind}`;
|
|
2000
|
+
let subject = details.subject;
|
|
2001
|
+
let content = typeof message.content === "string" ? message.content : "";
|
|
2002
|
+
const liveWorker = details.workerId ? readWorkerStatusSync(details.workerId) : undefined;
|
|
2003
|
+
if (liveWorker) {
|
|
2004
|
+
subject = workerLaunchSubject(liveWorker);
|
|
2005
|
+
content = workerLaunchDetail(liveWorker);
|
|
2006
|
+
}
|
|
2007
|
+
const box = new Box(1, 1, (s) => theme.bg("customMessageBg", s));
|
|
2008
|
+
|
|
2009
|
+
const accent = (s: string) => theme.fg(labelColor, s);
|
|
2010
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
2011
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
2012
|
+
|
|
2013
|
+
const headerLine = `${accent(theme.bold(`${glyph} ${headingText}`))}`;
|
|
2014
|
+
box.addChild(new Text(headerLine, 0, 0));
|
|
2015
|
+
|
|
2016
|
+
if (subject) {
|
|
2017
|
+
box.addChild(new Text(theme.bold(theme.fg("text", subject)), 0, 0));
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
if (content) {
|
|
2021
|
+
if (subject) box.addChild(new Text("", 0, 0));
|
|
2022
|
+
for (const rawLine of content.split("\n")) {
|
|
2023
|
+
let line: string;
|
|
2024
|
+
if (kind === "error") line = theme.fg("error", rawLine);
|
|
2025
|
+
else if (kind === "warning") line = theme.fg("warning", rawLine);
|
|
2026
|
+
else if (kind === "action" || kind === "success") line = muted(rawLine);
|
|
2027
|
+
else if (kind === "list") line = rawLine;
|
|
2028
|
+
else line = dim(rawLine);
|
|
2029
|
+
box.addChild(new Text(line, 0, 0));
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
return box;
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
export default function docketExtension(pi: ExtensionAPI) {
|
|
2037
|
+
let loadedCheckpoint: LoadedCheckpoint | undefined;
|
|
2038
|
+
let activeCtx: ExtensionContext | undefined;
|
|
2039
|
+
let sweptOnce = false;
|
|
2040
|
+
let heartbeatTimer: NodeJS.Timeout | undefined;
|
|
2041
|
+
let lastHeartbeatSignature: string | undefined;
|
|
2042
|
+
let workerDockUnwatch: Unwatcher | undefined;
|
|
2043
|
+
let workerDockCache: WorkerSnapshotCache | undefined;
|
|
2044
|
+
let workerDockPending = false;
|
|
2045
|
+
let workerDockRunning = false;
|
|
2046
|
+
let workerDockIdleHideMs = 0;
|
|
2047
|
+
let sessionProjectKey: string | undefined;
|
|
2048
|
+
let dockAnimTimer: NodeJS.Timeout | undefined;
|
|
2049
|
+
let dockTui: TUI | undefined;
|
|
2050
|
+
const stopDockAnimation = (): void => {
|
|
2051
|
+
if (dockAnimTimer) {
|
|
2052
|
+
clearInterval(dockAnimTimer);
|
|
2053
|
+
dockAnimTimer = undefined;
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
2056
|
+
// Only repaint on a steady cadence while a worker is actually working. With no active
|
|
2057
|
+
// workers the timer is cleared, so an idle dock costs nothing (preserves the 0%-idle promise).
|
|
2058
|
+
const syncDockAnimation = (hasActive: boolean): void => {
|
|
2059
|
+
if (hasActive && !dockAnimTimer) {
|
|
2060
|
+
dockAnimTimer = setInterval(() => dockTui?.requestRender(), DOCK_PULSE_INTERVAL_MS);
|
|
2061
|
+
dockAnimTimer.unref?.();
|
|
2062
|
+
} else if (!hasActive) {
|
|
2063
|
+
stopDockAnimation();
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
let workerAutoEmbedSummary = true;
|
|
2067
|
+
const workerReadyEmbedEmitted = new Set<string>();
|
|
2068
|
+
let workerResult: { worker: WorkerStatus; artifacts: Artifact[]; expanded: boolean } | undefined;
|
|
2069
|
+
let pinnedRefs = new Set<string>();
|
|
2070
|
+
let completedRefs = new Set<string>();
|
|
2071
|
+
const loadedArtifacts = createLoadedArtifactContext({
|
|
2072
|
+
readCheckpointArtifacts: async (checkpoint) => createCheckpointStore().readArtifacts(checkpoint),
|
|
2073
|
+
readWorkerArtifacts: readWorkerArtifactsForReview,
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
const queueShutdownConsume: QueueConsume = (checkpoint) => loadedArtifacts.queueCheckpointConsume(checkpoint);
|
|
2077
|
+
|
|
2078
|
+
const drainShutdownConsume = async (): Promise<void> => {
|
|
2079
|
+
const store = createCheckpointStore();
|
|
2080
|
+
await loadedArtifacts.drainCheckpointConsumes((checkpoint) => store.markConsumed(checkpoint));
|
|
2081
|
+
};
|
|
2082
|
+
|
|
2083
|
+
// Continue composes load: a continued session auto-mounts the checkpoint's bundle at zero token
|
|
2084
|
+
// cost, so the orientation header's artifact refs resolve via /docket ref. Survives restarts
|
|
2085
|
+
// because it keys off the checkpoint marker left in the session branch. See ADR-0001.
|
|
2086
|
+
const mountLoadedCheckpoint = async (id: string): Promise<void> => {
|
|
2087
|
+
try {
|
|
2088
|
+
const entry = await createCheckpointStore().find(id, { includeConsumed: true });
|
|
2089
|
+
if (entry) await loadedArtifacts.loadCheckpoint(entry);
|
|
2090
|
+
} catch { /* best-effort: continue still works without the mount */ }
|
|
2091
|
+
};
|
|
2092
|
+
|
|
2093
|
+
const maybeSweep = async (cwd: string): Promise<void> => {
|
|
2094
|
+
if (sweptOnce) return;
|
|
2095
|
+
sweptOnce = true;
|
|
2096
|
+
try {
|
|
2097
|
+
const config = await loadConfig(cwd);
|
|
2098
|
+
await createCheckpointStore().sweepConsumed(config.consumedRetentionDays);
|
|
2099
|
+
} catch { /* best-effort */ }
|
|
2100
|
+
await maybeSweepWorkers(cwd);
|
|
2101
|
+
};
|
|
2102
|
+
|
|
2103
|
+
const maybeSweepWorkers = async (cwd: string): Promise<void> => {
|
|
2104
|
+
try {
|
|
2105
|
+
const config = await loadConfig(cwd);
|
|
2106
|
+
const pruneMs = pruneAfterMs(config.worker);
|
|
2107
|
+
if (pruneMs <= 0) return;
|
|
2108
|
+
const store = createWorkerStore();
|
|
2109
|
+
const workers = await store.list();
|
|
2110
|
+
const targets = selectPrunableWorkers(workers, Date.now(), pruneMs);
|
|
2111
|
+
for (const worker of targets) {
|
|
2112
|
+
try { await store.purge(worker.id); } catch { /* best-effort */ }
|
|
2113
|
+
}
|
|
2114
|
+
} catch { /* best-effort */ }
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
const refreshWorkerResultWidget = (): void => {
|
|
2118
|
+
const ctx = activeCtx;
|
|
2119
|
+
if (!ctx?.hasUI) return;
|
|
2120
|
+
if (!workerResult) {
|
|
2121
|
+
ctx.ui.setWidget("docket-worker-result", undefined);
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const snapshot = workerResult;
|
|
2125
|
+
ctx.ui.setWidget(
|
|
2126
|
+
"docket-worker-result",
|
|
2127
|
+
(_tui, theme) => {
|
|
2128
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
2129
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
2130
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
2131
|
+
const success = (s: string) => theme.fg("success", s);
|
|
2132
|
+
const warning = (s: string) => theme.fg("warning", s);
|
|
2133
|
+
const errorColor = (s: string) => theme.fg("error", s);
|
|
2134
|
+
const text = (s: string) => theme.fg("text", s);
|
|
2135
|
+
const report = workerResultReport(snapshot.worker, snapshot.artifacts);
|
|
2136
|
+
const headline = workerResultHeadline(snapshot.worker, snapshot.artifacts, 78);
|
|
2137
|
+
const container = new Container();
|
|
2138
|
+
const stateColor = report.state === "failed" ? errorColor : report.state === "needs_input" ? warning : success;
|
|
2139
|
+
const headerLine = `${accent(theme.bold("docket"))} ${dim("·")} ${accent(report.label)} ${dim("·")} ${stateColor(report.stateLabel)} ${muted(headline)}`;
|
|
2140
|
+
container.addChild(new Text(headerLine, 0, 0));
|
|
2141
|
+
if (!snapshot.expanded) return container;
|
|
2142
|
+
const width = 110;
|
|
2143
|
+
const indent = 2;
|
|
2144
|
+
const factLine = (key: string, value: string) => `${muted(`${key}:`)} ${dim(value)}`;
|
|
2145
|
+
container.addChild(new Text(factLine("Task", report.taskLabel), indent, 0));
|
|
2146
|
+
container.addChild(new Text(factLine("Progress", report.progressLine), indent, 0));
|
|
2147
|
+
container.addChild(new Text(factLine("Changes", report.changesLine), indent, 0));
|
|
2148
|
+
const renderSection = (title: string, body: string) => {
|
|
2149
|
+
container.addChild(new Text("", indent, 0));
|
|
2150
|
+
container.addChild(new Text(accent(theme.bold(title)), indent, 0));
|
|
2151
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
2152
|
+
for (const line of wrapPlainText(raw, width - 4, 4)) container.addChild(new Text(text(line), indent + 2, 0));
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
const primaryTitle = report.primarySection === "question" ? "Question" : report.primarySection === "failure" ? "Failure" : "Outcome";
|
|
2156
|
+
renderSection(primaryTitle, report.primaryBody);
|
|
2157
|
+
if (report.recommendations.length > 0) {
|
|
2158
|
+
container.addChild(new Text("", indent, 0));
|
|
2159
|
+
container.addChild(new Text(accent(theme.bold("Recommendations")), indent, 0));
|
|
2160
|
+
for (let i = 0; i < report.recommendations.length; i++) {
|
|
2161
|
+
const bullet = `${i + 1}. ${report.recommendations[i]}`;
|
|
2162
|
+
for (const line of wrapPlainText(bullet, width - 4, 3)) container.addChild(new Text(text(line), indent + 2, 0));
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
if (report.references.length > 0) {
|
|
2166
|
+
container.addChild(new Text("", indent, 0));
|
|
2167
|
+
container.addChild(new Text(accent(theme.bold("Useful references")), indent, 0));
|
|
2168
|
+
for (const ref of report.references) {
|
|
2169
|
+
const id = accent(`@${ref.displayId}`);
|
|
2170
|
+
const tag = dim(`/${kindLabel(ref.kind)}`);
|
|
2171
|
+
const label = muted(ref.label);
|
|
2172
|
+
container.addChild(new Text(truncateToWidth(`${id}${tag} ${label}`, width - 4), indent + 2, 0));
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
return container;
|
|
2176
|
+
},
|
|
2177
|
+
{ placement: "aboveEditor" },
|
|
2178
|
+
);
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
const showWorkerResultWidget = (worker: WorkerStatus, artifacts: Artifact[], expanded: boolean): void => {
|
|
2182
|
+
workerResult = { worker, artifacts, expanded };
|
|
2183
|
+
refreshWorkerResultWidget();
|
|
2184
|
+
};
|
|
2185
|
+
|
|
2186
|
+
const clearWorkerResultWidget = (): boolean => {
|
|
2187
|
+
const had = workerResult !== undefined;
|
|
2188
|
+
workerResult = undefined;
|
|
2189
|
+
refreshWorkerResultWidget();
|
|
2190
|
+
return had;
|
|
2191
|
+
};
|
|
2192
|
+
|
|
2193
|
+
const refreshChipWidget = (): void => {
|
|
2194
|
+
const ctx = activeCtx;
|
|
2195
|
+
if (!ctx?.hasUI) return;
|
|
2196
|
+
const snapshot = loadedArtifacts.chips();
|
|
2197
|
+
if (snapshot.length === 0) {
|
|
2198
|
+
ctx.ui.setWidget("docket-chips", undefined);
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
ctx.ui.setWidget(
|
|
2202
|
+
"docket-chips",
|
|
2203
|
+
(_tui, theme) => {
|
|
2204
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
2205
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
2206
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
2207
|
+
const tags = snapshot
|
|
2208
|
+
.map((c) => `${accent(`@${c.displayId}${c.mode === "full" ? "*" : ""}`)}${muted(`/${kindLabel(c.kind)}`)}`)
|
|
2209
|
+
.join(" ");
|
|
2210
|
+
const label = accent(theme.bold("docket"));
|
|
2211
|
+
const summary = dim(`${snapshot.length === 1 ? "attached" : `${snapshot.length} attached`} · expands on send · /docket clear`);
|
|
2212
|
+
const container = new Container();
|
|
2213
|
+
container.addChild(new Text(`${label} ${dim("·")} ${tags} ${summary}`, 0, 0));
|
|
2214
|
+
return container;
|
|
2215
|
+
},
|
|
2216
|
+
{ placement: "aboveEditor" },
|
|
2217
|
+
);
|
|
2218
|
+
};
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
const announceChipChange = (ctx: ExtensionCommandContext, chip: Chip, result: ChipToggleResult): void => {
|
|
2222
|
+
const name = `@${chip.displayId}${chip.mode === "full" ? "*" : ""}`;
|
|
2223
|
+
const message =
|
|
2224
|
+
result === "added" ? `Docket attached ${name} · expands on send` :
|
|
2225
|
+
result === "removed" ? `Docket detached ${name}` :
|
|
2226
|
+
result === "upgraded" ? `Docket attached ${name} as full text` :
|
|
2227
|
+
`Docket attached ${name} as reference`;
|
|
2228
|
+
notifyDocket(pi, ctx, message, "info");
|
|
2229
|
+
};
|
|
2230
|
+
|
|
2231
|
+
pi.registerMessageRenderer("docket", docketMessageRenderer());
|
|
2232
|
+
|
|
2233
|
+
const workerId = process.env[DOCKET_WORKER_ENV];
|
|
2234
|
+
|
|
2235
|
+
const kindRegistry = createWorkerKindRegistry();
|
|
2236
|
+
let kindRegistryReloaded = false;
|
|
2237
|
+
const ensureKindRegistryLoaded = async (cwd: string): Promise<void> => {
|
|
2238
|
+
if (kindRegistryReloaded) return;
|
|
2239
|
+
kindRegistryReloaded = true;
|
|
2240
|
+
await kindRegistry.reload(cwd).catch(() => undefined);
|
|
2241
|
+
};
|
|
2242
|
+
const docketSurface: DocketExtensionSurfaceInternals = installDocketExtensionSurface(kindRegistry);
|
|
2243
|
+
|
|
2244
|
+
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
|
2245
|
+
const packagedGuardrails = path.join(extensionDir, "worker-guardrails.md");
|
|
2246
|
+
let guardrailsCache: { path?: string; text?: string } | undefined;
|
|
2247
|
+
async function loadWorkerGuardrails(cwd: string): Promise<string | undefined> {
|
|
2248
|
+
if (guardrailsCache !== undefined) return guardrailsCache.text;
|
|
2249
|
+
const config = await loadConfig(cwd).catch(() => undefined);
|
|
2250
|
+
const override = config?.worker?.guardrailsPath;
|
|
2251
|
+
const candidates = [override, packagedGuardrails].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
2252
|
+
for (const candidate of candidates) {
|
|
2253
|
+
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
|
2254
|
+
try {
|
|
2255
|
+
const text = await fs.readFile(resolved, "utf8");
|
|
2256
|
+
guardrailsCache = { path: resolved, text };
|
|
2257
|
+
return text;
|
|
2258
|
+
} catch {
|
|
2259
|
+
// try next candidate
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
guardrailsCache = {};
|
|
2263
|
+
return undefined;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
async function loadWorkerKindForCurrent(cwd: string): Promise<WorkerKind | undefined> {
|
|
2267
|
+
if (!workerId) return undefined;
|
|
2268
|
+
// Sync fallback first so worker can resolve its kind even before the async reload finishes.
|
|
2269
|
+
const sync = (kindRegistry as unknown as { _reloadSync?: (cwd: string) => void })._reloadSync;
|
|
2270
|
+
if (sync && !kindRegistryReloaded) sync(cwd);
|
|
2271
|
+
await ensureKindRegistryLoaded(cwd);
|
|
2272
|
+
const status = readWorkerStatusSync(workerId);
|
|
2273
|
+
if (!status?.kind) return undefined;
|
|
2274
|
+
return kindRegistry.get(status.kind);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
const updateTmuxStatusLine = (workers: WorkerStatus[]): void => {
|
|
2278
|
+
const counts = { needs_input: 0, ready: 0, failed: 0, active: 0 };
|
|
2279
|
+
const now = Date.now();
|
|
2280
|
+
for (const worker of workers) {
|
|
2281
|
+
const state = deriveWorkerState(worker, now);
|
|
2282
|
+
if (state === "needs_input") counts.needs_input++;
|
|
2283
|
+
else if (state === "ready" || state === "ready_open_todos") counts.ready++;
|
|
2284
|
+
else if (state === "failed") counts.failed++;
|
|
2285
|
+
else if (state === "thinking" || state === "starting") counts.active++;
|
|
2286
|
+
}
|
|
2287
|
+
const parts: string[] = [];
|
|
2288
|
+
if (counts.needs_input > 0) parts.push(`#[fg=yellow,bold]?${counts.needs_input}#[default]`);
|
|
2289
|
+
if (counts.failed > 0) parts.push(`#[fg=red,bold]✗${counts.failed}#[default]`);
|
|
2290
|
+
if (counts.ready > 0) parts.push(`#[fg=green]✓${counts.ready}#[default]`);
|
|
2291
|
+
if (counts.active > 0) parts.push(`#[fg=blue]●${counts.active}#[default]`);
|
|
2292
|
+
const line = parts.length > 0 ? `docket ${parts.join(" ")} ` : "docket · idle ";
|
|
2293
|
+
spawnSync("tmux", ["set-option", "-t", "docket-workers", "status-right", line], { stdio: "ignore" });
|
|
2294
|
+
spawnSync("tmux", ["set-option", "-t", "docket-workers", "status", "on"], { stdio: "ignore" });
|
|
2295
|
+
};
|
|
2296
|
+
|
|
2297
|
+
const reconcileOrphanedWorkers = async (workers: WorkerStatus[]): Promise<void> => {
|
|
2298
|
+
const ACTIVE_STATES: Array<WorkerStatus["state"]> = ["starting", "active", "idle", "needs_input"];
|
|
2299
|
+
const sharedTargets = workers.filter((w) => isSharedSessionTarget(w.tmuxSession) && ACTIVE_STATES.includes(w.state));
|
|
2300
|
+
if (sharedTargets.length === 0) return;
|
|
2301
|
+
if (sharedSessionExists()) return;
|
|
2302
|
+
const store = createWorkerStore();
|
|
2303
|
+
for (const worker of sharedTargets) {
|
|
2304
|
+
await store.patchStatus(worker.id, { state: "error", lastError: "tmux session ended; worker terminated" });
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
const refreshWorkerDockWidget = async (): Promise<void> => {
|
|
2309
|
+
const ctx = activeCtx;
|
|
2310
|
+
if (!ctx?.hasUI || workerId) return;
|
|
2311
|
+
if (workerDockRunning) {
|
|
2312
|
+
workerDockPending = true;
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
workerDockRunning = true;
|
|
2316
|
+
try {
|
|
2317
|
+
if (!workerDockCache) workerDockCache = new WorkerSnapshotCache(createWorkerStore().root());
|
|
2318
|
+
const { workers: allWorkers, artifactsByWorker, eventsByWorker, newEventsByWorker } = await workerDockCache.snapshot();
|
|
2319
|
+
for (const [id, events] of newEventsByWorker) {
|
|
2320
|
+
for (const ev of events) {
|
|
2321
|
+
docketSurface.emitWorkerEvent(id, ev);
|
|
2322
|
+
if (
|
|
2323
|
+
workerAutoEmbedSummary &&
|
|
2324
|
+
ev.kind === "state" &&
|
|
2325
|
+
(ev.payload?.state === "ready" || ev.payload?.state === "ready_open_todos") &&
|
|
2326
|
+
!workerReadyEmbedEmitted.has(id)
|
|
2327
|
+
) {
|
|
2328
|
+
const worker = allWorkers.find((w) => w.id === id);
|
|
2329
|
+
if (worker) {
|
|
2330
|
+
const embed = formatReadyEmbedMessage(worker);
|
|
2331
|
+
if (embed) {
|
|
2332
|
+
workerReadyEmbedEmitted.add(id);
|
|
2333
|
+
try {
|
|
2334
|
+
pi.sendMessage({
|
|
2335
|
+
customType: "docket",
|
|
2336
|
+
content: embed.content,
|
|
2337
|
+
display: true,
|
|
2338
|
+
details: {
|
|
2339
|
+
kind: "action",
|
|
2340
|
+
heading: embed.heading,
|
|
2341
|
+
subject: embed.subject,
|
|
2342
|
+
docket: { kind: "response", title: embed.title, subtitle: embed.subtitle },
|
|
2343
|
+
} as DocketMessageDetails & { docket: { kind: ArtifactKind; title: string; subtitle: string } },
|
|
2344
|
+
}, { triggerTurn: false });
|
|
2345
|
+
} catch {
|
|
2346
|
+
workerReadyEmbedEmitted.delete(id);
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
const tmuxStatusEnabled = await loadConfig(ctx.cwd).then((c) => c.worker?.tmuxStatusLine === true).catch(() => false);
|
|
2354
|
+
if (tmuxStatusEnabled && sharedSessionExists()) updateTmuxStatusLine(allWorkers);
|
|
2355
|
+
await reconcileOrphanedWorkers(allWorkers);
|
|
2356
|
+
const now = Date.now();
|
|
2357
|
+
const promptWorkers = allWorkers.filter((worker) => isPromptDockWorker(worker, now) && !isDockIdleEvictable(worker, now, workerDockIdleHideMs));
|
|
2358
|
+
const key = sessionProjectKey ?? projectKey(ctx.cwd);
|
|
2359
|
+
const workers = promptWorkers.filter((worker) => workerInProject(worker, key));
|
|
2360
|
+
const otherWorkers = promptWorkers.filter((worker) => !workerInProject(worker, key));
|
|
2361
|
+
const otherProjectCount = new Set(otherWorkers.map(workerProjectKey)).size;
|
|
2362
|
+
const otherWaiting = otherWorkers.filter((worker) => deriveWorkerState(worker, now) === "needs_input").length;
|
|
2363
|
+
const otherFailed = otherWorkers.filter((worker) => deriveWorkerState(worker, now) === "failed").length;
|
|
2364
|
+
const otherReady = otherWorkers.filter((worker) => { const derived = deriveWorkerState(worker, now); return derived === "ready" || derived === "ready_open_todos"; }).length;
|
|
2365
|
+
const otherAttentionLabel = [otherWaiting ? `${otherWaiting} waiting` : "", otherFailed ? `${otherFailed} failed` : "", otherReady ? `${otherReady} ready` : ""].filter(Boolean).join(" · ");
|
|
2366
|
+
if (workers.length === 0 && otherWorkers.length === 0) {
|
|
2367
|
+
stopDockAnimation();
|
|
2368
|
+
ctx.ui.setWidget("docket-workers", undefined);
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
const rows = workerActivityRows(workers, artifactsByWorker);
|
|
2372
|
+
const counts = workerActivityTotals(rows);
|
|
2373
|
+
const dockRows = dockRowsForRender(rows, { parentModelId: ctx.model?.id, eventsByWorker });
|
|
2374
|
+
syncDockAnimation(dockRows.some((row) => row.state === "thinking" || row.state === "starting"));
|
|
2375
|
+
const git = gitSnapshotLabel(readGitSnapshot(ctx.cwd));
|
|
2376
|
+
ctx.ui.setWidget(
|
|
2377
|
+
"docket-workers",
|
|
2378
|
+
(_tui, theme) => ({
|
|
2379
|
+
render(width: number): string[] {
|
|
2380
|
+
dockTui = _tui;
|
|
2381
|
+
const renderNow = Date.now();
|
|
2382
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
2383
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
2384
|
+
const attentionParts: string[] = [];
|
|
2385
|
+
if (counts.waiting) attentionParts.push(`${counts.waiting} waiting`);
|
|
2386
|
+
if (counts.failed) attentionParts.push(`${counts.failed} failed`);
|
|
2387
|
+
if (counts.readyOpenTodos) attentionParts.push(`${counts.readyOpenTodos} ready/todos`);
|
|
2388
|
+
if (counts.ready) attentionParts.push(`${counts.ready} ready`);
|
|
2389
|
+
const idle = counts.workers - counts.waiting - counts.failed - counts.ready - counts.readyOpenTodos;
|
|
2390
|
+
const idlePart = idle > 0 ? `${idle} ${idle === 1 ? "running" : "running"}` : "";
|
|
2391
|
+
const summary = counts.workers > 0 ? (attentionParts.length ? attentionParts.join(" · ") : idlePart || plural(counts.workers, "worker")) : "no workers in this project";
|
|
2392
|
+
const heading = `${accent(theme.bold("docket"))}${git ? ` ${dim("·")} ${dim(git)}` : ""} ${dim("·")} ${dim(summary)}`;
|
|
2393
|
+
const rowWidth = Math.min(width, 110);
|
|
2394
|
+
const breadcrumb = otherWorkers.length > 0 ? dim(`↗ ${otherAttentionLabel || `${otherWorkers.length} worker${otherWorkers.length === 1 ? "" : "s"}`} in ${otherProjectCount} other project${otherProjectCount === 1 ? "" : "s"} · /docket workers --all`) : undefined;
|
|
2395
|
+
return [
|
|
2396
|
+
truncateToWidth(heading, width, ""),
|
|
2397
|
+
...renderDockRows(theme, dockRows, rowWidth, renderNow),
|
|
2398
|
+
...(breadcrumb ? [truncateToWidth(breadcrumb, width, "")] : []),
|
|
2399
|
+
];
|
|
2400
|
+
},
|
|
2401
|
+
invalidate() {},
|
|
2402
|
+
}),
|
|
2403
|
+
{ placement: "aboveEditor" },
|
|
2404
|
+
);
|
|
2405
|
+
} catch {
|
|
2406
|
+
// best-effort dock; never disturb the session
|
|
2407
|
+
} finally {
|
|
2408
|
+
workerDockRunning = false;
|
|
2409
|
+
if (workerDockPending) {
|
|
2410
|
+
workerDockPending = false;
|
|
2411
|
+
void refreshWorkerDockWidget();
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
};
|
|
2415
|
+
|
|
2416
|
+
const emitWorkerStateArtifact = (_ctx: ExtensionContext, state: WorkerProtocolState, text?: string): void => {
|
|
2417
|
+
const message = workerProtocolMessage(state, text);
|
|
2418
|
+
pi.sendMessage({
|
|
2419
|
+
customType: "docket",
|
|
2420
|
+
content: message.content,
|
|
2421
|
+
display: true,
|
|
2422
|
+
details: {
|
|
2423
|
+
kind: message.messageKind,
|
|
2424
|
+
heading: "docket · worker",
|
|
2425
|
+
subject: message.subject,
|
|
2426
|
+
docket: { kind: message.artifactKind, title: message.title, subtitle: message.subtitle },
|
|
2427
|
+
} as DocketMessageDetails & { docket: { kind: ArtifactKind; title: string; subtitle: string } },
|
|
2428
|
+
}, { triggerTurn: false });
|
|
2429
|
+
};
|
|
2430
|
+
|
|
2431
|
+
const refreshWorkerCarryoverForReview = async (): Promise<void> => {
|
|
2432
|
+
if (workerId) return;
|
|
2433
|
+
try {
|
|
2434
|
+
const workers = await createWorkerStore().list({ ...(sessionProjectKey ? { projectRoot: sessionProjectKey } : {}) });
|
|
2435
|
+
await Promise.all(workers.map(async (worker) => {
|
|
2436
|
+
loadedArtifacts.unloadSource("worker", worker.id);
|
|
2437
|
+
await loadedArtifacts.loadSource({ kind: "worker", worker });
|
|
2438
|
+
}));
|
|
2439
|
+
} catch {
|
|
2440
|
+
// best-effort; the inbox should still open for current-session artifacts
|
|
2441
|
+
}
|
|
2442
|
+
};
|
|
2443
|
+
|
|
2444
|
+
const writeWorkerHeartbeat = async (ctx: ExtensionContext): Promise<void> => {
|
|
2445
|
+
if (!workerId) return;
|
|
2446
|
+
try {
|
|
2447
|
+
const config = await loadConfig(ctx.cwd);
|
|
2448
|
+
const catalog = createArtifactCatalog(ctx, config, []);
|
|
2449
|
+
const fullArtifacts = catalog.list();
|
|
2450
|
+
const capped = fullArtifacts.length > HEARTBEAT_ARTIFACT_CAP ? fullArtifacts.slice(-HEARTBEAT_ARTIFACT_CAP) : fullArtifacts;
|
|
2451
|
+
const signature = heartbeatArtifactSignature(capped);
|
|
2452
|
+
const workerStore = createWorkerStore();
|
|
2453
|
+
if (signature !== lastHeartbeatSignature) {
|
|
2454
|
+
await workerStore.writeArtifacts(workerId, capped);
|
|
2455
|
+
lastHeartbeatSignature = signature;
|
|
2456
|
+
}
|
|
2457
|
+
const current = await workerStore.find(workerId);
|
|
2458
|
+
await workerStore.patchStatus(workerId, {
|
|
2459
|
+
...workerHeartbeatPatch(current, {
|
|
2460
|
+
pid: process.pid,
|
|
2461
|
+
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
2462
|
+
artifactCount: fullArtifacts.length,
|
|
2463
|
+
}),
|
|
2464
|
+
...(ctx.model?.id ? { model: ctx.model.id } : {}),
|
|
2465
|
+
});
|
|
2466
|
+
} catch {
|
|
2467
|
+
// best-effort heartbeat; never crash the worker
|
|
2468
|
+
}
|
|
2469
|
+
};
|
|
2470
|
+
|
|
2471
|
+
const applyWorkerState = async (ctx: ExtensionContext, state: WorkerProtocolState, text?: string, doneInput?: WorkerDoneInput, questionMeta?: { risk?: string; options?: string[]; recommend?: string }): Promise<WorkerStatus | undefined> => {
|
|
2472
|
+
if (!workerId) return undefined;
|
|
2473
|
+
const store = createWorkerStore();
|
|
2474
|
+
const current = await store.find(workerId);
|
|
2475
|
+
if (!current) return undefined;
|
|
2476
|
+
let nextState = state;
|
|
2477
|
+
let nextText = text;
|
|
2478
|
+
let nextDoneInput = doneInput;
|
|
2479
|
+
if (state === "ready") {
|
|
2480
|
+
const config = await loadConfig(ctx.cwd);
|
|
2481
|
+
const artifacts = createArtifactCatalog(ctx, config, []).list();
|
|
2482
|
+
const question = workerDoneClarificationQuestion(current, doneInput ?? { summary: text }, { artifactEvidenceCount: artifacts.filter((artifact) => artifact.kind === "command" || artifact.kind === "file" || artifact.kind === "code").length });
|
|
2483
|
+
if (question) {
|
|
2484
|
+
nextState = "needs_input";
|
|
2485
|
+
nextText = question;
|
|
2486
|
+
nextDoneInput = undefined;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
const patch = workerProtocolPatch(current, nextState, nextText, {
|
|
2490
|
+
id: `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 6)}`,
|
|
2491
|
+
text: nextText ?? "",
|
|
2492
|
+
createdAt: new Date().toISOString(),
|
|
2493
|
+
...(nextState === "needs_input" && questionMeta ? questionMeta : {}),
|
|
2494
|
+
}, nextDoneInput);
|
|
2495
|
+
const updated = patch ? await store.patchStatus(workerId, patch) : current;
|
|
2496
|
+
emitWorkerStateArtifact(ctx, nextState, nextText);
|
|
2497
|
+
appendWorkerEventSync(store.root(), workerId, { kind: "state", payload: { state: nextState, ...(nextText ? { text: nextText } : {}) } });
|
|
2498
|
+
await writeWorkerHeartbeat(ctx);
|
|
2499
|
+
return updated;
|
|
2500
|
+
};
|
|
2501
|
+
|
|
2502
|
+
const applyWorkerTodos = async (ctx: ExtensionContext, items: WorkerTodoInput[]): Promise<WorkerStatus | undefined> => {
|
|
2503
|
+
if (!workerId) return undefined;
|
|
2504
|
+
const store = createWorkerStore();
|
|
2505
|
+
const current = await store.find(workerId);
|
|
2506
|
+
if (!current) return undefined;
|
|
2507
|
+
const updated = await store.patchStatus(workerId, workerTodosPatch(items));
|
|
2508
|
+
if (updated?.todos) {
|
|
2509
|
+
const progress = workerTodoProgress(updated);
|
|
2510
|
+
appendWorkerEventSync(store.root(), workerId, { kind: "todo", payload: { total: progress.total, completed: progress.completed, inProgress: progress.inProgress } });
|
|
2511
|
+
}
|
|
2512
|
+
await writeWorkerHeartbeat(ctx);
|
|
2513
|
+
return updated;
|
|
2514
|
+
};
|
|
2515
|
+
|
|
2516
|
+
if (workerId) {
|
|
2517
|
+
void loadWorkerGuardrails(activeCtx?.cwd ?? process.cwd());
|
|
2518
|
+
void ensureKindRegistryLoaded(activeCtx?.cwd ?? process.cwd());
|
|
2519
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
2520
|
+
const text = await loadWorkerGuardrails(ctx.cwd);
|
|
2521
|
+
if (!text) return;
|
|
2522
|
+
const kind = await loadWorkerKindForCurrent(ctx.cwd);
|
|
2523
|
+
const appendix = kind ? workerKindGuardrailsAppendix(kind) : "";
|
|
2524
|
+
return { systemPrompt: `${event.systemPrompt}\n\n<docket_worker_guardrails>\n${text.trim()}${appendix}\n</docket_worker_guardrails>` };
|
|
2525
|
+
});
|
|
2526
|
+
|
|
2527
|
+
let workerProtocolCalledThisTurn = false;
|
|
2528
|
+
let workerNudgesThisSession = 0;
|
|
2529
|
+
const MAX_WORKER_NUDGES = 1;
|
|
2530
|
+
const markWorkerProtocolCalled = (): void => { workerProtocolCalledThisTurn = true; };
|
|
2531
|
+
|
|
2532
|
+
pi.on("turn_start", () => {
|
|
2533
|
+
workerProtocolCalledThisTurn = false;
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
pi.on("agent_start", async () => {
|
|
2537
|
+
try {
|
|
2538
|
+
const store = createWorkerStore();
|
|
2539
|
+
const current = await store.find(workerId);
|
|
2540
|
+
if (current?.state === "idle") await store.patchStatus(workerId, { state: "active" });
|
|
2541
|
+
} catch { /* best-effort */ }
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
pi.on("agent_end", async () => {
|
|
2545
|
+
if (workerProtocolCalledThisTurn) return;
|
|
2546
|
+
try {
|
|
2547
|
+
const store = createWorkerStore();
|
|
2548
|
+
const current = await store.find(workerId);
|
|
2549
|
+
if (!current || current.state !== "active") return;
|
|
2550
|
+
await store.patchStatus(workerId, { state: "idle" });
|
|
2551
|
+
if (workerNudgesThisSession >= MAX_WORKER_NUDGES) return;
|
|
2552
|
+
workerNudgesThisSession++;
|
|
2553
|
+
pi.sendUserMessage("Docket: this turn ended without calling a protocol tool. If the task is complete with useful output, call `docket_done` with a summary (include a `Recommended:` bullet list if you have recommendations). If you are blocked or any non-trivial assumption is needed, call `docket_wait` with a concise question. If you cannot continue and have no useful partial output, call `docket_fail` with a one-sentence reason. Otherwise continue working.");
|
|
2554
|
+
} catch { /* best-effort */ }
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
pi.on("input", (event) => {
|
|
2558
|
+
if (event.source !== "extension") workerNudgesThisSession = 0;
|
|
2559
|
+
return { action: "continue" };
|
|
2560
|
+
});
|
|
2561
|
+
|
|
2562
|
+
pi.registerTool({
|
|
2563
|
+
name: "docket_todos",
|
|
2564
|
+
label: "Docket Todos",
|
|
2565
|
+
description: "Docket worker only: publish a small ordered progress checklist visible to the parent session.",
|
|
2566
|
+
promptSnippet: "Publish a small worker progress checklist for the parent dock/dashboard.",
|
|
2567
|
+
promptGuidelines: ["See <docket_worker_guardrails> for when to call docket_todos and how it differs from a durable task manager."],
|
|
2568
|
+
parameters: Type.Object({
|
|
2569
|
+
items: Type.Array(Type.Object({
|
|
2570
|
+
id: Type.Optional(Type.String({ description: "Stable short id for this item, if useful" })),
|
|
2571
|
+
text: Type.String({ description: "Short todo text" }),
|
|
2572
|
+
state: Type.Optional(StringEnum(["pending", "in_progress", "completed"] as const, { description: "Todo state" })),
|
|
2573
|
+
note: Type.Optional(Type.String({ description: "Optional short note, e.g. current blocker or substep" })),
|
|
2574
|
+
})),
|
|
2575
|
+
}),
|
|
2576
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2577
|
+
markWorkerProtocolCalled();
|
|
2578
|
+
const updated = await applyWorkerTodos(ctx, params.items as WorkerTodoInput[]);
|
|
2579
|
+
const progress = updated ? workerTodoProgress(updated) : { completed: 0, total: 0 };
|
|
2580
|
+
return { content: [{ type: "text", text: `Docket todos recorded (${progress.completed}/${progress.total}). Parent can see progress in the worker dock and /docket workers.` }], details: { todoCount: progress.total, completed: progress.completed } };
|
|
2581
|
+
},
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
pi.registerTool({
|
|
2585
|
+
name: "docket_wait",
|
|
2586
|
+
label: "Docket Wait",
|
|
2587
|
+
description: "Docket worker only: ask the parent session for input and mark this worker waiting.",
|
|
2588
|
+
promptSnippet: "Ask parent for input when a Docket worker is blocked or ambiguity is non-trivial.",
|
|
2589
|
+
promptGuidelines: ["See <docket_worker_guardrails> for when to call docket_wait. When the decision has discrete answers, pass concrete `options` (and `recommend` your pick) and flag stakes via `risk`. Do not assume; do not run /docket wait via bash."],
|
|
2590
|
+
parameters: Type.Object({
|
|
2591
|
+
question: Type.String({ description: "Concise question for the parent session" }),
|
|
2592
|
+
risk: Type.Optional(Type.String({ description: "One line on the stakes when this is irreversible or unauthorized (e.g. 'drops the sessions table'). Rendered as a warning on the parent's card." })),
|
|
2593
|
+
options: Type.Optional(Type.Array(Type.String({ description: "A concrete choice the parent can pick" }), { description: "2–4 concrete options the parent can accept directly; the chosen one is sent back to you verbatim. Omit for open-ended questions." })),
|
|
2594
|
+
recommend: Type.Optional(Type.String({ description: "Which option you would choose (must match one of `options`); pre-selected on the parent's card." })),
|
|
2595
|
+
}),
|
|
2596
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2597
|
+
markWorkerProtocolCalled();
|
|
2598
|
+
const options = Array.isArray(params.options) ? params.options.map((option) => String(option).trim()).filter((option) => option.length > 0) : [];
|
|
2599
|
+
const questionMeta = {
|
|
2600
|
+
...(typeof params.risk === "string" && params.risk.trim() ? { risk: params.risk.trim() } : {}),
|
|
2601
|
+
...(options.length ? { options } : {}),
|
|
2602
|
+
...(typeof params.recommend === "string" && params.recommend.trim() ? { recommend: params.recommend.trim() } : {}),
|
|
2603
|
+
};
|
|
2604
|
+
await applyWorkerState(ctx, "needs_input", params.question, undefined, questionMeta);
|
|
2605
|
+
return { content: [{ type: "text", text: workerProtocolResultText("needs_input") }], details: { state: "needs_input", question: params.question, ...questionMeta } };
|
|
2606
|
+
},
|
|
2607
|
+
});
|
|
2608
|
+
|
|
2609
|
+
pi.registerTool({
|
|
2610
|
+
name: "docket_done",
|
|
2611
|
+
label: "Docket Done",
|
|
2612
|
+
description: "Docket worker only: mark this worker's useful output ready for parent review. Provide outcome, concise summary, evidence, and optional recommendations.",
|
|
2613
|
+
promptSnippet: "Mark Docket worker output ready for parent review with outcome, summary, and evidence.",
|
|
2614
|
+
promptGuidelines: ["See <docket_worker_guardrails> for outcome/evidence requirements and when to use docket_done vs docket_wait vs docket_fail. Do not run /docket done via bash."],
|
|
2615
|
+
parameters: Type.Object({
|
|
2616
|
+
outcome: Type.Optional(StringEnum(["completed", "findings", "proposal", "no_evidence"] as const, { description: "Best description of the result" })),
|
|
2617
|
+
summary: Type.Optional(Type.String({ description: "Concise summary of completed worker output" })),
|
|
2618
|
+
evidence: Type.Optional(Type.Array(Type.String({ description: "Short evidence item, e.g. searched path, file changed, command result, artifact ref" }))),
|
|
2619
|
+
recommended: Type.Optional(Type.Array(Type.String({ description: "Short action-oriented recommendation for the parent card" }))),
|
|
2620
|
+
scopeConfidence: Type.Optional(StringEnum(["clear", "unclear"] as const, { description: "Whether the original task scope was clear enough to finish without more parent input" })),
|
|
2621
|
+
}),
|
|
2622
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2623
|
+
markWorkerProtocolCalled();
|
|
2624
|
+
const done = params as WorkerDoneInput;
|
|
2625
|
+
const updated = await applyWorkerState(ctx, "ready", done.summary, done);
|
|
2626
|
+
const progress = updated ? workerTodoProgress(updated) : { completed: 0, total: 0 };
|
|
2627
|
+
const open = Math.max(0, progress.total - progress.completed);
|
|
2628
|
+
if (updated?.state === "needs_input") {
|
|
2629
|
+
return { content: [{ type: "text", text: "Docket did not accept done; marked waiting. Stop now and wait for parent reply." }], details: { state: "needs_input", question: updated.question } };
|
|
2630
|
+
}
|
|
2631
|
+
const warning = open > 0 ? ` Docket marked ready/open-todos (${progress.completed}/${progress.total}); call docket_todos again if those items are actually complete.` : "";
|
|
2632
|
+
return { content: [{ type: "text", text: `${workerProtocolResultText("ready")}${warning}` }], details: { state: open > 0 ? "ready_open_todos" : "ready", summary: updated?.summary ?? done.summary, outcome: done.outcome, evidence: done.evidence, recommended: done.recommended, todoCount: progress.total, todoOpenCount: open } };
|
|
2633
|
+
},
|
|
2634
|
+
});
|
|
2635
|
+
|
|
2636
|
+
pi.registerTool({
|
|
2637
|
+
name: "docket_fail",
|
|
2638
|
+
label: "Docket Fail",
|
|
2639
|
+
description: "Docket worker only: mark this worker failed with a one-sentence reason. Use only when no partial output is useful; prefer docket_done with notes when partial output exists.",
|
|
2640
|
+
promptSnippet: "Mark a Docket worker failed when it cannot continue and has no useful partial output.",
|
|
2641
|
+
promptGuidelines: ["See <docket_worker_guardrails> for when to use docket_fail vs docket_done vs docket_wait. Do not run /docket fail via bash."],
|
|
2642
|
+
parameters: Type.Object({ reason: Type.String({ description: "Reason this worker cannot continue" }) }),
|
|
2643
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2644
|
+
markWorkerProtocolCalled();
|
|
2645
|
+
await applyWorkerState(ctx, "failed", params.reason);
|
|
2646
|
+
return { content: [{ type: "text", text: workerProtocolResultText("failed") }], details: { state: "failed", reason: params.reason } };
|
|
2647
|
+
},
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2650
|
+
// Only expose docket_spawn_child when current worker's kind allows it.
|
|
2651
|
+
// We probe synchronously via status.json + the sync kind-registry fallback so the
|
|
2652
|
+
// tool registration decision happens before the worker's first turn starts.
|
|
2653
|
+
(() => {
|
|
2654
|
+
const status = readWorkerStatusSync(workerId);
|
|
2655
|
+
if (!status) return;
|
|
2656
|
+
const cwd = activeCtx?.cwd ?? process.cwd();
|
|
2657
|
+
const syncReload = (kindRegistry as unknown as { _reloadSync?: (cwd: string) => void })._reloadSync;
|
|
2658
|
+
if (syncReload && !kindRegistryReloaded) syncReload(cwd);
|
|
2659
|
+
const kind = status.kind ? kindRegistry.get(status.kind) : kindRegistry.get(undefined);
|
|
2660
|
+
const allowed = (status.canSpawn ?? kind.canSpawn ?? []).filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
2661
|
+
if (allowed.length === 0) return;
|
|
2662
|
+
const allowedList = allowed.join(", ");
|
|
2663
|
+
pi.registerTool({
|
|
2664
|
+
name: "docket_spawn_child",
|
|
2665
|
+
label: "Docket Spawn Child",
|
|
2666
|
+
description: `Docket worker only: dispatch a child Docket worker. Allowed child kinds for this worker: ${allowedList}. Child runs in a sibling tmux window inside the shared docket-workers session; child docket_done returns here, not to the human user.`,
|
|
2667
|
+
promptSnippet: `Dispatch a child Docket worker (allowed kinds: ${allowedList}).`,
|
|
2668
|
+
promptGuidelines: [
|
|
2669
|
+
"Use child workers sparingly. A child consumes a worker slot and a tmux window.",
|
|
2670
|
+
"Only spawn when the parent's context truly lacks the information you need; otherwise grep/read here.",
|
|
2671
|
+
"Child outcome will arrive in your inbox as a worker artifact under its short label (e.g. wN).",
|
|
2672
|
+
],
|
|
2673
|
+
parameters: Type.Object({
|
|
2674
|
+
kind: StringEnum(allowed as unknown as readonly [string, ...string[]], { description: "Child kind to dispatch" }),
|
|
2675
|
+
task: Type.String({ description: "Concrete task description for the child. Be specific; the child inherits no extra context beyond its kind's system prompt and your seeded parent session." }),
|
|
2676
|
+
}),
|
|
2677
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2678
|
+
markWorkerProtocolCalled();
|
|
2679
|
+
const store = createWorkerStore();
|
|
2680
|
+
const current = await store.find(workerId);
|
|
2681
|
+
if (!current) return { content: [{ type: "text", text: "Docket: cannot spawn child — current worker status missing." }], details: { error: "no-status" } };
|
|
2682
|
+
await ensureKindRegistryLoaded(ctx.cwd);
|
|
2683
|
+
const config = await loadConfig(ctx.cwd).catch(() => undefined);
|
|
2684
|
+
const maxActive = typeof config?.worker?.maxActive === "number" ? config.worker.maxActive : 8;
|
|
2685
|
+
const maxDepth = typeof config?.worker?.maxSpawnDepth === "number" ? config.worker.maxSpawnDepth : 2;
|
|
2686
|
+
const currentDepth = current.depth ?? 0;
|
|
2687
|
+
if (currentDepth + 1 > maxDepth) {
|
|
2688
|
+
return { content: [{ type: "text", text: `Docket: spawn-depth cap reached (${currentDepth + 1} > ${maxDepth}). Use docket_wait to ask the parent to dispatch instead.` }], details: { error: "max-depth", currentDepth, maxDepth } };
|
|
2689
|
+
}
|
|
2690
|
+
if (maxActive > 0) {
|
|
2691
|
+
const active = await store.countActive();
|
|
2692
|
+
if (active >= maxActive) {
|
|
2693
|
+
return { content: [{ type: "text", text: `Docket: fleet cap reached (${active}/${maxActive}). Cannot spawn child right now.` }], details: { error: "max-active", active, maxActive } };
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
const requestedKind = (params as { kind: string }).kind;
|
|
2697
|
+
if (!allowed.includes(requestedKind)) {
|
|
2698
|
+
return { content: [{ type: "text", text: `Docket: kind "${requestedKind}" not in allowlist (${allowedList}).` }], details: { error: "not-allowed" } };
|
|
2699
|
+
}
|
|
2700
|
+
const childKind = kindRegistry.get(requestedKind);
|
|
2701
|
+
const taskText = ((params as { task: string }).task ?? "").trim();
|
|
2702
|
+
if (!taskText) return { content: [{ type: "text", text: "Docket: child task is empty." }], details: { error: "empty-task" } };
|
|
2703
|
+
try {
|
|
2704
|
+
const child = await store.spawn({
|
|
2705
|
+
task: taskText,
|
|
2706
|
+
cwd: current.cwd,
|
|
2707
|
+
...(current.sessionFile ? { parentSession: current.sessionFile } : {}),
|
|
2708
|
+
worktree: childKind.defaultWorktree,
|
|
2709
|
+
kind: childKind.name,
|
|
2710
|
+
...(childKind.canSpawn.length > 0 ? { canSpawn: childKind.canSpawn } : {}),
|
|
2711
|
+
parentWorkerId: current.id,
|
|
2712
|
+
depth: currentDepth + 1,
|
|
2713
|
+
layout: childKind.layout,
|
|
2714
|
+
});
|
|
2715
|
+
appendWorkerEventSync(store.root(), current.id, { kind: "message", payload: { event: "spawn-child", childId: child.id, childIndex: child.index, kind: childKind.name } });
|
|
2716
|
+
return { content: [{ type: "text", text: `Docket: dispatched child ${workerShortLabel(child.index)} (kind: ${childKind.name}). Their docket_done will surface in your inbox.` }], details: { childId: child.id, childIndex: child.index, kind: childKind.name } };
|
|
2717
|
+
} catch (err) {
|
|
2718
|
+
return { content: [{ type: "text", text: `Docket: child spawn failed: ${String(err)}` }], details: { error: "spawn-failed", message: String(err) } };
|
|
2719
|
+
}
|
|
2720
|
+
},
|
|
2721
|
+
});
|
|
2722
|
+
})();
|
|
2723
|
+
|
|
2724
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
2725
|
+
if (workerId) {
|
|
2726
|
+
const target = toolEventTarget(event);
|
|
2727
|
+
appendWorkerEventSync(createWorkerStore().root(), workerId, { kind: "tool", payload: { tool: event.toolName, when: "call", ...(target ? { target } : {}) } });
|
|
2728
|
+
}
|
|
2729
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
2730
|
+
const intent = parseDocketWorkerShellCommand(event.input.command);
|
|
2731
|
+
if (!intent) return;
|
|
2732
|
+
markWorkerProtocolCalled();
|
|
2733
|
+
await applyWorkerState(ctx, intent.state, intent.text);
|
|
2734
|
+
event.input.command = `printf '%s\n' ${shellSingleQuote(workerProtocolResultText(intent.state))}`;
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
pi.on("session_start", (_event, ctx) => {
|
|
2739
|
+
activeCtx = ctx;
|
|
2740
|
+
sessionProjectKey = projectKey(ctx.cwd);
|
|
2741
|
+
pinnedRefs = new Set();
|
|
2742
|
+
completedRefs = new Set();
|
|
2743
|
+
loadedArtifacts.reset();
|
|
2744
|
+
workerResult = undefined;
|
|
2745
|
+
loadedCheckpoint = loadedCheckpointFromSession(ctx);
|
|
2746
|
+
if (ctx.hasUI) {
|
|
2747
|
+
ctx.ui.setWidget("docket-chips", undefined);
|
|
2748
|
+
ctx.ui.setWidget("docket-worker-result", undefined);
|
|
2749
|
+
}
|
|
2750
|
+
setLoadedCheckpointWidget(ctx, loadedCheckpoint);
|
|
2751
|
+
if (loadedCheckpoint) void mountLoadedCheckpoint(loadedCheckpoint.id);
|
|
2752
|
+
void maybeSweep(ctx.cwd);
|
|
2753
|
+
if (workerId) {
|
|
2754
|
+
void writeWorkerHeartbeat(ctx);
|
|
2755
|
+
heartbeatTimer = setInterval(() => void writeWorkerHeartbeat(ctx), 15000);
|
|
2756
|
+
heartbeatTimer.unref?.();
|
|
2757
|
+
} else if (ctx.hasUI) {
|
|
2758
|
+
const root = createWorkerStore().root();
|
|
2759
|
+
workerDockCache = new WorkerSnapshotCache(root);
|
|
2760
|
+
workerDockIdleHideMs = 0;
|
|
2761
|
+
workerReadyEmbedEmitted.clear();
|
|
2762
|
+
void loadConfig(ctx.cwd).then((config) => {
|
|
2763
|
+
workerDockIdleHideMs = dockIdleHideMs(config.worker);
|
|
2764
|
+
workerAutoEmbedSummary = config.worker?.autoEmbedSummary !== false;
|
|
2765
|
+
void refreshWorkerDockWidget();
|
|
2766
|
+
}).catch(() => undefined);
|
|
2767
|
+
workerDockUnwatch = watchWorkersRoot(root, () => void refreshWorkerDockWidget());
|
|
2768
|
+
}
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
2772
|
+
if (heartbeatTimer) {
|
|
2773
|
+
clearInterval(heartbeatTimer);
|
|
2774
|
+
heartbeatTimer = undefined;
|
|
2775
|
+
}
|
|
2776
|
+
if (workerDockUnwatch) {
|
|
2777
|
+
workerDockUnwatch();
|
|
2778
|
+
workerDockUnwatch = undefined;
|
|
2779
|
+
}
|
|
2780
|
+
stopDockAnimation();
|
|
2781
|
+
dockTui = undefined;
|
|
2782
|
+
workerDockCache = undefined;
|
|
2783
|
+
workerDockPending = false;
|
|
2784
|
+
workerDockRunning = false;
|
|
2785
|
+
workerDockIdleHideMs = 0;
|
|
2786
|
+
sessionProjectKey = undefined;
|
|
2787
|
+
if (workerId) {
|
|
2788
|
+
try { await createWorkerStore().patchStatus(workerId, { state: "ended" }); } catch { /* best-effort */ }
|
|
2789
|
+
}
|
|
2790
|
+
await drainShutdownConsume();
|
|
2791
|
+
activeCtx = undefined;
|
|
2792
|
+
pinnedRefs = new Set();
|
|
2793
|
+
completedRefs = new Set();
|
|
2794
|
+
loadedArtifacts.reset();
|
|
2795
|
+
workerResult = undefined;
|
|
2796
|
+
loadedCheckpoint = undefined;
|
|
2797
|
+
if (ctx.hasUI) {
|
|
2798
|
+
ctx.ui.setWidget(DOCKET_CHECKPOINT_WIDGET_ID, undefined);
|
|
2799
|
+
ctx.ui.setWidget("docket-worker-result", undefined);
|
|
2800
|
+
ctx.ui.setWidget("docket-workers", undefined);
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
pi.on("input", async (event, ctx) => {
|
|
2805
|
+
if (event.source === "extension") return { action: "continue" };
|
|
2806
|
+
if (loadedCheckpoint) {
|
|
2807
|
+
loadedCheckpoint = undefined;
|
|
2808
|
+
setLoadedCheckpointWidget(ctx, undefined);
|
|
2809
|
+
}
|
|
2810
|
+
if (loadedArtifacts.chips().length === 0) return { action: "continue" };
|
|
2811
|
+
const result = await loadedArtifacts.expandChipsForSubmit(ctx, event.text);
|
|
2812
|
+
if (result.expanded === 0 && result.missing.length === 0) return { action: "continue" };
|
|
2813
|
+
if (result.missing.length > 0 && ctx.hasUI) {
|
|
2814
|
+
ctx.ui.notify(`Docket dropped stale chip(s): ${result.missing.join(", ")}`, "warning");
|
|
2815
|
+
}
|
|
2816
|
+
loadedArtifacts.clearChips();
|
|
2817
|
+
workerResult = undefined;
|
|
2818
|
+
refreshChipWidget();
|
|
2819
|
+
refreshWorkerResultWidget();
|
|
2820
|
+
if (result.expanded === 0) return { action: "continue" };
|
|
2821
|
+
return { action: "transform", text: result.text };
|
|
2822
|
+
});
|
|
2823
|
+
|
|
2824
|
+
pi.registerCommand("docket", {
|
|
2825
|
+
description: "Inspect unresolved agent work and save/load evidence bundles",
|
|
2826
|
+
getArgumentCompletions: async (prefix: string) => {
|
|
2827
|
+
const trimmed = prefix.replace(/^\s+/, "");
|
|
2828
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
2829
|
+
if (firstSpace === -1) {
|
|
2830
|
+
const items = DOCKET_COMMANDS.filter((c) => c.startsWith(trimmed)).map((c) => ({ value: c, label: c }));
|
|
2831
|
+
return items.length ? items : null;
|
|
2832
|
+
}
|
|
2833
|
+
const subcommand = trimmed.slice(0, firstSpace);
|
|
2834
|
+
const rest = trimmed.slice(firstSpace + 1);
|
|
2835
|
+
if (subcommand === "load" || subcommand === "unload" || subcommand === "delete" || subcommand === "tell" || subcommand === "verdict") {
|
|
2836
|
+
const lastSpace = rest.lastIndexOf(" ");
|
|
2837
|
+
const partial = lastSpace === -1 ? rest : rest.slice(lastSpace + 1);
|
|
2838
|
+
const completed = lastSpace === -1 ? "" : `${rest.slice(0, lastSpace + 1)}`;
|
|
2839
|
+
const candidates = await checkpointAndWorkerCandidates(subcommand, activeCtx ? projectKey(activeCtx.cwd) : undefined);
|
|
2840
|
+
const matches = candidates.filter((c) => c.value.toLowerCase().startsWith(partial.toLowerCase()));
|
|
2841
|
+
const items = matches.map((c) => ({ value: `${subcommand} ${completed}${c.value}`, label: c.label }));
|
|
2842
|
+
return items.length ? items : null;
|
|
2843
|
+
}
|
|
2844
|
+
return null;
|
|
2845
|
+
},
|
|
2846
|
+
handler: (args, ctx) => runDocketCommand(args, ctx),
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2849
|
+
// One-key path to the highest-attention decision. Only ever fires the verdict intent,
|
|
2850
|
+
// which uses base-context members (ui/store/sessionManager) — safe to upcast the shortcut ctx.
|
|
2851
|
+
pi.registerShortcut?.("ctrl+shift+d", {
|
|
2852
|
+
description: "Docket: resolve the top worker decision",
|
|
2853
|
+
handler: (ctx) => runDocketCommand("verdict", ctx as ExtensionCommandContext),
|
|
2854
|
+
});
|
|
2855
|
+
|
|
2856
|
+
async function runDocketCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
2857
|
+
const parsed = parseDocketCommand(args);
|
|
2858
|
+
if (!parsed.ok) {
|
|
2859
|
+
emitText(pi, ctx, `${parsed.message}\n\n${parsed.usage}`, "usage", "docket · usage");
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
const intent = parsed.intent;
|
|
2864
|
+
const workerStore = createWorkerStore();
|
|
2865
|
+
const checkpointStore = createCheckpointStore();
|
|
2866
|
+
await ensureKindRegistryLoaded(ctx.cwd);
|
|
2867
|
+
const docketConfig = await loadConfig(ctx.cwd).catch(() => undefined);
|
|
2868
|
+
const maxActive = typeof docketConfig?.worker?.maxActive === "number" ? docketConfig.worker.maxActive : 8;
|
|
2869
|
+
const captureTerminal = docketConfig?.worker?.captureTerminal === true;
|
|
2870
|
+
const workerCommands = createWorkerCommands({
|
|
2871
|
+
store: workerStore,
|
|
2872
|
+
loadedArtifacts,
|
|
2873
|
+
cwd: ctx.cwd,
|
|
2874
|
+
projectRoot: sessionProjectKey ?? projectKey(ctx.cwd),
|
|
2875
|
+
...(ctx.sessionManager.getSessionFile?.() ? { parentSession: ctx.sessionManager.getSessionFile() } : {}),
|
|
2876
|
+
kinds: kindRegistry,
|
|
2877
|
+
maxActive: () => maxActive,
|
|
2878
|
+
captureTerminal: () => captureTerminal,
|
|
2879
|
+
notify: (text, level) => notifyDocket(pi, ctx, text, level),
|
|
2880
|
+
announce: (subject, detail, kind, docket, meta) => announceAction(pi, ctx, subject, detail, kind, docket, meta),
|
|
2881
|
+
emitText: (text, kind, heading) => emitText(pi, ctx, text, kind, heading),
|
|
2882
|
+
});
|
|
2883
|
+
const checkpointCommands = createCheckpointCommands({
|
|
2884
|
+
store: checkpointStore,
|
|
2885
|
+
hasUI: ctx.hasUI,
|
|
2886
|
+
notify: (text, level) => notifyDocket(pi, ctx, text, level),
|
|
2887
|
+
emitText: (text, kind, heading) => emitText(pi, ctx, text, kind, heading),
|
|
2888
|
+
confirmDelete: (checkpoint) => confirmDeleteCheckpoint(ctx, checkpoint),
|
|
2889
|
+
selectCheckpoint: (summaries, selected, mode) => showCheckpointResumeSelector(ctx, summaries, selected, mode),
|
|
2890
|
+
showText: (title, text) => showTextViewer(ctx, title, text),
|
|
2891
|
+
editText: (title, text) => ctx.hasUI ? ctx.ui.editor(title, text) : Promise.resolve(undefined),
|
|
2892
|
+
startSession: (checkpoint, content) => startCheckpointSession(pi, ctx, checkpoint, content, queueShutdownConsume),
|
|
2893
|
+
});
|
|
2894
|
+
await createDocketCommandRouter({
|
|
2895
|
+
hasUI: ctx.hasUI,
|
|
2896
|
+
workerId,
|
|
2897
|
+
projectRoot: sessionProjectKey ?? projectKey(ctx.cwd),
|
|
2898
|
+
workerCommands,
|
|
2899
|
+
checkpointCommands,
|
|
2900
|
+
loadedArtifacts,
|
|
2901
|
+
workerStore,
|
|
2902
|
+
checkpointStore,
|
|
2903
|
+
notify: (text, level) => notifyDocket(pi, ctx, text, level),
|
|
2904
|
+
emitText: (text, kind, heading) => emitText(pi, ctx, text, kind, heading),
|
|
2905
|
+
announce: (subject, detail, kind) => announceAction(pi, ctx, subject, detail, kind),
|
|
2906
|
+
docketUsage,
|
|
2907
|
+
renderArtifactList,
|
|
2908
|
+
renderParallelWorkList,
|
|
2909
|
+
formatArtifact,
|
|
2910
|
+
refreshChipWidget,
|
|
2911
|
+
refreshWorkerDockWidget,
|
|
2912
|
+
refreshWorkerCarryoverForReview,
|
|
2913
|
+
showWorkerResult: showWorkerResultWidget,
|
|
2914
|
+
clearWorkerResult: clearWorkerResultWidget,
|
|
2915
|
+
markArtifactDone: (artifact) => completedRefs.add(artifact.ref),
|
|
2916
|
+
promoteWorkerChangeSet: async (artifact) => {
|
|
2917
|
+
const workerIdValue = typeof artifact.meta?.workerId === "string" ? artifact.meta.workerId : undefined;
|
|
2918
|
+
const worker = workerIdValue ? await workerStore.find(workerIdValue) : undefined;
|
|
2919
|
+
if (!worker) {
|
|
2920
|
+
notifyDocket(pi, ctx, "Docket worker not found for change set", "error");
|
|
2921
|
+
return false;
|
|
2922
|
+
}
|
|
2923
|
+
let result = promoteWorkerChangeSet(worker, ctx.cwd);
|
|
2924
|
+
if (!result.ok && result.needsConfirmation && ctx.hasUI) {
|
|
2925
|
+
const ok = await ctx.ui.confirm("Promote worker changes?", `${result.message}\n\n${artifact.title}`);
|
|
2926
|
+
if (!ok) return false;
|
|
2927
|
+
result = promoteWorkerChangeSet(worker, ctx.cwd, { force: true });
|
|
2928
|
+
}
|
|
2929
|
+
notifyDocket(pi, ctx, result.ok ? `${result.message} Stop the worker to free its workspace.` : result.message, result.ok ? "info" : result.needsConfirmation ? "warning" : "error");
|
|
2930
|
+
if (result.ok) await refreshWorkerDockWidget();
|
|
2931
|
+
return result.ok;
|
|
2932
|
+
},
|
|
2933
|
+
applyWorkerState: async (state, text) => { await applyWorkerState(ctx, state, text); },
|
|
2934
|
+
createCheckpoint: async (options) => {
|
|
2935
|
+
const checkpointLifecycle = await createCheckpointLifecycle(pi, ctx);
|
|
2936
|
+
await checkpointLifecycle.create(options);
|
|
2937
|
+
},
|
|
2938
|
+
createHandoffCheckpoint: async () => {
|
|
2939
|
+
const checkpointLifecycle = await createCheckpointLifecycle(pi, ctx);
|
|
2940
|
+
await checkpointLifecycle.create({ note: "", consumeOnUse: false, summarize: false });
|
|
2941
|
+
},
|
|
2942
|
+
catalog: async () => {
|
|
2943
|
+
const config = await loadConfig(ctx.cwd);
|
|
2944
|
+
return createArtifactCatalog(ctx, config, loadedArtifacts.carryoverArtifacts());
|
|
2945
|
+
},
|
|
2946
|
+
readWorkersWithArtifacts: (options) => readWorkersWithArtifacts(workerStore, options?.allProjects ? undefined : sessionProjectKey ?? projectKey(ctx.cwd)),
|
|
2947
|
+
showParallelWorkDashboard: (workers, artifactsByWorker, options) => showParallelWorkDashboard(ctx, workers, artifactsByWorker, options?.groupByProject === true),
|
|
2948
|
+
showLoadPicker: (summaries, workers, initialMode) => showLoadPicker(ctx, summaries, workers, initialMode),
|
|
2949
|
+
showText: (title, text) => showTextViewer(ctx, title, text),
|
|
2950
|
+
showDocketBrowser: (catalog, artifacts, initialMode) => showDocketBrowser(ctx, catalog, artifacts, pinnedRefs, completedRefs, initialMode),
|
|
2951
|
+
showVerdict: (worker, remaining) => showWorkerVerdict(ctx, worker, remaining),
|
|
2952
|
+
showArtifact: (catalog, artifact) => showArtifactViewer(ctx, catalog, artifact),
|
|
2953
|
+
openFileOrArtifact: async (catalog, artifact) => {
|
|
2954
|
+
const filePath = artifactFilePath(artifact, ctx.cwd);
|
|
2955
|
+
if (filePath) await showFileViewer(ctx, filePath);
|
|
2956
|
+
else await showArtifactViewer(ctx, catalog, artifact);
|
|
2957
|
+
},
|
|
2958
|
+
input: (title, placeholder) => ctx.hasUI ? ctx.ui.input(title, placeholder) : Promise.resolve(undefined),
|
|
2959
|
+
confirmDeleteWorker: (worker) => ctx.hasUI ? ctx.ui.confirm("Stop Docket worker?", `Stop ${workerSourceLabel(worker)} and remove its workspace? This cannot be undone.`) : Promise.resolve(true),
|
|
2960
|
+
copyText: copyToClipboard,
|
|
2961
|
+
announceChipChange: (artifact, mode, result) => announceChipChange(ctx, { displayId: artifact.displayId, ref: artifact.ref, mode, kind: artifact.kind, title: artifact.title }, result),
|
|
2962
|
+
parallelKindLabel,
|
|
2963
|
+
}).handle(intent);
|
|
2964
|
+
}
|
|
2965
|
+
}
|