@a-t-h-i/bot-lobby 0.6.17 → 0.6.19

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 (70) hide show
  1. package/README.md +53 -697
  2. package/package.json +1 -1
  3. package/src/ask/relay.ts +6 -4
  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 +5 -2
  19. package/src/lobby/feed.ts +0 -6
  20. package/src/lobby/host.ts +6 -0
  21. package/src/lobby/planner.ts +26 -7
  22. package/src/lobby/pr-review.ts +1 -7
  23. package/src/lobby/prompt-hub.ts +7 -1
  24. package/src/lobby/pulls.ts +44 -1
  25. package/src/lobby/quickfix.ts +3 -16
  26. package/src/lobby/service.ts +104 -6
  27. package/src/lobby/sessions.ts +12 -0
  28. package/src/lobby/task-rows.ts +5 -0
  29. package/src/master/master.ts +1 -1
  30. package/src/master/research.ts +0 -1
  31. package/src/pi/events.ts +0 -2
  32. package/src/pi/model-settings.ts +7 -26
  33. package/src/pi/model-support.ts +4 -19
  34. package/src/schemas/configuration.ts +8 -38
  35. package/src/schemas/findings.ts +0 -2
  36. package/src/schemas/task.ts +14 -0
  37. package/src/state/attachments.ts +20 -20
  38. package/src/state/blocking-requests.ts +23 -0
  39. package/src/state/comments.ts +25 -3
  40. package/src/state/persistence.ts +12 -1
  41. package/src/state/phase-timing.ts +53 -0
  42. package/src/state/previews.ts +15 -0
  43. package/src/state/task-state.ts +2 -0
  44. package/src/state/timing-merge.ts +25 -0
  45. package/src/webui/api/index.ts +26 -2
  46. package/src/webui/api/lobby.ts +1 -1
  47. package/src/webui/api/planner.ts +15 -2
  48. package/src/webui/api/prompts.ts +4 -4
  49. package/src/webui/api/quickfix.ts +1 -1
  50. package/src/webui/api/sessions.ts +2 -2
  51. package/src/webui/api/tasks.ts +72 -2
  52. package/src/webui/dev/fake-service.ts +19 -1
  53. package/src/webui/files.ts +15 -17
  54. package/src/webui/gateway.ts +64 -0
  55. package/src/webui/projects.ts +111 -0
  56. package/src/webui/protocol.ts +12 -0
  57. package/src/webui/server.ts +35 -6
  58. package/src/webui/uploads.ts +19 -9
  59. package/src/workflow/approvals.ts +3 -0
  60. package/src/workflow/workflow.ts +18 -3
  61. package/webui/dist/assets/index-BJI9lE5R.js +98 -0
  62. package/webui/dist/assets/index-Dg0tvkeY.css +2 -0
  63. package/webui/dist/build.json +131 -79
  64. package/webui/dist/index.html +3 -3
  65. package/webui/dist/sw.js +0 -1
  66. package/src/execution/fallback.ts +0 -75
  67. package/src/pi/master-fallback.ts +0 -61
  68. package/webui/dist/assets/index-BjUR1j07.css +0 -2
  69. package/webui/dist/assets/index-uDDAhAAQ.js +0 -98
  70. package/webui/dist/vite.svg +0 -1
@@ -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
@@ -5,6 +5,7 @@ import { registerOrchestrateTool } from "./pi/tools.ts";
5
5
  import { registerRouteTool } from "./pi/route.ts";
6
6
  import { onTransition } from "./state/task-state.ts";
7
7
  import { releaseAttachments } from "./state/attachments.ts";
8
+ import { detectProjectRoot } from "./state/project.ts";
8
9
  import { TERMINAL_STATES } from "./schemas/task.ts";
9
10
  import { pingTransition } from "./pi/notify.ts";
10
11
  import { isSubagentProcess } from "./pi/quiet.ts";
@@ -19,6 +20,8 @@ import { registerWebTools } from "./web/tools.ts";
19
20
  import { registerExcalidrawTools } from "./excalidraw/tools.ts";
20
21
 
21
22
  export default function (pi: ExtensionAPI): void {
23
+ let root = detectProjectRoot(process.cwd(), CONFIG_DIR_NAME);
24
+ pi.on("session_start", (_event, ctx) => { root = detectProjectRoot(ctx.cwd, CONFIG_DIR_NAME); });
22
25
  // First, so every session_start handler below finds the classifier bound to this session's keys.
23
26
  registerClassifier(pi, CONFIG_DIR_NAME);
24
27
  // Before the lifecycle, so a task that ended while the oracle was idle is closed before it builds the next turn's prompt.
@@ -30,14 +33,14 @@ export default function (pi: ExtensionAPI): void {
30
33
  // A finished task takes the files attached during it along.
31
34
  onTransition((task) => {
32
35
  pingTransition(task);
33
- if (TERMINAL_STATES.includes(task.state)) releaseAttachments(task.id);
36
+ if (TERMINAL_STATES.includes(task.state)) releaseAttachments(task.id, root);
34
37
  });
35
38
  registerCommands(pi, CONFIG_DIR_NAME);
36
39
  registerOrchestrateTool(pi, CONFIG_DIR_NAME);
37
40
  // A new request one agent can do alone: the oracle confirms, and the lobby hands it to the quick-fix agent.
38
41
  registerRouteTool(pi, CONFIG_DIR_NAME);
39
42
  // The questionnaire the oracle (and pi without a task) asks the user with; no other extension is needed for it.
40
- registerAskTool(pi);
43
+ registerAskTool(pi, undefined, CONFIG_DIR_NAME);
41
44
  // The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
42
45
  registerWebTools(pi);
43
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
@@ -86,12 +86,16 @@ export interface ModelChoice {
86
86
 
87
87
  /** Everything the web server needs from pi and bot-lobby. */
88
88
  export interface LobbyService {
89
+ projectRoot?(): string;
89
90
  sessionId(): string | undefined;
90
91
  /** This session's active task and its runs, as the lobby reads them. */
91
92
  zen(): { task?: Task; runs: readonly AgentRun[] };
92
93
  feed: LobbyFeed;
93
94
  masterBusy(): boolean;
94
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;
95
99
  plans(): PlannedTask[];
96
100
  comments(taskId: string): PlanComment[];
97
101
  metrics(): MetricRecord[];
@@ -100,6 +104,8 @@ export interface LobbyService {
100
104
  /** Send text to the oracle, or start a task when none is active; returns a notice. */
101
105
  toOracle(text: string): string | undefined;
102
106
  comment(taskId: string, text: string): string;
107
+ /** Correct a comment this session sent; returns the edited comment. */
108
+ editComment(taskId: string, commentId: string, text: string): PlanComment;
103
109
  startPlanned(plan: PlannedTask): string;
104
110
  discardPlan(id: string): void;
105
111
  abortMaster(): void;
@@ -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";
@@ -86,6 +85,7 @@ export interface PlannerMessage {
86
85
  role: "you" | "planner";
87
86
  text: string;
88
87
  at: number;
88
+ editedAt?: number;
89
89
  /** The round's questions, attributed, when the panel asked any. */
90
90
  questions?: PanelQuestion[];
91
91
  /** Questions the classifier answered with their recommended option instead of asking. */
@@ -574,6 +574,30 @@ export class PlanningSession {
574
574
  await this.post(text, settled);
575
575
  }
576
576
 
577
+ /** Correct an ordinary user turn, keeping the conversation and rerunning the panel. */
578
+ async editMessage(messageIndex: number, at: number, text: string): Promise<void> {
579
+ this.validateEdit(messageIndex, at, text);
580
+ this.messages = this.messages.map((message, index) => index === messageIndex ? { ...message, text: text.trim(), editedAt: Date.now() } : message);
581
+ this.reply = undefined;
582
+ this.questions = [];
583
+ this.answered = [];
584
+ this.lineComments = [];
585
+ this.notes = [];
586
+ this.memberNotes.clear();
587
+ this.lastStatus.clear();
588
+ this.autoContinued = false;
589
+ await this.turn();
590
+ }
591
+
592
+ /** Shared boundary checks, also used by HTTP before starting the background round. */
593
+ validateEdit(messageIndex: number, at: number, text: string): void {
594
+ if (this.busy) throw new Error("the panel is still thinking");
595
+ const message = this.messages[messageIndex];
596
+ if (!Number.isInteger(messageIndex) || messageIndex < 0 || !message || message.at !== at) throw new Error("the message has changed — refresh the conversation");
597
+ if (message.role !== "you" || message.settled?.length) throw new Error("only ordinary user messages can be edited");
598
+ if (!text.trim()) throw new Error("an edited message needs some text");
599
+ }
600
+
577
601
  private async post(text: string, settled?: readonly SettledQuestion[]): Promise<void> {
578
602
  const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
579
603
  if (!body) return;
@@ -798,12 +822,7 @@ export class PlanningSession {
798
822
  },
799
823
  this.deps.runProcess ?? spawnPiProcess,
800
824
  );
801
- // A model that is out of usage hands the turn to the fallback the settings name.
802
- const { result, switchedFrom } = await withFallback(profile.model, profile.thinking, profile.fallback, attempt);
803
- if (switchedFrom && profile.fallback) {
804
- this.deps.feed?.log(label, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model}`, "warning");
805
- profile = { ...profile, model: profile.fallback.model, thinking: profile.fallback.thinking };
806
- }
825
+ const result = await attempt(profile.model, profile.thinking);
807
826
  outcome = { status: result.status, output: result.output, ...(result.error ? { error: result.error } : {}), ...(result.model ? { model: result.model } : {}), usage: result.usage };
808
827
  } catch (error) {
809
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;
@@ -4,7 +4,7 @@
4
4
  * Like issues, `gh` owns authentication and repository detection; every
5
5
  * failure becomes one readable line.
6
6
  */
7
- import { gh, type Exec } from "./issues.ts";
7
+ import { execCommand, gh, type Exec } from "./issues.ts";
8
8
 
9
9
  export type ChecksState = "passing" | "failing" | "pending";
10
10
 
@@ -193,6 +193,49 @@ export async function pullDiff(exec: Exec, cwd: string, number: number, limit =
193
193
  return stdout.length > limit ? `${stdout.slice(0, limit)}\n[…the diff continues: ${stdout.length - limit} more characters]` : stdout;
194
194
  }
195
195
 
196
+ /** A pull request opened for a completed task's branch. */
197
+ export interface CreatedPull {
198
+ number: number;
199
+ url: string;
200
+ }
201
+
202
+ const PUSH_TIMEOUT_MS = 60_000;
203
+
204
+ async function run(exec: Exec, cwd: string, command: string, args: string[]): Promise<string> {
205
+ let result: Awaited<ReturnType<Exec>>;
206
+ try {
207
+ result = await exec(command, args, { cwd, timeout: PUSH_TIMEOUT_MS });
208
+ } catch (error) {
209
+ throw new Error(`${command} could not run — ${(error as Error).message}`);
210
+ }
211
+ if (result.code !== 0) throw new Error(result.stderr.trim() || `${command} exited with code ${result.code}`);
212
+ return result.stdout;
213
+ }
214
+
215
+ /** `gh pr create` ends with the URL on its own last line; the number is its tail. */
216
+ function parseCreated(stdout: string): CreatedPull {
217
+ const url = stdout.trim().split("\n").map((line) => line.trim()).filter((line) => /^https?:\/\/\S+$/.test(line)).pop();
218
+ if (!url) throw new Error("gh pr create printed no pull request URL");
219
+ const match = /\/pull\/([1-9]\d*)$/.exec(url);
220
+ const number = Number(match?.[1]);
221
+ if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`gh pr create printed an unreadable URL: ${url}`);
222
+ return { number, url };
223
+ }
224
+
225
+ /**
226
+ * Push a finished task's branch and open its pull request, so the work lands
227
+ * in review without the oracle having to remember a second step.
228
+ */
229
+ export async function createPullRequest(options: { exec?: Exec; cwd: string; branch: string; base: string; title: string; body: string }): Promise<CreatedPull> {
230
+ for (const ref of [options.branch, options.base]) {
231
+ if (!ref || ref.startsWith("-") || /[\s~^:?*\[\\]|\.\.|@\{|\/\//.test(ref) || /[/.]$/.test(ref)) throw new Error("invalid pull request branch");
232
+ }
233
+ const exec = options.exec ?? execCommand;
234
+ await run(exec, options.cwd, "git", ["push", "-u", "origin", options.branch]);
235
+ const stdout = await gh(exec, options.cwd, ["pr", "create", "--base", options.base, "--head", options.branch, "--title", options.title, "--body", options.body.slice(0, 60_000)]);
236
+ return parseCreated(stdout);
237
+ }
238
+
196
239
  /** Lobby state for the Git tab: the list, the open pull request, and what is loading. */
197
240
  export class PullsState {
198
241
  pulls: PullSummary[] = [];
@@ -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;