@a-t-h-i/bot-lobby 0.6.18 → 0.6.20

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.
Files changed (56) hide show
  1. package/README.md +19 -1
  2. package/package.json +1 -1
  3. package/src/ask/relay.ts +3 -1
  4. package/src/ask/tool.ts +18 -3
  5. package/src/ask/types.ts +12 -0
  6. package/src/ask/web.ts +2 -1
  7. package/src/delivery/checks.ts +37 -0
  8. package/src/delivery/lock.ts +78 -0
  9. package/src/delivery/merge.ts +44 -0
  10. package/src/delivery/operations.ts +134 -0
  11. package/src/delivery/pr.ts +40 -0
  12. package/src/delivery/review.ts +90 -0
  13. package/src/delivery/rules.ts +46 -0
  14. package/src/delivery/source.ts +39 -0
  15. package/src/delivery/transport.ts +36 -0
  16. package/src/delivery/types.ts +60 -0
  17. package/src/execution/agent-runner.ts +1 -23
  18. package/src/index.ts +1 -1
  19. package/src/lobby/feed.ts +0 -6
  20. package/src/lobby/host.ts +3 -0
  21. package/src/lobby/planner.ts +1 -7
  22. package/src/lobby/pr-review.ts +1 -7
  23. package/src/lobby/prompt-hub.ts +7 -1
  24. package/src/lobby/quickfix.ts +3 -16
  25. package/src/lobby/service.ts +73 -3
  26. package/src/lobby/sessions.ts +12 -0
  27. package/src/lobby/task-rows.ts +5 -0
  28. package/src/master/master.ts +1 -1
  29. package/src/master/research.ts +0 -1
  30. package/src/pi/events.ts +0 -2
  31. package/src/pi/model-settings.ts +7 -26
  32. package/src/pi/model-support.ts +4 -19
  33. package/src/schemas/configuration.ts +8 -38
  34. package/src/schemas/findings.ts +0 -2
  35. package/src/schemas/task.ts +14 -0
  36. package/src/state/blocking-requests.ts +23 -0
  37. package/src/state/persistence.ts +12 -1
  38. package/src/state/phase-timing.ts +53 -0
  39. package/src/state/task-state.ts +2 -0
  40. package/src/state/timing-merge.ts +25 -0
  41. package/src/webui/api/index.ts +17 -1
  42. package/src/webui/api/status.ts +4 -2
  43. package/src/webui/api/tasks.ts +52 -0
  44. package/src/webui/protocol.ts +8 -0
  45. package/src/workflow/approvals.ts +3 -0
  46. package/src/workflow/workflow.ts +14 -33
  47. package/webui/dist/assets/index-9daVA-Sp.js +99 -0
  48. package/webui/dist/assets/index-DKVApIgy.css +2 -0
  49. package/webui/dist/build.json +142 -94
  50. package/webui/dist/index.html +4 -4
  51. package/webui/dist/manifest.webmanifest +2 -2
  52. package/webui/dist/offline.html +12 -9
  53. package/src/execution/fallback.ts +0 -75
  54. package/src/pi/master-fallback.ts +0 -61
  55. package/webui/dist/assets/index-CHLNxlA1.js +0 -98
  56. package/webui/dist/assets/index-m9XcQvH8.css +0 -2
@@ -0,0 +1,46 @@
1
+ import { api, object, pages, strings, type DeliveryContext } from "./transport.ts";
2
+
3
+ export interface Rules { known: boolean; required: string[]; policies: unknown[]; blocked?: string }
4
+ function effective(raw: unknown, required: Set<string>): string | undefined {
5
+ const rule = object(raw);
6
+ if (typeof rule.type !== "string") throw new Error("Malformed repository rule.");
7
+ if (["deletion", "non_fast_forward"].includes(rule.type)) return undefined;
8
+ if (rule.type !== "required_status_checks") return `Repository rule ${rule.type} prohibits or cannot safely authorize direct pushes. Use a PR or ask a repository administrator.`;
9
+ const params = object(rule.parameters);
10
+ if (typeof params.strict_required_status_checks_policy !== "boolean" || !Array.isArray(params.required_status_checks)) throw new Error("Malformed required status rule.");
11
+ for (const rawCheck of params.required_status_checks) {
12
+ const check = object(rawCheck);
13
+ if (typeof check.context !== "string" || !check.context.trim()) throw new Error("Malformed required check context.");
14
+ if (check.integration_id !== null && check.integration_id !== undefined) throw new Error("App-bound required checks need verified app identity; use a PR.");
15
+ required.add(check.context);
16
+ }
17
+ }
18
+ function legacy(raw: unknown, required: Set<string>): string | undefined {
19
+ const rule = object(raw);
20
+ if (rule.required_status_checks !== null) {
21
+ const checks = object(rule.required_status_checks);
22
+ if (typeof checks.strict !== "boolean") throw new Error("Malformed legacy status policy.");
23
+ strings(checks.contexts).forEach((name) => required.add(name));
24
+ if (!Array.isArray(checks.checks)) throw new Error("Missing required check bindings.");
25
+ for (const value of checks.checks) { const check = object(value); if (check.app_id !== null) throw new Error("App-bound legacy checks require verified app identity; use a PR."); }
26
+ }
27
+ const linear = object(rule.required_linear_history);
28
+ const signatures = object(rule.required_signatures);
29
+ if (typeof linear.enabled !== "boolean" || typeof signatures.enabled !== "boolean") throw new Error("Unknown branch protection policy.");
30
+ if (rule.required_pull_request_reviews !== null || rule.restrictions !== null || linear.enabled || signatures.enabled) return "Branch protection requires PR/review, restricted pushes, linear history or signed commits; use a PR. No bypass is attempted.";
31
+ if (rule.lock_branch && object(rule.lock_branch).enabled !== false) return "Main is locked; ask a repository administrator.";
32
+ }
33
+ export async function readRules(ctx: DeliveryContext, repo: string): Promise<Rules> {
34
+ try {
35
+ const branch = object(await api(ctx, `repos/${repo}/branches/main`));
36
+ if (branch.name !== "main" || typeof branch.protected !== "boolean") throw new Error("Malformed main branch response.");
37
+ const policies = await pages(ctx, `repos/${repo}/rules/branches/main`);
38
+ const required = new Set<string>();
39
+ const reasons = policies.map((raw) => effective(raw, required)).filter(Boolean);
40
+ if (branch.protected) {
41
+ const protection = await api(ctx, `repos/${repo}/branches/main/protection`);
42
+ policies.push(protection); reasons.push(legacy(protection, required));
43
+ }
44
+ return { known: true, required: [...required].sort(), policies, blocked: reasons.filter(Boolean).join(" ") || undefined };
45
+ } catch (error) { return { known: false, required: [], policies: [], blocked: `${(error as Error).message} Repository rules must be verified before merging; refresh review or use a PR.` }; }
46
+ }
@@ -0,0 +1,39 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { resolve, isAbsolute } from "node:path";
3
+ import type { Task } from "../schemas/task.ts";
4
+ import { command, type DeliveryContext } from "./transport.ts";
5
+
6
+ export interface Source { repository: string; remote: string; github: string; branch: string; commit: string; cwd: string }
7
+ export function sha(value: string): string {
8
+ if (!/^[a-f0-9]{40}$/.test(value)) throw new Error("Missing or invalid commit identity; commit completed work and refresh review.");
9
+ return value;
10
+ }
11
+ function githubRemote(remote: string): string {
12
+ const match = /^(?:https:\/\/github\.com\/|git@github\.com:)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(remote);
13
+ if (!match?.[1] || match[1].split("/").some((v) => v === "." || v === "..")) throw new Error("Origin must identify a GitHub repository without credentials; configure origin and refresh review.");
14
+ return match[1];
15
+ }
16
+ async function common(ctx: DeliveryContext): Promise<string> {
17
+ const dir = await command(ctx, "git", ["rev-parse", "--git-common-dir"]);
18
+ return realpathSync(resolve(ctx.cwd, dir));
19
+ }
20
+ export async function readSource(task: Task, ctx: DeliveryContext): Promise<Source> {
21
+ if (!task.git) throw new Error("Task has no Git isolation. Preserve the completed work and configure a task branch before delivery.");
22
+ const branch = task.git.branch;
23
+ if (!branch || branch === "main" || branch.startsWith("-") || /[\s\x00-\x1f]/.test(branch)) throw new Error("Invalid task branch; delivery cannot use main as its source.");
24
+ await command(ctx, "git", ["check-ref-format", "--branch", branch]);
25
+ const repository = await common(ctx);
26
+ const path = task.git.mode === "worktree" ? task.git.path : ctx.cwd;
27
+ if (!path || !isAbsolute(path)) throw new Error("Task working directory is missing; restore its isolated worktree.");
28
+ const sourceCtx = { ...ctx, cwd: realpathSync(path) };
29
+ if (await common(sourceCtx) !== repository) throw new Error("Task worktree belongs to a different repository; restore isolation.");
30
+ if (await command(sourceCtx, "git", ["symbolic-ref", "--short", "HEAD"]) !== branch) throw new Error("Task branch is not checked out in its working directory; restore it and refresh review.");
31
+ if (await command(sourceCtx, "git", ["status", "--porcelain"])) throw new Error("Task source has uncommitted changes. Commit only completed task work, then refresh review.");
32
+ const commit = sha(await command(sourceCtx, "git", ["rev-parse", `refs/heads/${branch}^{commit}`]));
33
+ const remote = await command(sourceCtx, "git", ["remote", "get-url", "origin"]);
34
+ return { repository, remote, github: githubRemote(remote), branch, commit, cwd: sourceCtx.cwd };
35
+ }
36
+ export async function refreshMain(ctx: DeliveryContext): Promise<string> {
37
+ await command(ctx, "git", ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]);
38
+ return sha(await command(ctx, "git", ["rev-parse", "refs/remotes/origin/main^{commit}"]));
39
+ }
@@ -0,0 +1,36 @@
1
+ import type { Exec } from "../lobby/issues.ts";
2
+
3
+ export interface DeliveryContext { exec: Exec; cwd: string }
4
+ export async function command(ctx: DeliveryContext, tool: string, args: string[]): Promise<string> {
5
+ const result = await ctx.exec(tool, args, { cwd: ctx.cwd, timeout: 20_000 });
6
+ if (result.code !== 0) throw new Error(`${tool} lookup failed; check remote access and authentication, then refresh review.`);
7
+ return result.stdout.trim();
8
+ }
9
+ export async function api(ctx: DeliveryContext, endpoint: string): Promise<unknown> {
10
+ const text = await command(ctx, "gh", ["api", endpoint]);
11
+ try { return JSON.parse(text); }
12
+ catch { throw new Error("GitHub returned malformed JSON; refresh review after connectivity recovers."); }
13
+ }
14
+ export function object(value: unknown): Record<string, unknown> {
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Malformed GitHub response; refresh review.");
16
+ return value as Record<string, unknown>;
17
+ }
18
+ export function strings(value: unknown): string[] {
19
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) throw new Error("Malformed required check names.");
20
+ return value as string[];
21
+ }
22
+ export async function pages(ctx: DeliveryContext, endpoint: string, key?: string): Promise<unknown[]> {
23
+ const all: unknown[] = [];
24
+ for (let page = 1; page <= 100; page++) {
25
+ const raw = await api(ctx, `${endpoint}?per_page=100&page=${page}`);
26
+ const rows = key ? object(raw)[key] : raw;
27
+ if (!Array.isArray(rows) || rows.length > 100) throw new Error("Malformed GitHub pagination; refresh review.");
28
+ if (key && (!Number.isInteger(object(raw).total_count) || Number(object(raw).total_count) < all.length + rows.length)) throw new Error("Malformed check count.");
29
+ all.push(...rows);
30
+ if (rows.length < 100) {
31
+ if (key && object(raw).total_count !== all.length) throw new Error("Incomplete check pagination; refresh review.");
32
+ return all;
33
+ }
34
+ }
35
+ throw new Error("GitHub pagination exceeds safety limit; review checks manually.");
36
+ }
@@ -0,0 +1,60 @@
1
+ /** Persisted delivery review contract; completion and publication are separate. */
2
+ export type DeliveryStatus = "pending_approval" | "deferred" | "in_progress" | "successful" | "recoverable_failure";
3
+ export type DeliveryAction = "create_pr" | "merge_main";
4
+ export type DeliveryChecksState = "passing" | "failing" | "pending" | "absent" | "unavailable" | "stale";
5
+
6
+ export interface DeliveryVerification {
7
+ checks: DeliveryChecksState;
8
+ summary: string;
9
+ localVerified: boolean;
10
+ requiredChecks: string[];
11
+ rulesKnown: boolean;
12
+ }
13
+
14
+ export interface DeliveryOperation {
15
+ id: string;
16
+ action: DeliveryAction;
17
+ sourceCommit: string;
18
+ repository?: string;
19
+ remote?: string;
20
+ sourceBranch?: string;
21
+ target?: "main";
22
+ fingerprint?: string;
23
+ reviewId?: string;
24
+ integrationPath?: string;
25
+ targetCommit?: string;
26
+ stage: string;
27
+ mergeCommit?: string;
28
+ pullNumber?: number;
29
+ pullUrl?: string;
30
+ startedAt: string;
31
+ }
32
+
33
+ export interface DeliveryResult {
34
+ action: DeliveryAction;
35
+ commit?: string;
36
+ pullNumber?: number;
37
+ pullUrl?: string;
38
+ }
39
+
40
+ export interface Delivery {
41
+ status: DeliveryStatus;
42
+ reviewId: string;
43
+ repository?: string;
44
+ remote?: string;
45
+ project: string;
46
+ sourceBranch: string;
47
+ sourceCommit?: string;
48
+ target: "main";
49
+ targetCommit?: string;
50
+ reviewedAt: string;
51
+ verification?: DeliveryVerification;
52
+ /** Fingerprint of reviewed rules and check responses; missing means not reviewed. */
53
+ fingerprint?: string;
54
+ blocked: Partial<Record<DeliveryAction, string>>;
55
+ operation?: DeliveryOperation;
56
+ /** Reconciled, non-delivered intents superseded only by explicit fresh approval. */
57
+ previousOperations?: DeliveryOperation[];
58
+ error?: string;
59
+ result?: DeliveryResult;
60
+ }
@@ -7,7 +7,6 @@ import { shortDuration, truncate } from "../text.ts";
7
7
  import { EditLog } from "../state/changes.ts";
8
8
  import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
9
9
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
10
- import { isUnavailable, looksUnavailable, markUnavailable, usableFallback } from "./fallback.ts";
11
10
  import { ASK_ENV } from "../ask/relay.ts";
12
11
  import { ASK_TOOL } from "../ask/types.ts";
13
12
  import { agentOfRun, grantOption } from "../excalidraw/sessions.ts";
@@ -42,10 +41,6 @@ export interface AgentRequest {
42
41
  context: AgentContext;
43
42
  model?: string;
44
43
  thinking?: string;
45
- /** Where the run goes when its model runs out of usage or is unavailable. */
46
- fallback?: { model: string; thinking: string };
47
- /** Set by the runner once it has switched: the model that ran out. */
48
- fellBackFrom?: string;
49
44
  timeoutMs: number;
50
45
  cwd: string;
51
46
  signal?: AbortSignal;
@@ -141,24 +136,13 @@ function retryable(run: AgentRun): boolean {
141
136
  */
142
137
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
143
138
  const startedAt = new Date().toISOString();
144
- let attempts = Math.max(1, (request.retries ?? 0) + 1);
139
+ const attempts = Math.max(1, (request.retries ?? 0) + 1);
145
140
  // A failed attempt may have edited files before the retry: the run owns every edit.
146
141
  const edits = new EditLog(request.cwd);
147
142
  let last: AgentRun | undefined;
148
- // A model already known to be out of usage goes straight to the fallback.
149
- const other = usableFallback(request.fallback, request.model);
150
- if (other && isUnavailable(request.model)) request = switchToFallback(request, other, request.model ?? "the session model");
151
143
  for (let attempt = 1; attempt <= attempts; attempt++) {
152
144
  last = await runAgentOnce(request, run, attempt, startedAt, edits);
153
145
  request.onAttemptEnd?.(last);
154
- // Out of usage (or the model unavailable): the same model would fail again, so the fallback takes the retry.
155
- const fallback = usableFallback(request.fallback, request.model);
156
- if (fallback && !request.fellBackFrom && last.status === "failed" && looksUnavailable(last.error)) {
157
- if (request.model) markUnavailable(request.model);
158
- request = switchToFallback(request, fallback, request.model ?? "the session model");
159
- attempts += 1;
160
- continue;
161
- }
162
146
  if (!retryable(last)) break;
163
147
  // Under a budget a retry only uses what is left of the allotment.
164
148
  if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
@@ -167,11 +151,6 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
167
151
  return edited.length > 0 ? { ...last!, edited } : last!;
168
152
  }
169
153
 
170
- /** The request moved to its fallback model and thinking level. */
171
- function switchToFallback(request: AgentRequest, fallback: { model: string; thinking?: string }, from: string): AgentRequest {
172
- return { ...request, model: fallback.model, thinking: fallback.thinking ?? request.thinking, fellBackFrom: from };
173
- }
174
-
175
154
  /** Abort every in-flight subagent (session shutdown, user cancel). */
176
155
  export function cancelAllRuns(): void {
177
156
  for (const controller of activeControllers) controller.abort();
@@ -190,7 +169,6 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
190
169
  attempts,
191
170
  startedAt,
192
171
  ...(request.thinking ? { thinking: request.thinking } : {}),
193
- ...(request.fellBackFrom ? { fellBackFrom: request.fellBackFrom } : {}),
194
172
  ...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
195
173
  ...(request.route ? { route: request.route } : {}),
196
174
  ...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
package/src/index.ts CHANGED
@@ -40,7 +40,7 @@ export default function (pi: ExtensionAPI): void {
40
40
  // A new request one agent can do alone: the oracle confirms, and the lobby hands it to the quick-fix agent.
41
41
  registerRouteTool(pi, CONFIG_DIR_NAME);
42
42
  // The questionnaire the oracle (and pi without a task) asks the user with; no other extension is needed for it.
43
- registerAskTool(pi);
43
+ registerAskTool(pi, undefined, CONFIG_DIR_NAME);
44
44
  // The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
45
45
  registerWebTools(pi);
46
46
  // The shared Excalidraw sessions the user assigned to agents: the oracle here, every other agent through its own grant.
package/src/lobby/feed.ts CHANGED
@@ -71,7 +71,6 @@ export class LobbyFeed {
71
71
  private readonly runThoughts = new Map<string, string>();
72
72
  private readonly runStatus = new Map<string, AgentRun["status"]>();
73
73
  /** Runs already reported as switched to their fallback model. */
74
- private readonly fellBack = new Set<string>();
75
74
 
76
75
  onChange(listener: () => void): () => void {
77
76
  this.listeners.add(listener);
@@ -202,10 +201,6 @@ export class LobbyFeed {
202
201
  const before = this.runStatus.get(key);
203
202
  this.runStatus.set(key, run.status);
204
203
  if (before === undefined) this.log(source, `started as ${run.role}`, "info", at);
205
- if (run.fellBackFrom && !this.fellBack.has(key)) {
206
- this.fellBack.add(key);
207
- this.log(source, `${run.fellBackFrom} is out of usage or unavailable: running on the fallback model`, "warning", at);
208
- }
209
204
  if (run.step && this.runSteps.get(key) !== run.step) {
210
205
  this.runSteps.set(key, run.step);
211
206
  this.step(source, run.step, key, at);
@@ -231,7 +226,6 @@ export class LobbyFeed {
231
226
  this.runSteps.clear();
232
227
  this.runThoughts.clear();
233
228
  this.runStatus.clear();
234
- this.fellBack.clear();
235
229
  this.touch();
236
230
  }
237
231
  }
package/src/lobby/host.ts CHANGED
@@ -93,6 +93,9 @@ export interface LobbyService {
93
93
  feed: LobbyFeed;
94
94
  masterBusy(): boolean;
95
95
  tasks(): Task[];
96
+ deliveryDeliver?(taskId: string, request: import("../delivery/operations.ts").DeliveryRequest): Promise<import("../delivery/types.ts").Delivery>;
97
+ deliveryReview?(taskId: string): Promise<import("../delivery/types.ts").Delivery>;
98
+ deliveryDefer?(taskId: string, reviewId: string): import("../delivery/types.ts").Delivery;
96
99
  plans(): PlannedTask[];
97
100
  comments(taskId: string): PlanComment[];
98
101
  metrics(): MetricRecord[];
@@ -20,7 +20,6 @@
20
20
  */
21
21
  import { loadPrompt } from "../prompts/loader.ts";
22
22
  import { compilePrompt } from "../prompts/compiler.ts";
23
- import { withFallback } from "../execution/fallback.ts";
24
23
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
25
24
  import { grantOption, type Grant } from "../excalidraw/sessions.ts";
26
25
  import { describeToolCall } from "../pi/activity.ts";
@@ -823,12 +822,7 @@ export class PlanningSession {
823
822
  },
824
823
  this.deps.runProcess ?? spawnPiProcess,
825
824
  );
826
- // A model that is out of usage hands the turn to the fallback the settings name.
827
- const { result, switchedFrom } = await withFallback(profile.model, profile.thinking, profile.fallback, attempt);
828
- if (switchedFrom && profile.fallback) {
829
- this.deps.feed?.log(label, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model}`, "warning");
830
- profile = { ...profile, model: profile.fallback.model, thinking: profile.fallback.thinking };
831
- }
825
+ const result = await attempt(profile.model, profile.thinking);
832
826
  outcome = { status: result.status, output: result.output, ...(result.error ? { error: result.error } : {}), ...(result.model ? { model: result.model } : {}), usage: result.usage };
833
827
  } catch (error) {
834
828
  outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
@@ -11,7 +11,6 @@
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { loadPrompt } from "../prompts/loader.ts";
14
- import { withFallback } from "../execution/fallback.ts";
15
14
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
16
15
  import { describeToolCall } from "../pi/activity.ts";
17
16
  import { appendMetrics } from "../state/metrics.ts";
@@ -284,12 +283,7 @@ export class PullReviews {
284
283
  },
285
284
  this.deps.runProcess ?? spawnPiProcess,
286
285
  );
287
- const { result, switchedFrom } = await withFallback(profile.model, profile.thinking, profile.fallback, attempt);
288
- if (switchedFrom && profile.fallback) {
289
- review.model = profile.fallback.model;
290
- review.thinking = profile.fallback.thinking;
291
- this.step(review, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model}`);
292
- }
286
+ const result = await attempt(profile.model, profile.thinking);
293
287
  if (result.model) review.model = result.model;
294
288
  review.status = result.status === "success" ? "done" : result.status;
295
289
  const text = result.output.trim();
@@ -5,6 +5,7 @@
5
5
  * the answer, or with how it was put away. A cancelled caller's `signal`
6
6
  * withdraws the prompt.
7
7
  */
8
+ import { blockingRequestContext } from "../state/blocking-requests.ts";
8
9
  import { lobbyTopics } from "./topics.ts";
9
10
 
10
11
  export type PromptKind = "questionnaire" | "choose" | "confirm" | "text" | "sessionDialog";
@@ -16,6 +17,9 @@ export interface WebPrompt {
16
17
  createdAt: number;
17
18
  from: string;
18
19
  payload: unknown;
20
+ taskId?: string;
21
+ sessionId?: string;
22
+ blockingRequestId?: string;
19
23
  }
20
24
 
21
25
  interface Pending {
@@ -50,7 +54,9 @@ export class PromptHub {
50
54
  /** Show a prompt on every surface; the answer settles it later. */
51
55
  open(kind: PromptKind, from: string, payload: unknown): WebPrompt {
52
56
  this.seq += 1;
53
- const prompt: WebPrompt = { id: nextId(this.seq), kind, createdAt: Date.now(), from, payload };
57
+ const identity = blockingRequestContext.getStore();
58
+ const prompt: WebPrompt = { id: nextId(this.seq), kind, createdAt: Date.now(), from, payload,
59
+ ...(identity ? { taskId: identity.taskId, sessionId: identity.sessionId, blockingRequestId: identity.requestId } : {}) };
54
60
  this.pendingMap.set(prompt.id, { prompt });
55
61
  lobbyTopics.bump("prompts");
56
62
  return prompt;
@@ -6,7 +6,6 @@
6
6
  * and the lobby feed, and every finished job lands in the metrics log.
7
7
  */
8
8
  import { loadPrompt } from "../prompts/loader.ts";
9
- import { withFallback } from "../execution/fallback.ts";
10
9
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
11
10
  import { grantOption } from "../excalidraw/sessions.ts";
12
11
  import { describeToolCall } from "../pi/activity.ts";
@@ -77,7 +76,6 @@ export interface QuickFixProfile {
77
76
  timeoutMs: number;
78
77
  instructions?: string;
79
78
  /** Where the job goes when its model runs out of usage or is unavailable. */
80
- fallback?: { model: string; thinking: string };
81
79
  }
82
80
 
83
81
  export interface QuickFixDeps {
@@ -265,17 +263,6 @@ export class QuickFixQueue {
265
263
  },
266
264
  this.deps.runProcess ?? spawnPiProcess,
267
265
  );
268
- // A model that is out of usage hands the job to the fallback the settings name.
269
- const attemptOrFallback = async (model: string | undefined, thinking: string) => {
270
- const { result: outcome, switchedFrom } = await withFallback(model, thinking, profile.fallback, attempt);
271
- if (switchedFrom && profile.fallback) {
272
- job.model = profile.fallback.model;
273
- job.thinking = profile.fallback.thinking;
274
- this.addStep(job, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model} (${profile.fallback.thinking})`, Date.now());
275
- this.changed();
276
- }
277
- return outcome;
278
- };
279
266
  let result;
280
267
  if (route) {
281
268
  job.route = routeLabel(route);
@@ -283,7 +270,7 @@ export class QuickFixQueue {
283
270
  job.thinking = route.thinking;
284
271
  if (route.model) job.model = route.model;
285
272
  this.changed();
286
- result = await attemptOrFallback(route.model, route.thinking);
273
+ result = await attempt(route.model, route.thinking);
287
274
  if (fellShort(result) && !controller.signal.aborted) {
288
275
  // The routed attempt counts in the metrics on its own; the job re-runs on the configured profile.
289
276
  appendMetrics(this.deps.root, this.deps.configDir, [{ ...quickFixMetric({ ...job, status: result.status, finishedAt: Date.now(), usage: result.usage, ...(result.model ? { model: result.model } : {}) }), id: `${job.id}-routed-${job.startedAt}` }]);
@@ -292,10 +279,10 @@ export class QuickFixQueue {
292
279
  delete job.routedFrom;
293
280
  job.thinking = profile.thinking;
294
281
  if (profile.model) job.model = profile.model;
295
- result = await attemptOrFallback(profile.model, profile.thinking);
282
+ result = await attempt(profile.model, profile.thinking);
296
283
  }
297
284
  } else {
298
- result = await attemptOrFallback(profile.model, profile.thinking);
285
+ result = await attempt(profile.model, profile.thinking);
299
286
  }
300
287
  job.status = result.status;
301
288
  job.report = result.output.trim() || undefined;
@@ -5,7 +5,15 @@
5
5
  */
6
6
  import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
8
- import { loadTask, peekTasks } from "../state/persistence.ts";
8
+ import { updateBlockingRequest } from "../state/blocking-requests.ts";
9
+ import { randomUUID } from "node:crypto";
10
+ import { realpathSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { loadTask, ownedTask, peekTasks, saveTask } from "../state/persistence.ts";
13
+ import { refreshReview, deferReview } from "../delivery/review.ts";
14
+ import { deliver, type DeliveryRequest, type DeliveryStore } from "../delivery/operations.ts";
15
+ import type { LockIdentity } from "../delivery/lock.ts";
16
+ import { execCommand } from "./issues.ts";
9
17
  import { releaseAttachments } from "../state/attachments.ts";
10
18
  import { loadConfig, saveConfig as writeConfig } from "../state/project.ts";
11
19
  import { addPlanComment, editComment, readPlanComments, type PlanComment } from "../state/comments.ts";
@@ -40,6 +48,34 @@ import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
40
48
  import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
41
49
  import type { Runtime } from "./runtime.ts";
42
50
 
51
+ function deliveryTask(state: Runtime, taskId: string): Task {
52
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId) || taskId === "." || taskId === "..") throw new Error("Invalid task ID.");
53
+ const task = loadTask(state.root, state.configDir, taskId);
54
+ if (!task || task.state !== "completed" || !task.delivery) throw new Error("Completed task delivery review is unavailable in this project.");
55
+ return task;
56
+ }
57
+
58
+ function recoveryDeliveryStore(state: Runtime, identity: LockIdentity): DeliveryStore {
59
+ if (identity.configDir !== resolve(state.configDir) || realpathSync(identity.projectRoot) !== identity.projectRoot) throw new Error("Interrupted owner has an untrusted project/config identity.");
60
+ return deliveryStore({ ...state, root: identity.projectRoot }, identity.taskId);
61
+ }
62
+ function deliveryStore(state: Runtime, taskId: string): DeliveryStore {
63
+ let baseline: string | undefined;
64
+ return {
65
+ identity: { projectRoot: realpathSync(state.root), configDir: resolve(state.configDir) },
66
+ recoveryStore: (identity) => recoveryDeliveryStore(state, identity),
67
+ load: () => { const task = deliveryTask(state, taskId); baseline = JSON.stringify(task.delivery); return task; },
68
+ save: (task) => {
69
+ const latest = deliveryTask(state, taskId);
70
+ if (JSON.stringify(latest.delivery) !== baseline) throw new Error("Delivery changed during operation; reconcile before retrying.");
71
+ latest.delivery = structuredClone(task.delivery);
72
+ saveTask(state.root, state.configDir, latest);
73
+ baseline = JSON.stringify(latest.delivery);
74
+ lobbyTopics.bump("tasks");
75
+ },
76
+ };
77
+ }
78
+
43
79
  /** The state background callbacks report to; set while the lobby service runs. */
44
80
  let serviceState: Runtime | undefined;
45
81
 
@@ -210,6 +246,20 @@ function sessionsChanged(): void {
210
246
  }
211
247
  }
212
248
 
249
+ function dialogTracker(state: Runtime): (id: string, pending: boolean, sessionId?: string) => void {
250
+ const requests = new Map<string, { taskId: string; sessionId: string; requestId: string }>();
251
+ const prefix = `dialog:${randomUUID()}`;
252
+ return (id, pending, sessionId) => {
253
+ if (!sessionId) return;
254
+ const task = pending ? ownedTask(state.root, state.configDir, sessionId) : undefined;
255
+ if (task && !requests.has(id)) requests.set(id, { taskId: task.id, sessionId, requestId: `${prefix}:${id}` });
256
+ const request = requests.get(id);
257
+ if (!request) return;
258
+ updateBlockingRequest({ root: state.root, configDir: state.configDir, ...request }, request.requestId, pending);
259
+ if (!pending) requests.delete(id);
260
+ };
261
+ }
262
+
213
263
  /** Start a task in its own new pi session, named after the task; the session, or why not. */
214
264
  function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
215
265
  const request = start.request?.trim();
@@ -224,7 +274,7 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
224
274
  // A request may lead with flags (`--worktree add login`); the session is named after what follows them.
225
275
  const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
226
276
  try {
227
- const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
277
+ const session = sessionRegistry().start(state.ctx.cwd, { name, projectRoot: state.root, onDialog: dialogTracker(state), ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
228
278
  lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
229
279
  return session;
230
280
  } catch (error) {
@@ -520,6 +570,26 @@ export function createLobbyService(state: Runtime): LobbyService {
520
570
  feed: lobbyFeed,
521
571
  masterBusy: () => !state.ctx.isIdle(),
522
572
  tasks: () => peekTasks(state.root, state.configDir),
573
+ deliveryDeliver: (taskId, request: DeliveryRequest) => deliver(request, { cwd: state.root, exec: execCommand }, deliveryStore(state, taskId)),
574
+ deliveryReview: async (taskId) => {
575
+ const task = deliveryTask(state, taskId);
576
+ const baseline = JSON.stringify(task.delivery);
577
+ const delivery = await refreshReview(task, { cwd: state.root, exec: execCommand });
578
+ // An async lookup must not overwrite intervening deferrals or operations.
579
+ const latest = deliveryTask(state, taskId);
580
+ if (JSON.stringify(latest.delivery) !== baseline) throw new Error("Review changed during lookup; refresh again.");
581
+ latest.delivery = delivery;
582
+ saveTask(state.root, state.configDir, latest);
583
+ lobbyTopics.bump("tasks");
584
+ return delivery;
585
+ },
586
+ deliveryDefer: (taskId, reviewId) => {
587
+ const task = deliveryTask(state, taskId);
588
+ const delivery = deferReview(task, reviewId);
589
+ saveTask(state.root, state.configDir, task);
590
+ lobbyTopics.bump("tasks");
591
+ return delivery;
592
+ },
523
593
  plans: () => listPlannedTasks(state.root, state.configDir),
524
594
  comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
525
595
  metrics: () => readMetrics(state.root, state.configDir),
@@ -584,7 +654,7 @@ export function createLobbyService(state: Runtime): LobbyService {
584
654
  workspace: () => state.workspace,
585
655
  refreshWorkspace: () => refreshWorkspace(state),
586
656
  sessionName: () => state.pi.getSessionName(),
587
- sessions: () => backgroundSessions(),
657
+ sessions: () => backgroundSessions().filter((session) => session.projectRoot === state.root),
588
658
  startSession: (start) => startSession(state, start),
589
659
  isAuto: (taskId) => autoFor(state, taskId),
590
660
  setAuto: (taskId, on) => switchAuto(state, taskId, on),
@@ -39,6 +39,9 @@ export interface SessionProcess {
39
39
  export type SessionStatus = "starting" | "idle" | "working" | "exited";
40
40
 
41
41
  export interface SessionStart {
42
+ /** Server-owned task association; never supplied by a browser payload. */
43
+ onDialog?: (id: string, pending: boolean, sessionId?: string) => void;
44
+ projectRoot?: string;
42
45
  /** The display name: the task's title. */
43
46
  name: string;
44
47
  /** What to send once it is up: the request, or a planned task to start. */
@@ -54,6 +57,7 @@ export const STOP_GRACE_MS = 5000;
54
57
 
55
58
  export class BackgroundSession {
56
59
  readonly key: string;
60
+ readonly projectRoot?: string;
57
61
  readonly name: string;
58
62
  /** The planned task it was started from. */
59
63
  readonly planId?: string;
@@ -67,6 +71,7 @@ export class BackgroundSession {
67
71
  exitCode?: number | null;
68
72
  /** Dialogs the session waits on, oldest first. */
69
73
  readonly dialogs: SessionDialog[] = [];
74
+ private readonly onDialog: NonNullable<SessionStart["onDialog"]>;
70
75
  private readonly proc: SessionProcess;
71
76
  private buffer = "";
72
77
  private stderr = "";
@@ -79,7 +84,9 @@ export class BackgroundSession {
79
84
  counter += 1;
80
85
  this.key = `S${counter}`;
81
86
  this.name = start.name;
87
+ this.projectRoot = start.projectRoot;
82
88
  if (start.planId) this.planId = start.planId;
89
+ this.onDialog = start.onDialog ?? (() => {});
83
90
  this.proc = proc;
84
91
  this.onChange = onChange;
85
92
  proc.stdout?.on("data", (chunk) => this.receive(String(chunk)));
@@ -102,6 +109,7 @@ export class BackgroundSession {
102
109
  if (this.status === "exited") return;
103
110
  this.status = "exited";
104
111
  this.exitCode = code;
112
+ for (const dialog of this.dialogs) this.onDialog(dialog.id, false, this.sessionId);
105
113
  this.dialogs.length = 0;
106
114
  if (this.killTimer) clearTimeout(this.killTimer);
107
115
  this.feed.say("note", reason);
@@ -148,6 +156,7 @@ export class BackgroundSession {
148
156
 
149
157
  /** Stop the oracle's running turn (the session stays up). */
150
158
  abort(): void {
159
+ for (const dialog of [...this.dialogs]) this.answer(dialog.id, { cancelled: true });
151
160
  if (this.busy) this.write({ type: "abort" });
152
161
  }
153
162
 
@@ -156,6 +165,7 @@ export class BackgroundSession {
156
165
  const index = this.dialogs.findIndex((dialog) => dialog.id === id);
157
166
  if (index < 0) return;
158
167
  this.dialogs.splice(index, 1);
168
+ this.onDialog(id, false, this.sessionId);
159
169
  if (this.alive && this.proc.stdin) this.proc.stdin.write(`${JSON.stringify({ type: "extension_ui_response", id, ...answer })}\n`);
160
170
  this.onChange();
161
171
  }
@@ -213,6 +223,7 @@ export class BackgroundSession {
213
223
  if (event.command === "get_state" && data) {
214
224
  this.sessionId = data.sessionId;
215
225
  this.sessionFile = data.sessionFile;
226
+ for (const dialog of this.dialogs) this.onDialog(dialog.id, true, this.sessionId);
216
227
  if (this.status === "starting") this.status = "idle";
217
228
  } else if (event.success === false && typeof event.error === "string") {
218
229
  this.feed.log("LOBBY", `the session refused a request: ${event.error}`, "warning");
@@ -240,6 +251,7 @@ export class BackgroundSession {
240
251
  ...(typeof event.placeholder === "string" ? { placeholder: event.placeholder } : {}),
241
252
  ...(typeof event.prefill === "string" ? { prefill: event.prefill } : {}),
242
253
  });
254
+ this.onDialog(id, true, this.sessionId);
243
255
  this.feed.log("LOBBY", `waiting for you: ${String(event.title ?? "a question").split("\n")[0]}`, "warning");
244
256
  } else if (method === "notify") {
245
257
  const level = event.notifyType === "error" ? "error" : event.notifyType === "warning" ? "warning" : "info";
@@ -1,11 +1,16 @@
1
1
  /** The shapes of the Tasks tab's rows, shared by the web API and the page's words. */
2
2
 
3
+ import type { TimingProjection } from "../state/phase-timing.ts";
4
+ import type { Delivery } from "../delivery/types.ts";
5
+
3
6
  export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
4
7
 
5
8
  /** How a row is ticked off: still to do (a plan, or a task under way), completed, or abandoned. */
6
9
  export type CheckState = "open" | "done" | "dropped";
7
10
 
8
11
  export interface TaskRow {
12
+ timing?: TimingProjection;
13
+ delivery?: Pick<Delivery, "status" | "reviewId">;
9
14
  /** A task on the list, a saved plan, or a task in the archive. */
10
15
  kind: "task" | "plan" | "archived";
11
16
  id: string;
@@ -52,7 +52,7 @@ export interface ScoutRequest {
52
52
  /** Model, thinking and time limit for one run, from settings. */
53
53
  function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
54
54
  const profile = profileFor(config, resolver, domain, role);
55
- return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs, ...(profile.fallback ? { fallback: profile.fallback } : {}) };
55
+ return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
56
56
  }
57
57
 
58
58
  function scoutInstruction(request: ScoutRequest, domain: Domain): string {