@a-t-h-i/bot-lobby 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +9 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/feed.ts +76 -8
- package/src/lobby/layout.ts +38 -18
- package/src/lobby/markdown.ts +92 -19
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +51 -37
- package/src/lobby/session-files.ts +162 -25
- package/src/lobby/sessions.ts +21 -7
- package/src/lobby/tabs/home.ts +289 -67
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +141 -25
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +54 -12
- package/src/pi/fresh-context.ts +134 -0
- package/src/pi/owner.ts +19 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +11 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +9 -3
- package/src/pi/zen-large.ts +10 -10
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +22 -15
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +8 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/archive.ts +12 -3
- package/src/state/backlog.ts +13 -3
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/state/file-cache.ts +62 -0
- package/src/state/metrics.ts +63 -13
- package/src/state/persistence.ts +36 -2
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/width.ts +102 -0
- package/src/workflow/workflow.ts +592 -42
package/src/execution/git.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import { promisify } from "node:util";
|
|
3
5
|
import { truncate } from "../text.ts";
|
|
4
6
|
|
|
@@ -10,33 +12,128 @@ async function git(cwd: string, args: string[]): Promise<string> {
|
|
|
10
12
|
return stdout.trim();
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
/** `git diff HEAD
|
|
14
|
-
async function
|
|
15
|
+
/** `git diff <base>` (HEAD by default), falling back to index+worktree when HEAD does not exist yet. */
|
|
16
|
+
async function diffAgainst(cwd: string, base: string, extra: readonly string[] = [], paths: readonly string[] = []): Promise<string> {
|
|
15
17
|
try {
|
|
16
|
-
return await git(cwd, ["diff", "
|
|
18
|
+
return await git(cwd, ["diff", ...extra, base, "--", ...paths]);
|
|
17
19
|
} catch {
|
|
18
|
-
const [unstaged, staged] = await Promise.all([git(cwd, ["diff"]), git(cwd, ["diff", "--cached"])]);
|
|
20
|
+
const [unstaged, staged] = await Promise.all([git(cwd, ["diff", ...extra, "--", ...paths]), git(cwd, ["diff", ...extra, "--cached", "--", ...paths])]);
|
|
19
21
|
return [unstaged, staged].filter((part) => part.length > 0).join("\n");
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/** The commit HEAD points at, or undefined (no commits yet, or no git). */
|
|
26
|
+
export async function headCommit(cwd: string): Promise<string | undefined> {
|
|
27
|
+
try {
|
|
28
|
+
return (await git(cwd, ["rev-parse", "--verify", "HEAD"])) || undefined;
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The newest commit on HEAD made before `iso` (where a task started), or undefined. */
|
|
35
|
+
export async function commitBefore(cwd: string, iso: string): Promise<string | undefined> {
|
|
36
|
+
try {
|
|
37
|
+
return (await git(cwd, ["rev-list", "-1", `--before=${iso}`, "HEAD"])) || undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DiffOptions {
|
|
44
|
+
/** Bound on the whole text. */
|
|
45
|
+
limitChars?: number;
|
|
46
|
+
/** The commit to compare with (where the task started), so work already committed still shows; HEAD when absent. */
|
|
47
|
+
base?: string;
|
|
48
|
+
/** Paths (relative to `cwd`) left out: bot-lobby's own records. */
|
|
49
|
+
exclude?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Longest new file shown whole; past it (or binary) only its name is listed. */
|
|
53
|
+
const MAX_NEW_FILE_CHARS = 4000;
|
|
54
|
+
const MAX_NEW_FILE_BYTES = 200 * 1024;
|
|
55
|
+
|
|
23
56
|
/**
|
|
24
|
-
* Repository evidence for a reviewer: working-tree status
|
|
25
|
-
*
|
|
26
|
-
*
|
|
57
|
+
* Repository evidence for a reviewer: working-tree status, the files changed
|
|
58
|
+
* since `base` (listed first, so a long diff never hides which files moved),
|
|
59
|
+
* the diff itself, and the content of new files (which `git diff` leaves
|
|
60
|
+
* out). Returns an explanatory string instead of throwing when git is
|
|
61
|
+
* unusable so a review can still proceed on other evidence.
|
|
27
62
|
*/
|
|
28
|
-
export async function readRepositoryDiff(cwd: string,
|
|
63
|
+
export async function readRepositoryDiff(cwd: string, options: DiffOptions = {}): Promise<string> {
|
|
64
|
+
const limit = options.limitChars ?? 20_000;
|
|
65
|
+
const paths = options.exclude?.length ? [".", ...options.exclude.map((path) => `:(exclude)${path}`)] : [];
|
|
66
|
+
const base = options.base ?? "HEAD";
|
|
67
|
+
const label = options.base ? `since ${options.base.slice(0, 7)}, where the task started (committed work included)` : "HEAD";
|
|
29
68
|
try {
|
|
30
|
-
const [status, diff] = await Promise.all([
|
|
31
|
-
git(cwd, ["status", "--porcelain"]),
|
|
32
|
-
|
|
69
|
+
const [status, stat, diff, untracked] = await Promise.all([
|
|
70
|
+
git(cwd, ["status", "--porcelain", "--", ...paths]),
|
|
71
|
+
diffAgainst(cwd, base, ["--stat"], paths),
|
|
72
|
+
diffAgainst(cwd, base, [], paths),
|
|
73
|
+
git(cwd, ["ls-files", "--others", "--exclude-standard", "--", ...paths]),
|
|
33
74
|
]);
|
|
34
|
-
const
|
|
75
|
+
const head = [
|
|
35
76
|
status ? `Status:\n${status}` : "Status: clean working tree",
|
|
36
|
-
|
|
77
|
+
stat ? `Changed files (${label}):\n${stat}` : "",
|
|
78
|
+
].filter(Boolean).join("\n\n");
|
|
79
|
+
const fresh = untracked ? newFiles(cwd, untracked.split("\n").filter(Boolean)) : "";
|
|
80
|
+
const room = Math.max(0, limit - head.length);
|
|
81
|
+
// The diff and the new files share what is left; new files get at least a third when there are any.
|
|
82
|
+
const diffRoom = fresh ? Math.max(Math.floor(room * 0.66), room - fresh.length) : room;
|
|
83
|
+
const parts = [
|
|
84
|
+
head,
|
|
85
|
+
diff ? `Diff (${label}):\n${truncate(diff, diffRoom)}` : `Diff (${label}): none`,
|
|
86
|
+
fresh ? `New files (untracked):\n${truncate(fresh, Math.max(0, room - Math.min(diff.length, diffRoom)))}` : "",
|
|
37
87
|
];
|
|
38
|
-
return truncate(parts.join("\n\n"),
|
|
88
|
+
return truncate(parts.filter(Boolean).join("\n\n"), limit + 200);
|
|
39
89
|
} catch (error) {
|
|
40
90
|
return `Unable to read git state: ${(error as Error).message}`;
|
|
41
91
|
}
|
|
42
92
|
}
|
|
93
|
+
|
|
94
|
+
/** New files as a reviewer reads them: each whole when it is small text, otherwise just named. */
|
|
95
|
+
function newFiles(cwd: string, files: readonly string[]): string {
|
|
96
|
+
return files.map((file) => {
|
|
97
|
+
try {
|
|
98
|
+
const path = join(cwd, file);
|
|
99
|
+
if (statSync(path).size > MAX_NEW_FILE_BYTES) return `--- ${file} (large; read it directly)`;
|
|
100
|
+
const text = readFileSync(path, "utf8");
|
|
101
|
+
if (text.includes("\0")) return `--- ${file} (binary)`;
|
|
102
|
+
return `--- ${file}\n${truncate(text, MAX_NEW_FILE_CHARS)}`;
|
|
103
|
+
} catch {
|
|
104
|
+
return `--- ${file} (unreadable)`;
|
|
105
|
+
}
|
|
106
|
+
}).join("\n\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Most changed files one look reports; the rest are summarised by the diff. */
|
|
110
|
+
export const MAX_CHANGED_FILES = 400;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The repository's top folder and every file changed, relative to it: what
|
|
114
|
+
* `git status` reports (renames by their new path, untracked files one by
|
|
115
|
+
* one), plus what was committed since `base`; undefined when git is unusable.
|
|
116
|
+
*/
|
|
117
|
+
export async function changedFiles(cwd: string, base?: string): Promise<{ top: string; files: string[] } | undefined> {
|
|
118
|
+
try {
|
|
119
|
+
const [top, status, committed] = await Promise.all([
|
|
120
|
+
git(cwd, ["rev-parse", "--show-toplevel"]),
|
|
121
|
+
run("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd, maxBuffer: MAX_BUFFER }),
|
|
122
|
+
// Work committed since the task started is still the task's to review.
|
|
123
|
+
base ? run("git", ["diff", "--name-only", "-z", base], { cwd, maxBuffer: MAX_BUFFER }).then((result) => result.stdout, () => "") : Promise.resolve(""),
|
|
124
|
+
]);
|
|
125
|
+
const entries = status.stdout.split("\0");
|
|
126
|
+
const files = new Set<string>();
|
|
127
|
+
for (let index = 0; index < entries.length && files.size < MAX_CHANGED_FILES; index += 1) {
|
|
128
|
+
const entry = entries[index]!;
|
|
129
|
+
if (entry.length < 4) continue;
|
|
130
|
+
files.add(entry.slice(3));
|
|
131
|
+
// A rename or copy is followed by the path it came from.
|
|
132
|
+
if (/[RC]/.test(entry.slice(0, 2))) index += 1;
|
|
133
|
+
}
|
|
134
|
+
for (const file of committed.split("\0")) if (file && files.size < MAX_CHANGED_FILES) files.add(file);
|
|
135
|
+
return { top, files: [...files] };
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -4,12 +4,20 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
5
|
import { activityWord } from "../pi/activity.ts";
|
|
6
6
|
import { shortDuration } from "../text.ts";
|
|
7
|
+
import { readRelayRequest } from "../ask/relay.ts";
|
|
8
|
+
import type { AskQuestion, AskResult } from "../ask/types.ts";
|
|
7
9
|
|
|
8
10
|
/** Live control over one running subagent: queue a steering message for its next turn. */
|
|
9
11
|
export interface RunHandle {
|
|
10
12
|
steer(text: string): void;
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Puts a subagent's relayed questions to the user (see ask/relay.ts). The
|
|
17
|
+
* signal aborts when the run ends first, so the questionnaire is put away.
|
|
18
|
+
*/
|
|
19
|
+
export type RelayAsk = (questions: AskQuestion[], signal: AbortSignal) => Promise<AskResult>;
|
|
20
|
+
|
|
13
21
|
export interface PiRunOptions {
|
|
14
22
|
cwd: string;
|
|
15
23
|
task: string;
|
|
@@ -33,6 +41,28 @@ export interface PiRunOptions {
|
|
|
33
41
|
wrapUpAtMs?: number;
|
|
34
42
|
/** Receives the steering handle once the process is running. */
|
|
35
43
|
onStart?: (handle: RunHandle) => void;
|
|
44
|
+
/** Under a task time budget: when the agent is warned, when its time is up, and who decides on more (see ProcessRunOptions). */
|
|
45
|
+
time?: TimeLimit;
|
|
46
|
+
/** Answers the agent's relayed questions; without it they are cancelled like any other dialog. */
|
|
47
|
+
onAsk?: RelayAsk;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A run's allotted time under a task budget. At `headsUpAtMs` the agent is
|
|
52
|
+
* told how much is left; at `upAtMs` it is steered to stop and report where it
|
|
53
|
+
* left off. Once that report is in (its turn ended), `onTimeUp` decides: more
|
|
54
|
+
* ms keeps the same process going with its context (a follow-up prompt), 0
|
|
55
|
+
* ends it. The hard deadline (`timeoutMs`) and the stall watchdog wait while
|
|
56
|
+
* the decision is pending, and move out with any time granted.
|
|
57
|
+
*/
|
|
58
|
+
export interface TimeLimit {
|
|
59
|
+
upAtMs: number;
|
|
60
|
+
headsUpAtMs?: number;
|
|
61
|
+
headsUpMessage?: string;
|
|
62
|
+
upMessage: string;
|
|
63
|
+
onTimeUp?: (report: string) => Promise<{ extraMs: number; message?: string }>;
|
|
64
|
+
/** Time the agent gets to write its report after `upAtMs` before it is stopped. */
|
|
65
|
+
graceMs: number;
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
export interface ProcessOutcome {
|
|
@@ -47,6 +77,10 @@ export interface ProcessOutcome {
|
|
|
47
77
|
wrappedUp?: boolean;
|
|
48
78
|
/** The RPC host rejected the prompt itself. */
|
|
49
79
|
protocolError?: string;
|
|
80
|
+
/** Stopped at its allotted time and not given more: its report says where it left off. */
|
|
81
|
+
timeUp?: boolean;
|
|
82
|
+
/** Time granted past its allotment, in ms. */
|
|
83
|
+
extendedMs?: number;
|
|
50
84
|
}
|
|
51
85
|
|
|
52
86
|
/** Streaming agent events forwarded as they arrive on stdout. */
|
|
@@ -61,8 +95,16 @@ export type PiStreamEvent =
|
|
|
61
95
|
| { type: "compaction" }
|
|
62
96
|
| { type: "usage"; input: number; output: number; cost: number; model?: string }
|
|
63
97
|
| { type: "wrap_up" }
|
|
98
|
+
/** Its allotted time is up: it was asked to stop and report where it left off. */
|
|
99
|
+
| { type: "time_up" }
|
|
100
|
+
/** It was given `ms` more and carries on. */
|
|
101
|
+
| { type: "extended"; ms: number }
|
|
64
102
|
/** One finished thinking block, bounded to `MAX_THOUGHT_CHARS`. */
|
|
65
103
|
| { type: "thought"; text: string }
|
|
104
|
+
/** It asked the user (relayed); its clocks wait until the answers are back. */
|
|
105
|
+
| { type: "asking"; questions: number }
|
|
106
|
+
/** The answers went back after `waitedMs` of waiting on the user. */
|
|
107
|
+
| { type: "answered"; waitedMs: number }
|
|
66
108
|
| { type: "heartbeat" };
|
|
67
109
|
|
|
68
110
|
/** Longest thought forwarded from a subagent stream. */
|
|
@@ -84,6 +126,8 @@ export interface ProcessRunOptions {
|
|
|
84
126
|
onStart?: (handle: RunHandle) => void;
|
|
85
127
|
/** Grace between the deadline abort and the hard kill. */
|
|
86
128
|
graceMs?: number;
|
|
129
|
+
time?: TimeLimit;
|
|
130
|
+
onAsk?: RelayAsk;
|
|
87
131
|
}
|
|
88
132
|
|
|
89
133
|
export type ProcessRunner = (
|
|
@@ -99,6 +143,8 @@ export interface PiRunResult {
|
|
|
99
143
|
model?: string;
|
|
100
144
|
stalled?: boolean;
|
|
101
145
|
wrappedUp?: boolean;
|
|
146
|
+
timeUp?: boolean;
|
|
147
|
+
extendedMs?: number;
|
|
102
148
|
}
|
|
103
149
|
|
|
104
150
|
export interface ParsedStream {
|
|
@@ -134,12 +180,17 @@ export interface ControlEvent {
|
|
|
134
180
|
method?: string;
|
|
135
181
|
willRetry?: boolean;
|
|
136
182
|
delayMs?: number;
|
|
183
|
+
/** extension_ui_request: the dialog's title and (editor) prefilled text. */
|
|
184
|
+
title?: string;
|
|
185
|
+
prefill?: string;
|
|
137
186
|
}
|
|
138
187
|
|
|
139
188
|
/** Buffers one JSON-mode stream, keeping only lines parsePiStream consumes. */
|
|
140
189
|
export interface StreamCollector {
|
|
141
190
|
push(chunk: string): void;
|
|
142
191
|
finish(): string;
|
|
192
|
+
/** What is kept so far, without ending the stream. */
|
|
193
|
+
peek(): string;
|
|
143
194
|
}
|
|
144
195
|
|
|
145
196
|
const INTERESTING = [
|
|
@@ -268,6 +319,9 @@ export function createStreamCollector(
|
|
|
268
319
|
if (partial.length > 0) keep(partial.splice(0).join(""));
|
|
269
320
|
return kept.join("\n");
|
|
270
321
|
},
|
|
322
|
+
peek() {
|
|
323
|
+
return kept.join("\n");
|
|
324
|
+
},
|
|
271
325
|
};
|
|
272
326
|
}
|
|
273
327
|
|
|
@@ -422,6 +476,14 @@ interface RunState {
|
|
|
422
476
|
wrappedUp: boolean;
|
|
423
477
|
settled: boolean;
|
|
424
478
|
protocolError?: string;
|
|
479
|
+
/** Told its time is up; its next turn end waits on the decision. */
|
|
480
|
+
timeUp: boolean;
|
|
481
|
+
headsUp: boolean;
|
|
482
|
+
/** Waiting on the decision: the agent is idle, so the deadline and the stall watchdog wait too. */
|
|
483
|
+
deciding: boolean;
|
|
484
|
+
/** Relayed questions waiting on the user: every clock and the watchdog wait. */
|
|
485
|
+
asking: number;
|
|
486
|
+
extendedMs: number;
|
|
425
487
|
}
|
|
426
488
|
|
|
427
489
|
/** Smallest positive limit, used to pick a watchdog cadence that can see it. */
|
|
@@ -452,9 +514,14 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
|
|
|
452
514
|
windowsHide: true,
|
|
453
515
|
stdio: ["pipe", "pipe", "pipe"],
|
|
454
516
|
});
|
|
455
|
-
const state: RunState = { killed: false, timedOut: false, stalled: false, wrappedUp: false, settled: false };
|
|
517
|
+
const state: RunState = { killed: false, timedOut: false, stalled: false, wrappedUp: false, settled: false, timeUp: false, headsUp: false, deciding: false, asking: 0, extendedMs: 0 };
|
|
456
518
|
const input = rpcInput(proc);
|
|
457
519
|
const started = Date.now();
|
|
520
|
+
const time = options.time;
|
|
521
|
+
// The allotted time counts from here; a grant restarts it.
|
|
522
|
+
let clockStart = started;
|
|
523
|
+
let upAtMs = time?.upAtMs ?? 0;
|
|
524
|
+
let headsUpAtMs = time?.headsUpAtMs ?? 0;
|
|
458
525
|
let lastOutput = started;
|
|
459
526
|
let quietGraceUntil = 0;
|
|
460
527
|
let toolsInFlight = 0;
|
|
@@ -462,6 +529,11 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
|
|
|
462
529
|
let exitCode: number | undefined;
|
|
463
530
|
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
464
531
|
let done = false;
|
|
532
|
+
// Time spent waiting on the user's answers is not the agent's: its limits move out by it.
|
|
533
|
+
let deadlineAt = started + options.timeoutMs;
|
|
534
|
+
let askStarted = 0;
|
|
535
|
+
let pausedMs = 0;
|
|
536
|
+
const asks = new AbortController();
|
|
465
537
|
|
|
466
538
|
const onEvent = (event: PiStreamEvent) => {
|
|
467
539
|
if (event.type === "tool_execution_start") toolsInFlight += 1;
|
|
@@ -469,18 +541,84 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
|
|
|
469
541
|
if (event.type === "retry") quietGraceUntil = Date.now() + event.delayMs;
|
|
470
542
|
options.onEvent?.(event);
|
|
471
543
|
};
|
|
544
|
+
// A turn that ended after the time ran out waits on the decision; any other ends the run.
|
|
545
|
+
let turnDone = false;
|
|
546
|
+
const turnOver = () => {
|
|
547
|
+
if (turnDone) return;
|
|
548
|
+
turnDone = true;
|
|
549
|
+
clearTimeout(settleTimer);
|
|
550
|
+
settleTimer = undefined;
|
|
551
|
+
if (state.timeUp && time?.onTimeUp && !done && !state.killed) decide(time.onTimeUp);
|
|
552
|
+
else input.close();
|
|
553
|
+
};
|
|
554
|
+
const decide = (onTimeUp: NonNullable<TimeLimit["onTimeUp"]>) => {
|
|
555
|
+
state.deciding = true;
|
|
556
|
+
clearTimeout(deadline);
|
|
557
|
+
const report = parsePiStream(collector.peek()).text;
|
|
558
|
+
onTimeUp(report).then(({ extraMs, message }) => {
|
|
559
|
+
if (done || state.killed) return;
|
|
560
|
+
state.deciding = false;
|
|
561
|
+
if (extraMs <= 0) return input.close();
|
|
562
|
+
// Granted: the same agent carries on where it stopped, its context intact.
|
|
563
|
+
state.timeUp = false;
|
|
564
|
+
state.headsUp = false;
|
|
565
|
+
state.settled = false;
|
|
566
|
+
state.extendedMs += extraMs;
|
|
567
|
+
turnDone = false;
|
|
568
|
+
clockStart = Date.now();
|
|
569
|
+
lastOutput = clockStart;
|
|
570
|
+
upAtMs = extraMs;
|
|
571
|
+
// The user just named the time it has: no heads-up quoting the old allotment.
|
|
572
|
+
headsUpAtMs = 0;
|
|
573
|
+
deadlineAt = Date.now() + extraMs + (time?.graceMs ?? 0);
|
|
574
|
+
deadline = setTimeout(onDeadline, extraMs + (time?.graceMs ?? 0));
|
|
575
|
+
input.send({ type: "prompt", message: message ?? "You have more time: carry on from where you left off and finish." });
|
|
576
|
+
options.onEvent?.({ type: "extended", ms: extraMs });
|
|
577
|
+
}, () => {
|
|
578
|
+
state.deciding = false;
|
|
579
|
+
input.close();
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
const relay = (id: string | undefined, questions: AskQuestion[], ask: RelayAsk) => {
|
|
583
|
+
state.asking += 1;
|
|
584
|
+
if (state.asking === 1) {
|
|
585
|
+
askStarted = Date.now();
|
|
586
|
+
clearTimeout(deadline);
|
|
587
|
+
}
|
|
588
|
+
options.onEvent?.({ type: "asking", questions: questions.length });
|
|
589
|
+
ask(questions, asks.signal)
|
|
590
|
+
.catch((): AskResult => ({ answers: [], cancelled: true }))
|
|
591
|
+
.then((result) => {
|
|
592
|
+
state.asking -= 1;
|
|
593
|
+
if (done || state.killed) return;
|
|
594
|
+
if (state.asking === 0) {
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
const waited = now - askStarted;
|
|
597
|
+
pausedMs += waited;
|
|
598
|
+
deadlineAt += waited;
|
|
599
|
+
clockStart += waited;
|
|
600
|
+
lastOutput = now;
|
|
601
|
+
if (!state.deciding) deadline = setTimeout(onDeadline, Math.max(0, deadlineAt - now));
|
|
602
|
+
options.onEvent?.({ type: "answered", waitedMs: waited });
|
|
603
|
+
}
|
|
604
|
+
input.send({ type: "extension_ui_response", id, value: JSON.stringify(result) });
|
|
605
|
+
});
|
|
606
|
+
};
|
|
472
607
|
const onControl = (event: ControlEvent) => {
|
|
608
|
+
const relayed = event.type === "extension_ui_request" && event.method === "editor" && options.onAsk ? readRelayRequest(event.title, event.prefill) : undefined;
|
|
473
609
|
if (event.type === "response" && event.command === "prompt" && event.success === false) {
|
|
474
610
|
state.protocolError = event.error ?? "the prompt was rejected";
|
|
475
611
|
input.close();
|
|
612
|
+
} else if (relayed && options.onAsk) {
|
|
613
|
+
relay(event.id, relayed, options.onAsk);
|
|
476
614
|
} else if (event.type === "extension_ui_request" && event.method && DIALOG_METHODS.has(event.method)) {
|
|
477
615
|
// Nobody can answer a dialog inside a subagent; cancel it instead of blocking forever.
|
|
478
616
|
input.send({ type: "extension_ui_response", id: event.id, cancelled: true });
|
|
479
617
|
} else if (event.type === "agent_settled") {
|
|
480
618
|
state.settled = true;
|
|
481
|
-
|
|
482
|
-
} else if (event.type === "agent_end" && event.willRetry !== true && !settleTimer) {
|
|
483
|
-
settleTimer = setTimeout(
|
|
619
|
+
turnOver();
|
|
620
|
+
} else if (event.type === "agent_end" && event.willRetry !== true && !settleTimer && !turnDone) {
|
|
621
|
+
settleTimer = setTimeout(turnOver, SETTLE_FALLBACK_MS);
|
|
484
622
|
}
|
|
485
623
|
};
|
|
486
624
|
const collector = createStreamCollector(onEvent, onControl);
|
|
@@ -501,12 +639,15 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
|
|
|
501
639
|
clearTimeout(deadline);
|
|
502
640
|
clearTimeout(settleTimer);
|
|
503
641
|
clearInterval(watch);
|
|
642
|
+
// Questions still open when the run ends are put away.
|
|
643
|
+
asks.abort();
|
|
504
644
|
options.signal?.removeEventListener("abort", onAbort);
|
|
505
645
|
input.close();
|
|
506
646
|
// Reap anything the agent left running in its group.
|
|
507
647
|
if (POSIX) signalGroup(proc, "SIGTERM");
|
|
508
648
|
const { killed, timedOut, stalled, wrappedUp, protocolError } = state;
|
|
509
|
-
|
|
649
|
+
const timeUp = state.timeUp && !killed;
|
|
650
|
+
resolve({ exitCode: code, stdout: collector.finish(), stderr, killed, timedOut, stalled, wrappedUp, protocolError, ...(timeUp ? { timeUp } : {}), ...(state.extendedMs > 0 ? { extendedMs: state.extendedMs } : {}) });
|
|
510
651
|
};
|
|
511
652
|
const onAbort = () => {
|
|
512
653
|
state.killed = true;
|
|
@@ -514,21 +655,31 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
|
|
|
514
655
|
input.close();
|
|
515
656
|
terminate(proc, 3000);
|
|
516
657
|
};
|
|
517
|
-
const
|
|
658
|
+
const onDeadline = () => {
|
|
518
659
|
state.killed = true;
|
|
519
660
|
state.timedOut = true;
|
|
520
661
|
input.send({ type: "abort" });
|
|
521
662
|
input.close();
|
|
522
663
|
setTimeout(() => terminate(proc), options.graceMs ?? DEADLINE_GRACE_MS).unref();
|
|
523
|
-
}
|
|
664
|
+
};
|
|
665
|
+
let deadline = setTimeout(onDeadline, options.timeoutMs);
|
|
524
666
|
const watch = setInterval(() => {
|
|
525
|
-
if (state.killed || done) return;
|
|
667
|
+
if (state.killed || done || state.deciding || state.asking > 0) return;
|
|
526
668
|
const now = Date.now();
|
|
527
|
-
if (!state.wrappedUp && !state.settled && options.wrapUpAtMs && options.wrapUpAtMs > 0 && now - started >= options.wrapUpAtMs) {
|
|
669
|
+
if (!state.wrappedUp && !state.settled && options.wrapUpAtMs && options.wrapUpAtMs > 0 && now - started - pausedMs >= options.wrapUpAtMs) {
|
|
528
670
|
state.wrappedUp = true;
|
|
529
671
|
input.send({ type: "steer", message: options.wrapUpMessage ?? WRAP_UP_MESSAGE });
|
|
530
672
|
options.onEvent?.({ type: "wrap_up" });
|
|
531
673
|
}
|
|
674
|
+
if (time && !state.headsUp && !state.timeUp && !turnDone && headsUpAtMs > 0 && now - clockStart >= headsUpAtMs) {
|
|
675
|
+
state.headsUp = true;
|
|
676
|
+
if (time.headsUpMessage) input.send({ type: "steer", message: time.headsUpMessage });
|
|
677
|
+
}
|
|
678
|
+
if (time && !state.timeUp && !turnDone && upAtMs > 0 && now - clockStart >= upAtMs) {
|
|
679
|
+
state.timeUp = true;
|
|
680
|
+
input.send({ type: "steer", message: time.upMessage });
|
|
681
|
+
options.onEvent?.({ type: "time_up" });
|
|
682
|
+
}
|
|
532
683
|
const limit = toolsInFlight > 0 ? options.toolStallTimeoutMs : options.stallTimeoutMs;
|
|
533
684
|
if (limit && limit > 0 && now - Math.max(lastOutput, quietGraceUntil) >= limit) {
|
|
534
685
|
state.killed = true;
|
|
@@ -536,7 +687,7 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
|
|
|
536
687
|
input.close();
|
|
537
688
|
terminate(proc, 3000);
|
|
538
689
|
}
|
|
539
|
-
}, watchInterval(options.stallTimeoutMs, options.toolStallTimeoutMs, options.wrapUpAtMs));
|
|
690
|
+
}, watchInterval(options.stallTimeoutMs, options.toolStallTimeoutMs, options.wrapUpAtMs, time?.upAtMs, time?.headsUpAtMs));
|
|
540
691
|
watch.unref();
|
|
541
692
|
|
|
542
693
|
proc.on("exit", (code, signal) => {
|
|
@@ -557,7 +708,7 @@ export function spawnPiProcess(args: string[], options: ProcessRunOptions): Prom
|
|
|
557
708
|
}
|
|
558
709
|
|
|
559
710
|
function toResult(parsed: ParsedStream, outcome: ProcessOutcome, aborted: boolean, options: PiRunOptions): PiRunResult {
|
|
560
|
-
const base = { output: parsed.text, usage: parsed.usage, model: parsed.model, wrappedUp: outcome.wrappedUp === true };
|
|
711
|
+
const base = { output: parsed.text, usage: parsed.usage, model: parsed.model, wrappedUp: outcome.wrappedUp === true, ...(outcome.timeUp ? { timeUp: true } : {}), ...(outcome.extendedMs ? { extendedMs: outcome.extendedMs } : {}) };
|
|
561
712
|
if (outcome.stalled) {
|
|
562
713
|
const quiet = shortDuration(options.stallTimeoutMs ?? 0);
|
|
563
714
|
return { ...base, status: "timeout", stalled: true, error: `stalled: no output for ${quiet}` };
|
|
@@ -602,6 +753,8 @@ export async function runPiAgent(options: PiRunOptions, run: ProcessRunner = spa
|
|
|
602
753
|
stallTimeoutMs: options.stallTimeoutMs,
|
|
603
754
|
toolStallTimeoutMs: options.toolStallTimeoutMs,
|
|
604
755
|
wrapUpAtMs: options.wrapUpAtMs,
|
|
756
|
+
...(options.time ? { time: options.time } : {}),
|
|
757
|
+
...(options.onAsk ? { onAsk: options.onAsk } : {}),
|
|
605
758
|
onStart: options.onStart,
|
|
606
759
|
onEvent: (event) => {
|
|
607
760
|
if (event.type === "tool_execution_start") options.onActivity?.(activityWord(event.toolName));
|
package/src/index.ts
CHANGED
|
@@ -10,10 +10,15 @@ import { registerLobbyEvents } from "./lobby/runtime.ts";
|
|
|
10
10
|
import { registerOwner } from "./pi/owner.ts";
|
|
11
11
|
import { registerClassifier } from "./classifier/instance.ts";
|
|
12
12
|
import { registerClassifierTools } from "./classifier/tools.ts";
|
|
13
|
+
import { registerFreshContext } from "./pi/fresh-context.ts";
|
|
14
|
+
import { registerAskTool } from "./ask/tool.ts";
|
|
15
|
+
import { registerWebTools } from "./web/tools.ts";
|
|
13
16
|
|
|
14
17
|
export default function (pi: ExtensionAPI): void {
|
|
15
18
|
// First, so every session_start handler below finds the classifier bound to this session's keys.
|
|
16
19
|
registerClassifier(pi, CONFIG_DIR_NAME);
|
|
20
|
+
// Before the lifecycle, so a task that ended while the oracle was idle is closed before it builds the next turn's prompt.
|
|
21
|
+
registerFreshContext(pi, CONFIG_DIR_NAME);
|
|
17
22
|
registerLifecycle(pi, CONFIG_DIR_NAME);
|
|
18
23
|
// After the lifecycle, so the lobby opens over a task the widget state already knows.
|
|
19
24
|
registerLobbyEvents(pi, CONFIG_DIR_NAME);
|
|
@@ -21,6 +26,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
21
26
|
onTransition((task) => pingTransition(task));
|
|
22
27
|
registerCommands(pi, CONFIG_DIR_NAME);
|
|
23
28
|
registerOrchestrateTool(pi, CONFIG_DIR_NAME);
|
|
29
|
+
// The questionnaire the oracle (and pi without a task) asks the user with; no other extension is needed for it.
|
|
30
|
+
registerAskTool(pi);
|
|
31
|
+
// The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
|
|
32
|
+
registerWebTools(pi);
|
|
24
33
|
// Parallel workers share files through the master's file desk.
|
|
25
34
|
if (isSubagentProcess()) registerDeskClient(pi);
|
|
26
35
|
// Subagents look files up with the classifier (the engine allows the tool only while file hints are on).
|
package/src/lobby/ask.ts
CHANGED
|
@@ -1,59 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The oracle puts the planning panel's questions to the user
|
|
3
|
-
*
|
|
4
|
-
* a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* calling that entry with a `pi` whose `registerTool` keeps the definition
|
|
8
|
-
* instead of registering it; its `execute` then runs the real questionnaire.
|
|
9
|
-
* Without the library, pi's own select and input dialogs ask the same
|
|
10
|
-
* questions in the same order.
|
|
2
|
+
* The oracle puts the planning panel's questions to the user through the
|
|
3
|
+
* questionnaire (`../ask`): a tab per question with each seat's options, the
|
|
4
|
+
* recommended one first, and a row for an answer in the user's own words.
|
|
5
|
+
* The panel's questions become questionnaires of at most four, and the
|
|
6
|
+
* answers become the user's turn for the next round.
|
|
11
7
|
*/
|
|
12
|
-
import type {
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
9
|
import type { PanelQuestion } from "./planner.ts";
|
|
10
|
+
import { MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskAnswer, type AskOption, type AskQuestion, type AskResult } from "../ask/types.ts";
|
|
11
|
+
import type { Asker } from "../ask/dialog.ts";
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
export
|
|
17
|
-
export const MIN_OPTIONS = 2;
|
|
18
|
-
export const MAX_OPTIONS = 4;
|
|
19
|
-
const MAX_HEADER = 16;
|
|
20
|
-
const MAX_LABEL = 60;
|
|
21
|
-
/** Labels the library reserves for its own rows. */
|
|
22
|
-
const RESERVED = new Set(["other", "type something.", "next"]);
|
|
23
|
-
|
|
24
|
-
export interface AskOption {
|
|
25
|
-
label: string;
|
|
26
|
-
description: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface AskQuestion {
|
|
30
|
-
question: string;
|
|
31
|
-
header: string;
|
|
32
|
-
options: AskOption[];
|
|
33
|
-
multiSelect?: boolean;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface AskAnswer {
|
|
37
|
-
questionIndex: number;
|
|
38
|
-
question: string;
|
|
39
|
-
kind: "option" | "custom" | "multi";
|
|
40
|
-
answer: string | null;
|
|
41
|
-
selected?: string[];
|
|
42
|
-
notes?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface AskResult {
|
|
46
|
-
answers: AskAnswer[];
|
|
47
|
-
cancelled: boolean;
|
|
48
|
-
globalNote?: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Puts up to `MAX_QUESTIONS` questions to the user and returns what they chose. */
|
|
52
|
-
export type Asker = (questions: readonly AskQuestion[], ctx: ExtensionContext) => Promise<AskResult>;
|
|
53
|
-
|
|
54
|
-
interface ToolLike {
|
|
55
|
-
execute(toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ details?: unknown }>;
|
|
56
|
-
}
|
|
13
|
+
export { MAX_QUESTIONS, MIN_OPTIONS, MAX_OPTIONS, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type Asker };
|
|
14
|
+
export { askUser, dialogAsker } from "../ask/dialog.ts";
|
|
57
15
|
|
|
58
16
|
function clip(text: string, max: number): string {
|
|
59
17
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
@@ -143,74 +101,6 @@ export function answerMessage(questions: readonly PanelQuestion[], results: read
|
|
|
143
101
|
].join("\n");
|
|
144
102
|
}
|
|
145
103
|
|
|
146
|
-
/**
|
|
147
|
-
* Capture the library's tool through its public extension entry: the entry
|
|
148
|
-
* registers the tool on the `pi` it is given, so a `pi` whose `registerTool`
|
|
149
|
-
* keeps the definition (and whose `on` ignores the library's own tool-list
|
|
150
|
-
* reconciler) yields it without touching the session's tools. Undefined when
|
|
151
|
-
* the library is missing or registers nothing.
|
|
152
|
-
*/
|
|
153
|
-
/**
|
|
154
|
-
* The library ships TypeScript sources; a non-literal specifier keeps them out
|
|
155
|
-
* of this package's typecheck (they are compiled by pi's loader at runtime).
|
|
156
|
-
*/
|
|
157
|
-
const ASK_LIBRARY: string = "@juicesharp/rpiv-ask-user-question";
|
|
158
|
-
|
|
159
|
-
export async function loadAskTool(pi: ExtensionAPI, load: () => Promise<unknown> = () => import(ASK_LIBRARY)): Promise<ToolLike | undefined> {
|
|
160
|
-
try {
|
|
161
|
-
const module = (await load()) as { default?: (pi: ExtensionAPI) => void };
|
|
162
|
-
if (typeof module.default !== "function") return undefined;
|
|
163
|
-
let captured: ToolLike | undefined;
|
|
164
|
-
const capture = new Proxy(pi, {
|
|
165
|
-
get(target, property, receiver) {
|
|
166
|
-
if (property === "registerTool") return (tool: ToolLike & { name?: string }) => {
|
|
167
|
-
if (tool.name === "ask_user_question") captured = tool;
|
|
168
|
-
};
|
|
169
|
-
if (property === "on") return () => () => {};
|
|
170
|
-
return Reflect.get(target, property, receiver);
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
module.default(capture);
|
|
174
|
-
return captured;
|
|
175
|
-
} catch {
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Run one questionnaire through the captured tool. */
|
|
181
|
-
export function toolAsker(tool: ToolLike): Asker {
|
|
182
|
-
return async (questions, ctx) => {
|
|
183
|
-
const result = await tool.execute(`bot-lobby-panel-${Date.now().toString(36)}`, { questions }, undefined, undefined, ctx);
|
|
184
|
-
const details = result.details as Partial<AskResult> | undefined;
|
|
185
|
-
return { answers: details?.answers ?? [], cancelled: details?.cancelled !== false, ...(details?.globalNote ? { globalNote: details.globalNote } : {}) };
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const TYPE_ANSWER = "Type an answer…";
|
|
190
|
-
const SKIP = "Skip";
|
|
191
|
-
|
|
192
|
-
/** The same questions through pi's built-in dialogs: pick an option, type an answer, or skip; esc stops. */
|
|
193
|
-
export function dialogAsker(): Asker {
|
|
194
|
-
return async (questions, ctx) => {
|
|
195
|
-
const answers: AskAnswer[] = [];
|
|
196
|
-
for (const [index, question] of questions.entries()) {
|
|
197
|
-
const choices = [...question.options.map((option) => option.label), TYPE_ANSWER, SKIP];
|
|
198
|
-
const title = `${question.header} · ${index + 1}/${questions.length}\n\n${question.question}`;
|
|
199
|
-
const choice = await ctx.ui.select(title, choices);
|
|
200
|
-
if (choice === undefined) return { answers, cancelled: true };
|
|
201
|
-
if (choice === SKIP) continue;
|
|
202
|
-
if (choice === TYPE_ANSWER) {
|
|
203
|
-
const typed = await ctx.ui.input(question.question, "your answer");
|
|
204
|
-
if (typed === undefined) return { answers, cancelled: true };
|
|
205
|
-
if (typed.trim()) answers.push({ questionIndex: index, question: question.question, kind: "custom", answer: typed.trim() });
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
answers.push({ questionIndex: index, question: question.question, kind: "option", answer: choice });
|
|
209
|
-
}
|
|
210
|
-
return { answers, cancelled: false };
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
104
|
/**
|
|
215
105
|
* Put every question to the user, one questionnaire after another. Stopping
|
|
216
106
|
* a questionnaire (esc) stops the rest; what was answered before it is kept.
|