@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.
Files changed (69) hide show
  1. package/README.md +112 -11
  2. package/package.json +1 -4
  3. package/prompts/master.md +47 -1
  4. package/prompts/researcher.md +8 -2
  5. package/prompts/reviewer.md +42 -0
  6. package/prompts/worker.md +7 -0
  7. package/src/ask/dialog.ts +167 -0
  8. package/src/ask/image.ts +202 -0
  9. package/src/ask/png.ts +179 -0
  10. package/src/ask/relay.ts +89 -0
  11. package/src/ask/state.ts +160 -0
  12. package/src/ask/tool.ts +126 -0
  13. package/src/ask/types.ts +55 -0
  14. package/src/ask/view.ts +159 -0
  15. package/src/execution/agent-runner.ts +103 -11
  16. package/src/execution/git.ts +111 -14
  17. package/src/execution/pi-runner.ts +164 -11
  18. package/src/index.ts +9 -0
  19. package/src/lobby/ask.ts +10 -120
  20. package/src/lobby/feed.ts +76 -8
  21. package/src/lobby/layout.ts +38 -18
  22. package/src/lobby/markdown.ts +92 -19
  23. package/src/lobby/planner.ts +1 -1
  24. package/src/lobby/quickfix.ts +21 -0
  25. package/src/lobby/runtime.ts +51 -37
  26. package/src/lobby/session-files.ts +162 -25
  27. package/src/lobby/sessions.ts +21 -7
  28. package/src/lobby/tabs/home.ts +289 -67
  29. package/src/lobby/tabs/issues.ts +4 -3
  30. package/src/lobby/tabs/plan.ts +4 -4
  31. package/src/lobby/tabs/quickfix.ts +7 -1
  32. package/src/lobby/tabs/tasks.ts +14 -4
  33. package/src/lobby/theme.ts +30 -0
  34. package/src/lobby/view.ts +141 -25
  35. package/src/master/decisions.ts +1 -1
  36. package/src/master/master.ts +41 -4
  37. package/src/master/research.ts +5 -2
  38. package/src/pi/commands.ts +94 -10
  39. package/src/pi/events.ts +54 -12
  40. package/src/pi/fresh-context.ts +134 -0
  41. package/src/pi/owner.ts +19 -10
  42. package/src/pi/quiet.ts +22 -4
  43. package/src/pi/start-task.ts +11 -2
  44. package/src/pi/tools.ts +26 -8
  45. package/src/pi/ui.ts +9 -3
  46. package/src/pi/zen-large.ts +10 -10
  47. package/src/pi/zen-metrics.ts +13 -3
  48. package/src/pi/zen.ts +22 -15
  49. package/src/roles/reviewer.ts +23 -4
  50. package/src/roles/worker.ts +18 -0
  51. package/src/schemas/configuration.ts +8 -0
  52. package/src/schemas/findings.ts +14 -0
  53. package/src/schemas/task.ts +21 -0
  54. package/src/state/archive.ts +12 -3
  55. package/src/state/backlog.ts +13 -3
  56. package/src/state/budget.ts +274 -0
  57. package/src/state/changes.ts +231 -0
  58. package/src/state/file-cache.ts +62 -0
  59. package/src/state/metrics.ts +63 -13
  60. package/src/state/persistence.ts +36 -2
  61. package/src/text.ts +28 -2
  62. package/src/web/extract.ts +332 -0
  63. package/src/web/fetch.ts +232 -0
  64. package/src/web/html.ts +183 -0
  65. package/src/web/read.ts +113 -0
  66. package/src/web/search.ts +202 -0
  67. package/src/web/tools.ts +279 -0
  68. package/src/width.ts +102 -0
  69. package/src/workflow/workflow.ts +592 -42
@@ -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`, falling back to index+worktree when HEAD does not exist yet. */
14
- async function diffAgainstHead(cwd: string): Promise<string> {
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", "HEAD"]);
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 plus the diff against
25
- * HEAD. Returns an explanatory string instead of throwing when git is unusable
26
- * so a review can still proceed on other evidence.
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, limitChars = 20_000): Promise<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
- diffAgainstHead(cwd),
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 parts = [
75
+ const head = [
35
76
  status ? `Status:\n${status}` : "Status: clean working tree",
36
- diff ? `Diff (HEAD):\n${diff}` : "Diff (HEAD): none",
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"), limitChars);
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
- input.close();
482
- } else if (event.type === "agent_end" && event.willRetry !== true && !settleTimer) {
483
- settleTimer = setTimeout(() => input.close(), SETTLE_FALLBACK_MS);
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
- resolve({ exitCode: code, stdout: collector.finish(), stderr, killed, timedOut, stalled, wrappedUp, protocolError });
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 deadline = setTimeout(() => {
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
- }, options.timeoutMs);
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 one at a time
3
- * through the ask-user-question library (`@juicesharp/rpiv-ask-user-question`):
4
- * a tabbed questionnaire with each seat's options, the recommended one first,
5
- * and a free-text row on every question. The library only publishes a pi
6
- * extension entry point, so its `ask_user_question` tool is captured by
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 { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
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
- /** The library's limits: at most 4 questions per questionnaire, 2-4 options each. */
16
- export const MAX_QUESTIONS = 4;
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.