ytdb-slate 0.1.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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Role-guideline prompt docs (config: orchestratorPromptDocs / workerPromptDocs).
3
+ *
4
+ * Projects may split role-specific guidance into per-role markdown docs
5
+ * that get injected into the matching role's system prompt only —
6
+ * orchestrator docs while orchestrator mode is on (mode.ts), worker docs
7
+ * into every worker session (worker.ts). Each role's always-loaded surface
8
+ * then carries only what that role needs (context-as-RAM discipline, P10).
9
+ * Both lists default to EMPTY, and callers gate the reads behind project
10
+ * trust — an untrusted project never gets its files injected into prompts.
11
+ * Missing/unreadable files and malformed config entries are skipped
12
+ * silently, so a configured setup works before the docs exist.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { relative, resolve } from "node:path";
17
+
18
+ /**
19
+ * Read each doc (relative paths resolved against cwd) and return one
20
+ * formatted prompt block per file that exists; missing/unreadable/empty
21
+ * files and non-string entries are skipped without error. Blocks carry NO
22
+ * separators — each caller joins/prefixes for its own prompt shape.
23
+ */
24
+ export function loadPromptDocs(cwd: string, paths: string[]): string[] {
25
+ // The declared type is a runtime lie: callers pass values straight from
26
+ // user-edited slate.json, so a whole-value shape error (e.g.
27
+ // orchestratorPromptDocs: 42, or a string — which for...of would iterate
28
+ // char by char) reaches this loop unchecked. Malformed config must never
29
+ // break prompt assembly, so bail out to "no docs" instead of throwing.
30
+ if (!Array.isArray(paths)) return [];
31
+ const blocks: string[] = [];
32
+ for (const path of paths) {
33
+ // The whole per-entry body is guarded: paths come from user-edited
34
+ // slate.json, and a throw escaping here (e.g. resolve() on a
35
+ // non-string entry) would abort the caller's prompt hook — on the
36
+ // orchestrator path that drops the ENTIRE systemPrompt modification
37
+ // for the turn, doctrine included.
38
+ try {
39
+ if (typeof path !== "string") continue;
40
+ const file = resolve(cwd, path);
41
+ const content = readFileSync(file, "utf8").trim();
42
+ if (!content) continue;
43
+ blocks.push(`# Role guidelines (injected from ${relative(cwd, file)})\n\n${content}`);
44
+ } catch {
45
+ /* missing/unreadable doc → skip */
46
+ }
47
+ }
48
+ return blocks;
49
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * TUI rendering for the `thread` tool (ExecPlan M3).
3
+ *
4
+ * Collapsed: one-line header (status icon, thread, task preview), streaming
5
+ * progress lines while running, Key Findings digest + usage when done.
6
+ * Expanded (Ctrl+O): full episode markdown + usage stats.
7
+ */
8
+
9
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
10
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
11
+ import type { UsageStats } from "./threads.ts";
12
+
13
+ // Minimal structural theme type (avoids depending on exact Theme export).
14
+ type ThemeLike = {
15
+ fg: (color: string, text: string) => string;
16
+ bold: (text: string) => string;
17
+ };
18
+
19
+ interface ThreadCallArgs {
20
+ thread?: string;
21
+ name?: string;
22
+ task?: string;
23
+ context?: string[];
24
+ model?: string;
25
+ }
26
+
27
+ interface ThreadDetails {
28
+ threadId?: string;
29
+ threadName?: string;
30
+ episodeId?: string;
31
+ status?: "ok" | "failed";
32
+ lines?: string[];
33
+ usage?: UsageStats;
34
+ done?: boolean;
35
+ }
36
+
37
+ function formatTokens(n: number): string {
38
+ if (n < 1000) return String(n);
39
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
40
+ return `${(n / 1_000_000).toFixed(1)}M`;
41
+ }
42
+
43
+ function formatUsage(u: UsageStats | undefined): string {
44
+ if (!u) return "";
45
+ const parts: string[] = [];
46
+ if (u.turns) parts.push(`${u.turns} turn${u.turns > 1 ? "s" : ""}`);
47
+ if (u.input) parts.push(`↑${formatTokens(u.input)}`);
48
+ if (u.output) parts.push(`↓${formatTokens(u.output)}`);
49
+ if (u.cost) parts.push(`$${u.cost.toFixed(4)}`);
50
+ if (u.contextTokens) parts.push(`ctx:${formatTokens(u.contextTokens)}`);
51
+ return parts.join(" ");
52
+ }
53
+
54
+ function contentText(result: { content?: Array<{ type: string; text?: string }> }): string {
55
+ return (result.content ?? [])
56
+ .filter((c) => c.type === "text")
57
+ .map((c) => c.text ?? "")
58
+ .join("\n");
59
+ }
60
+
61
+ /** Extract one `## Section` body from episode markdown. */
62
+ function extractSection(episode: string, section: string): string {
63
+ const re = new RegExp(`^## ${section}\\s*$`, "m");
64
+ const m = re.exec(episode);
65
+ if (!m) return "";
66
+ const rest = episode.slice(m.index + m[0].length);
67
+ const next = rest.search(/^## /m);
68
+ return (next >= 0 ? rest.slice(0, next) : rest).trim();
69
+ }
70
+
71
+ export function renderThreadCall(args: ThreadCallArgs, theme: ThemeLike) {
72
+ let text = theme.fg("toolTitle", theme.bold("thread "));
73
+ text += theme.fg("accent", args.thread ?? (args.name ? `new:"${args.name}"` : "new"));
74
+ if (args.model) text += theme.fg("muted", ` [${args.model}]`);
75
+ if (args.context && args.context.length > 0) text += theme.fg("muted", ` ⇐ ${args.context.join(", ")}`);
76
+ const task = (args.task ?? "").replace(/\s+/g, " ");
77
+ text += `\n ${theme.fg("dim", task.length > 100 ? `${task.slice(0, 100)}...` : task)}`;
78
+ return new Text(text, 0, 0);
79
+ }
80
+
81
+ export function renderThreadResult(
82
+ result: { content?: Array<{ type: string; text?: string }>; details?: unknown },
83
+ options: { expanded: boolean; isPartial?: boolean },
84
+ theme: ThemeLike,
85
+ ) {
86
+ const details = (result.details ?? {}) as ThreadDetails;
87
+ const name = details.threadName ?? details.threadId ?? "thread";
88
+
89
+ // Streaming: show live progress lines.
90
+ if (options.isPartial || !details.done) {
91
+ const lines = (details.lines ?? []).slice(-6);
92
+ let text = `${theme.fg("warning", "⏳")} ${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("muted", "running")}`;
93
+ for (const line of lines) {
94
+ text += `\n ${theme.fg("dim", line.length > 110 ? `${line.slice(0, 110)}...` : line)}`;
95
+ }
96
+ return new Text(text, 0, 0);
97
+ }
98
+
99
+ const failed = details.status === "failed";
100
+ const icon = failed ? theme.fg("error", "✗") : theme.fg("success", "✓");
101
+ const episodeLabel = `${details.episodeId ?? "episode"}${failed ? " FAILED" : ""}`;
102
+ const usageStr = formatUsage(details.usage);
103
+ const full = contentText(result);
104
+
105
+ if (options.expanded) {
106
+ const container = new Container();
107
+ container.addChild(
108
+ new Text(
109
+ `${icon} ${theme.fg("toolTitle", theme.bold(name))} ${theme.fg(failed ? "error" : "accent", episodeLabel)}`,
110
+ 0,
111
+ 0,
112
+ ),
113
+ );
114
+ container.addChild(new Spacer(1));
115
+ container.addChild(new Markdown(full.trim(), 0, 0, getMarkdownTheme()));
116
+ if (usageStr) {
117
+ container.addChild(new Spacer(1));
118
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
119
+ }
120
+ return container;
121
+ }
122
+
123
+ // Collapsed: headline + Key Findings digest (or Open Issues when failed).
124
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(name))} ${theme.fg(failed ? "error" : "accent", episodeLabel)}`;
125
+ const digestSection = failed ? "Open Issues" : "Key Findings";
126
+ const digest = extractSection(full, digestSection);
127
+ if (digest) {
128
+ const digestLines = digest.split("\n").slice(0, 8);
129
+ text += `\n${theme.fg("muted", `— ${digestSection}:`)}`;
130
+ for (const line of digestLines) {
131
+ text += `\n ${theme.fg("toolOutput", line.length > 110 ? `${line.slice(0, 110)}...` : line)}`;
132
+ }
133
+ if (digest.split("\n").length > 8) text += `\n ${theme.fg("muted", "…")}`;
134
+ }
135
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
136
+ text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`;
137
+ return new Text(text, 0, 0);
138
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Slate state: thread/episode records, session-scoped persistence.
3
+ *
4
+ * Persistence model (ExecPlan D9): every mutation appends a full snapshot as a
5
+ * custom session entry ("slate-state") via pi.appendEntry. On session_start the
6
+ * store rebuilds from the LAST such entry on the current branch, so state
7
+ * follows pi's session tree across restart/resume/fork. Thread session files
8
+ * and episode files live on disk under <config dir>/slate/ and are validated
9
+ * on restore.
10
+ */
11
+
12
+ import { existsSync } from "node:fs";
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ export interface ThreadRecord {
16
+ id: string; // "t1", "t2", ...
17
+ name: string;
18
+ sessionFile: string; // absolute path to worker .jsonl ("" until first dispatch completes session creation)
19
+ status: "idle" | "running";
20
+ model?: string; // "provider/id" when overridden at creation
21
+ episodeIds: string[];
22
+ episodeSeq: number; // monotonic per-thread episode counter
23
+ createdAt: number;
24
+ updatedAt: number;
25
+ }
26
+
27
+ export interface EpisodeRecord {
28
+ id: string; // "t1.e2"
29
+ threadId: string;
30
+ task: string;
31
+ status: "ok" | "failed";
32
+ file: string; // absolute path to episode .md
33
+ createdAt: number;
34
+ }
35
+
36
+ export interface SlateSnapshot {
37
+ threads: ThreadRecord[];
38
+ episodes: EpisodeRecord[];
39
+ orchestratorMode: boolean;
40
+ paused: boolean;
41
+ workerCostUsd: number;
42
+ carriedCostUsd: number; // orchestrator spend banked from ancestor sessions at handoff
43
+ }
44
+
45
+ export interface SlateConfig {
46
+ episodeModel?: string; // "provider/id" for the episode compressor (D5)
47
+ workerTools?: string[];
48
+ maxConcurrent?: number;
49
+ pauseThresholdPercent?: number; // orchestrator context budget for auto-pause (default 40)
50
+ orchestratorModeDefault?: boolean; // seed orchestrator mode ON for fresh interactive sessions (unsaved until first real mutation)
51
+ orchestratorPromptDocs?: string[]; // role-guideline docs appended to the orchestrator prompt (cwd-relative paths, default none)
52
+ workerPromptDocs?: string[]; // role-guideline docs appended to worker system prompts (cwd-relative paths, default none)
53
+ workflow?: { draftPRs?: boolean }; // draftPRs: umbrella draft PRs are part of the pre-implementation gates (default false)
54
+ doctrineExtraPath?: string; // cwd-relative markdown appended to the orchestrator doctrine (project-doctrine section)
55
+ reviewPerspectivesPath?: string; // cwd-relative markdown with additional project-specific review perspectives
56
+ }
57
+
58
+ export class SlateStore {
59
+ threads = new Map<string, ThreadRecord>();
60
+ episodes = new Map<string, EpisodeRecord>();
61
+ orchestratorMode = false;
62
+ /** When true (context budget exceeded) ThreadManager rejects NEW dispatches. */
63
+ paused = false;
64
+ /**
65
+ * Cumulative USD spend of worker threads this session. Includes the episode
66
+ * compressor's LLM calls, so the "workers" figure shown in the widget covers
67
+ * compression spend too.
68
+ */
69
+ workerCostUsd = 0;
70
+ /** Orchestrator spend inherited from ancestor sessions across handoffs. */
71
+ carriedCostUsd = 0;
72
+ /** Invoked after every save/restore; used by mode.ts to refresh the widget. */
73
+ onDidChange?: () => void;
74
+
75
+ constructor(private pi: ExtensionAPI) {}
76
+
77
+ nextThreadId(): string {
78
+ let max = 0;
79
+ for (const id of this.threads.keys()) {
80
+ const m = /^t(\d+)$/.exec(id);
81
+ if (m) max = Math.max(max, Number(m[1]));
82
+ }
83
+ return `t${max + 1}`;
84
+ }
85
+
86
+ snapshot(): SlateSnapshot {
87
+ return {
88
+ threads: [...this.threads.values()].map((t) => ({ ...t, status: "idle" as const })),
89
+ episodes: [...this.episodes.values()],
90
+ orchestratorMode: this.orchestratorMode,
91
+ paused: this.paused,
92
+ workerCostUsd: this.workerCostUsd,
93
+ carriedCostUsd: this.carriedCostUsd,
94
+ };
95
+ }
96
+
97
+ save(): void {
98
+ this.pi.appendEntry("slate-state", this.snapshot() as unknown as Record<string, unknown>);
99
+ this.onDidChange?.();
100
+ }
101
+
102
+ /** Rebuild from the last slate-state entry on the current branch. */
103
+ restore(ctx: ExtensionContext): void {
104
+ let latest: SlateSnapshot | undefined;
105
+ for (const entry of ctx.sessionManager.getBranch()) {
106
+ const e = entry as { type: string; customType?: string; data?: unknown };
107
+ if (e.type === "custom" && e.customType === "slate-state" && e.data) {
108
+ latest = e.data as SlateSnapshot;
109
+ }
110
+ }
111
+ this.adoptSnapshot(latest, ctx);
112
+ // Cost counters are NOT branch-scoped like the records above: money never
113
+ // un-spends, and dispatches on now-abandoned branches were still billed.
114
+ // Take the MAX over ALL slate-state entries (both counters are monotonic
115
+ // within a session file) so a branch switch cannot roll them back.
116
+ for (const entry of ctx.sessionManager.getEntries()) {
117
+ const e = entry as {
118
+ type: string;
119
+ customType?: string;
120
+ data?: { workerCostUsd?: number; carriedCostUsd?: number };
121
+ };
122
+ if (e.type !== "custom" || e.customType !== "slate-state") continue;
123
+ this.workerCostUsd = Math.max(this.workerCostUsd, e.data?.workerCostUsd ?? 0);
124
+ this.carriedCostUsd = Math.max(this.carriedCostUsd, e.data?.carriedCostUsd ?? 0);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Replace all state with a snapshot (undefined clears), dropping records
130
+ * whose files vanished. Shared by restore() and the cross-session handoff
131
+ * adoption in handoff.ts.
132
+ */
133
+ adoptSnapshot(latest: SlateSnapshot | undefined, ctx: ExtensionContext): void {
134
+ this.threads.clear();
135
+ this.episodes.clear();
136
+ this.orchestratorMode = false;
137
+ this.paused = false;
138
+ this.workerCostUsd = 0;
139
+ this.carriedCostUsd = 0;
140
+ if (!latest) return;
141
+
142
+ this.orchestratorMode = latest.orchestratorMode ?? false;
143
+ this.paused = latest.paused ?? false;
144
+ // ?? 0: old snapshots lack the cost fields.
145
+ this.workerCostUsd = latest.workerCostUsd ?? 0;
146
+ this.carriedCostUsd = latest.carriedCostUsd ?? 0;
147
+ const dropped: string[] = [];
148
+ for (const t of latest.threads ?? []) {
149
+ if (t.sessionFile && !existsSync(t.sessionFile)) {
150
+ dropped.push(`thread ${t.id} (${t.name}): missing ${t.sessionFile}`);
151
+ continue;
152
+ }
153
+ this.threads.set(t.id, { ...t, status: "idle" });
154
+ }
155
+ for (const e of latest.episodes ?? []) {
156
+ if (!existsSync(e.file)) {
157
+ dropped.push(`episode ${e.id}: missing ${e.file}`);
158
+ continue;
159
+ }
160
+ if (!this.threads.has(e.threadId)) continue;
161
+ this.episodes.set(e.id, e);
162
+ }
163
+ // Prune episode ids that did not survive.
164
+ for (const t of this.threads.values()) {
165
+ t.episodeIds = t.episodeIds.filter((id) => this.episodes.has(id));
166
+ }
167
+ if (dropped.length > 0 && ctx.hasUI) {
168
+ ctx.ui.notify(`slate: dropped stale records:\n${dropped.join("\n")}`, "warning");
169
+ }
170
+ this.onDidChange?.();
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Orchestrator spend recorded in the session file: billed LINEAGE spend —
176
+ * summed over ALL entries including abandoned branches (forked/cloned sessions
177
+ * thus inherit parent-file spend as their own). EXCLUDES pi-internal LLM calls
178
+ * stored as non-message entries (compaction, branch summarization).
179
+ * Shared by the widget (mode.ts) and handoff carry (handoff.ts).
180
+ */
181
+ export function orchestratorCostUsd(ctx: ExtensionContext): number {
182
+ let cost = 0;
183
+ for (const entry of ctx.sessionManager.getEntries()) {
184
+ // Loose cast + optional chaining: tolerate malformed/legacy entries.
185
+ const e = entry as { type: string; message?: { role?: string; usage?: { cost?: { total?: number } } } };
186
+ if (e.type === "message" && e.message?.role === "assistant") {
187
+ cost += e.message.usage?.cost?.total ?? 0;
188
+ }
189
+ }
190
+ return cost;
191
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * ThreadManager: dispatch lifecycle (ExecPlan M1).
3
+ *
4
+ * One dispatch = one bounded action on one thread:
5
+ * resolve/create thread → per-thread FIFO queue → global semaphore →
6
+ * open/reuse worker session → inject context episodes → prompt →
7
+ * compress the new messages into an episode (ok or FAILED) → update store.
8
+ *
9
+ * Parallelism happens ACROSS threads (pi runs sibling tool calls
10
+ * concurrently); a single thread is a serial work stream (D9).
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { compressEpisode } from "./episodes.ts";
16
+ import type { EpisodeRecord, SlateConfig, SlateStore, ThreadRecord } from "./state.ts";
17
+ import { openWorkerSession, type WorkerSession } from "./worker.ts";
18
+
19
+ export interface DispatchOptions {
20
+ threadId?: string;
21
+ name?: string;
22
+ task: string;
23
+ contextEpisodeIds?: string[];
24
+ model?: string;
25
+ tools?: string[];
26
+ }
27
+
28
+ export interface UsageStats {
29
+ turns: number;
30
+ input: number;
31
+ output: number;
32
+ cost: number;
33
+ contextTokens: number;
34
+ }
35
+
36
+ export interface DispatchProgress {
37
+ threadId: string;
38
+ threadName: string;
39
+ lines: string[];
40
+ usage: UsageStats;
41
+ done: boolean;
42
+ status?: "ok" | "failed";
43
+ }
44
+
45
+ export interface DispatchResult {
46
+ episodeText: string;
47
+ episode: EpisodeRecord;
48
+ thread: ThreadRecord;
49
+ usage: UsageStats;
50
+ }
51
+
52
+ class Semaphore {
53
+ private waiters: Array<() => void> = [];
54
+ private active = 0;
55
+ constructor(private limit: number) {}
56
+ async acquire(): Promise<void> {
57
+ if (this.active < this.limit) {
58
+ this.active++;
59
+ return;
60
+ }
61
+ // Wait for a slot transferred directly by release(); do NOT increment
62
+ // here — the releasing side keeps `active` unchanged when handing over.
63
+ await new Promise<void>((r) => this.waiters.push(r));
64
+ }
65
+ release(): void {
66
+ const next = this.waiters.shift();
67
+ if (next) {
68
+ // Transfer the slot to the next waiter without decrementing:
69
+ // the slot never becomes observable as free.
70
+ next();
71
+ } else {
72
+ this.active--;
73
+ }
74
+ }
75
+ }
76
+
77
+ export class ThreadManager {
78
+ private live = new Map<string, WorkerSession>();
79
+ private queues = new Map<string, Promise<unknown>>();
80
+ private semaphore: Semaphore;
81
+
82
+ constructor(
83
+ private store: SlateStore,
84
+ private config: SlateConfig,
85
+ ) {
86
+ this.semaphore = new Semaphore(config.maxConcurrent ?? 4);
87
+ }
88
+
89
+ getConfig(): SlateConfig {
90
+ return this.config;
91
+ }
92
+
93
+ async dispatch(
94
+ opts: DispatchOptions,
95
+ ctx: ExtensionContext,
96
+ signal: AbortSignal | undefined,
97
+ onProgress?: (p: DispatchProgress) => void,
98
+ ): Promise<DispatchResult> {
99
+ // Pause blocks only NEW dispatches — in-flight ones already hold their
100
+ // queue slot and are allowed to finish (their episodes still get written).
101
+ if (this.store.paused) {
102
+ throw new Error(
103
+ "Slate is paused for handoff: the context budget was exceeded and new dispatches are rejected. " +
104
+ "Reply to the user with a handoff brief (overall goal, per-thread state with episode ids, immediate next actions) " +
105
+ "and ask them to run /slate handoff [focus] to continue in a fresh session.",
106
+ );
107
+ }
108
+ const thread = this.resolveThread(opts);
109
+
110
+ // Per-thread FIFO: chain onto the previous dispatch for this thread.
111
+ const prev = this.queues.get(thread.id) ?? Promise.resolve();
112
+ const run = prev
113
+ .catch(() => undefined) // a failed predecessor must not poison the queue
114
+ .then(() => this.runDispatch(thread, opts, ctx, signal, onProgress));
115
+ this.queues.set(thread.id, run);
116
+ return run;
117
+ }
118
+
119
+ private resolveThread(opts: DispatchOptions): ThreadRecord {
120
+ if (opts.threadId) {
121
+ const existing = this.store.threads.get(opts.threadId);
122
+ if (!existing) {
123
+ const known = [...this.store.threads.keys()].join(", ") || "none";
124
+ throw new Error(`Unknown thread "${opts.threadId}". Known threads: ${known}. Omit "thread" to create a new one.`);
125
+ }
126
+ return existing;
127
+ }
128
+ const id = this.store.nextThreadId();
129
+ const record: ThreadRecord = {
130
+ id,
131
+ name: opts.name?.trim() || id,
132
+ sessionFile: "",
133
+ status: "idle",
134
+ model: opts.model,
135
+ episodeIds: [],
136
+ episodeSeq: 0,
137
+ createdAt: Date.now(),
138
+ updatedAt: Date.now(),
139
+ };
140
+ this.store.threads.set(id, record);
141
+ this.store.save();
142
+ return record;
143
+ }
144
+
145
+ private buildPrompt(opts: DispatchOptions): string {
146
+ const contextIds = opts.contextEpisodeIds ?? [];
147
+ if (contextIds.length === 0) return opts.task;
148
+ const parts: string[] = ["## Context from prior episodes (injected by the orchestrator)", ""];
149
+ for (const id of contextIds) {
150
+ const episode = this.store.episodes.get(id);
151
+ if (!episode) throw new Error(`Unknown episode "${id}". Known: ${[...this.store.episodes.keys()].join(", ") || "none"}`);
152
+ parts.push(readFileSync(episode.file, "utf8").trim(), "");
153
+ }
154
+ parts.push("## Action", "", opts.task);
155
+ return parts.join("\n");
156
+ }
157
+
158
+ private async runDispatch(
159
+ thread: ThreadRecord,
160
+ opts: DispatchOptions,
161
+ ctx: ExtensionContext,
162
+ signal: AbortSignal | undefined,
163
+ onProgress?: (p: DispatchProgress) => void,
164
+ ): Promise<DispatchResult> {
165
+ const prompt = this.buildPrompt(opts); // may throw on unknown episode ids (before any state change)
166
+ await this.semaphore.acquire();
167
+ try {
168
+ return await this.runDispatchInner(thread, opts, prompt, ctx, signal, onProgress);
169
+ } finally {
170
+ this.semaphore.release();
171
+ }
172
+ }
173
+
174
+ private async runDispatchInner(
175
+ thread: ThreadRecord,
176
+ opts: DispatchOptions,
177
+ prompt: string,
178
+ ctx: ExtensionContext,
179
+ signal: AbortSignal | undefined,
180
+ onProgress?: (p: DispatchProgress) => void,
181
+ ): Promise<DispatchResult> {
182
+ const usage: UsageStats = { turns: 0, input: 0, output: 0, cost: 0, contextTokens: 0 };
183
+ const lines: string[] = [];
184
+ const emit = (done: boolean, status?: "ok" | "failed") =>
185
+ onProgress?.({ threadId: thread.id, threadName: thread.name, lines, usage, done, status });
186
+
187
+ let session: WorkerSession | undefined;
188
+ let unsubscribe: (() => void) | undefined;
189
+ let onAbort: (() => void) | undefined;
190
+ let messagesBefore = 0;
191
+ let status: "ok" | "failed" = "ok";
192
+ let diagnostics: string | undefined;
193
+
194
+ try {
195
+ thread.status = "running";
196
+ thread.updatedAt = Date.now();
197
+ this.store.save();
198
+ emit(false);
199
+
200
+ session = this.live.get(thread.id);
201
+ if (!session) {
202
+ session = await openWorkerSession({
203
+ ctx,
204
+ sessionFile: thread.sessionFile || undefined,
205
+ model: thread.model,
206
+ tools: opts.tools ?? this.config.workerTools,
207
+ promptDocs: this.config.workerPromptDocs,
208
+ });
209
+ this.live.set(thread.id, session);
210
+ if (!thread.sessionFile && session.sessionFile) {
211
+ thread.sessionFile = session.sessionFile;
212
+ this.store.save();
213
+ }
214
+ }
215
+
216
+ messagesBefore = session.messages.length;
217
+
218
+ unsubscribe = session.subscribe((event: { type: string; [k: string]: unknown }) => {
219
+ if (event.type === "tool_execution_start") {
220
+ lines.push(`→ ${(event as unknown as { toolName: string }).toolName}`);
221
+ emit(false);
222
+ } else if (event.type === "message_end") {
223
+ const msg = (event as unknown as { message: { role: string; content?: Array<{ type: string; text?: string }>; usage?: { input?: number; output?: number; totalTokens?: number; cost?: { total?: number } }; stopReason?: string; errorMessage?: string } }).message;
224
+ if (msg.role !== "assistant") return;
225
+ usage.turns++;
226
+ usage.input += msg.usage?.input ?? 0;
227
+ usage.output += msg.usage?.output ?? 0;
228
+ usage.cost += msg.usage?.cost?.total ?? 0;
229
+ usage.contextTokens = msg.usage?.totalTokens ?? usage.contextTokens;
230
+ const text = (msg.content ?? [])
231
+ .filter((c) => c.type === "text")
232
+ .map((c) => c.text ?? "")
233
+ .join(" ")
234
+ .trim();
235
+ if (text) lines.push(text.length > 120 ? `${text.slice(0, 120)}...` : text);
236
+ if (msg.stopReason === "error" || msg.stopReason === "aborted") {
237
+ status = "failed";
238
+ diagnostics = msg.errorMessage ?? `worker stopReason: ${msg.stopReason}`;
239
+ }
240
+ emit(false);
241
+ }
242
+ });
243
+
244
+ if (signal?.aborted) throw new Error("aborted before worker start");
245
+ onAbort = () => void session?.abort();
246
+ signal?.addEventListener("abort", onAbort, { once: true });
247
+
248
+ await session.prompt(prompt);
249
+
250
+ if (signal?.aborted) {
251
+ status = "failed";
252
+ diagnostics = diagnostics ?? "aborted by orchestrator";
253
+ }
254
+ } catch (error) {
255
+ status = "failed";
256
+ diagnostics = error instanceof Error ? error.message : String(error);
257
+ } finally {
258
+ unsubscribe?.();
259
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
260
+ }
261
+
262
+ const actionMessages = session ? session.messages.slice(messagesBefore) : [];
263
+
264
+ const episodeId = `${thread.id}.e${++thread.episodeSeq}`;
265
+ const compressed = await compressEpisode({
266
+ ctx,
267
+ episodeId,
268
+ threadId: thread.id,
269
+ threadName: thread.name,
270
+ task: opts.task,
271
+ status,
272
+ diagnostics,
273
+ messages: actionMessages as unknown[],
274
+ workerModel: session?.model ? { provider: session.model.provider, id: session.model.id } : undefined,
275
+ configuredModel: this.config.episodeModel,
276
+ signal: signal?.aborted ? undefined : signal,
277
+ });
278
+
279
+ const episode: EpisodeRecord = {
280
+ id: episodeId,
281
+ threadId: thread.id,
282
+ task: opts.task,
283
+ status,
284
+ file: compressed.file,
285
+ createdAt: Date.now(),
286
+ };
287
+ this.store.episodes.set(episodeId, episode);
288
+ thread.episodeIds.push(episodeId);
289
+ thread.status = "idle";
290
+ thread.updatedAt = Date.now();
291
+ // Accumulate session-wide worker spend (worker turns + episode compressor)
292
+ // BEFORE save so it persists with the snapshot.
293
+ this.store.workerCostUsd += usage.cost + compressed.costUsd;
294
+ this.store.save();
295
+
296
+ emit(true, status);
297
+
298
+ return { episodeText: compressed.text, episode, thread, usage };
299
+ }
300
+
301
+ disposeAll(): void {
302
+ for (const session of this.live.values()) {
303
+ try {
304
+ session.dispose();
305
+ } catch {
306
+ /* ignore */
307
+ }
308
+ }
309
+ this.live.clear();
310
+ }
311
+ }