@a-t-h-i/bot-lobby 0.6.3 → 0.6.5
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 +180 -17
- package/package.json +12 -6
- package/prompts/backend.md +3 -1
- package/prompts/designer.md +24 -1
- package/prompts/master.md +98 -19
- package/prompts/quickfix.md +16 -3
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/agents/backend.ts +2 -2
- package/src/agents/designer.ts +2 -2
- 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/classifier/triage.ts +24 -7
- 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 +6 -1
- package/src/lobby/layout.ts +27 -8
- package/src/lobby/markdown.ts +36 -6
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +50 -3
- package/src/lobby/runtime.ts +41 -40
- package/src/lobby/tabs/home.ts +112 -48
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +8 -2
- package/src/lobby/tabs/tasks.ts +16 -5
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +36 -5
- package/src/master/decisions.ts +10 -2
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +108 -13
- package/src/pi/events.ts +48 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/route.ts +180 -0
- package/src/pi/start-task.ts +56 -4
- package/src/pi/tools.ts +32 -9
- package/src/pi/ui.ts +6 -1
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +16 -9
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +28 -2
- package/src/schemas/configuration.ts +15 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +52 -0
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- 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/workflow/track.ts +436 -0
- package/src/workflow/workflow.ts +734 -46
|
@@ -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
|
@@ -2,6 +2,7 @@ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-ag
|
|
|
2
2
|
import { registerCommands } from "./pi/commands.ts";
|
|
3
3
|
import { registerLifecycle } from "./pi/events.ts";
|
|
4
4
|
import { registerOrchestrateTool } from "./pi/tools.ts";
|
|
5
|
+
import { registerRouteTool } from "./pi/route.ts";
|
|
5
6
|
import { onTransition } from "./state/task-state.ts";
|
|
6
7
|
import { pingTransition } from "./pi/notify.ts";
|
|
7
8
|
import { isSubagentProcess } from "./pi/quiet.ts";
|
|
@@ -11,6 +12,8 @@ import { registerOwner } from "./pi/owner.ts";
|
|
|
11
12
|
import { registerClassifier } from "./classifier/instance.ts";
|
|
12
13
|
import { registerClassifierTools } from "./classifier/tools.ts";
|
|
13
14
|
import { registerFreshContext } from "./pi/fresh-context.ts";
|
|
15
|
+
import { registerAskTool } from "./ask/tool.ts";
|
|
16
|
+
import { registerWebTools } from "./web/tools.ts";
|
|
14
17
|
|
|
15
18
|
export default function (pi: ExtensionAPI): void {
|
|
16
19
|
// First, so every session_start handler below finds the classifier bound to this session's keys.
|
|
@@ -24,6 +27,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
24
27
|
onTransition((task) => pingTransition(task));
|
|
25
28
|
registerCommands(pi, CONFIG_DIR_NAME);
|
|
26
29
|
registerOrchestrateTool(pi, CONFIG_DIR_NAME);
|
|
30
|
+
// A new request one agent can do alone: the oracle confirms, and the lobby hands it to the quick-fix agent.
|
|
31
|
+
registerRouteTool(pi, CONFIG_DIR_NAME);
|
|
32
|
+
// The questionnaire the oracle (and pi without a task) asks the user with; no other extension is needed for it.
|
|
33
|
+
registerAskTool(pi);
|
|
34
|
+
// The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
|
|
35
|
+
registerWebTools(pi);
|
|
27
36
|
// Parallel workers share files through the master's file desk.
|
|
28
37
|
if (isSubagentProcess()) registerDeskClient(pi);
|
|
29
38
|
// 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.
|
package/src/lobby/feed.ts
CHANGED
|
@@ -284,8 +284,13 @@ export function chatText(role: "user" | "assistant", text: string): Array<{ role
|
|
|
284
284
|
const kickoff = /^A bot-lobby task is active: (\S+)\nTitle: (.*)/.exec(body);
|
|
285
285
|
if (kickoff) {
|
|
286
286
|
const request = /\nRequest: ([\s\S]*?)(?:\nState: |$)/.exec(body)?.[1]?.trim();
|
|
287
|
-
|
|
287
|
+
// A request the oracle routed to the team is already in the conversation, under the routing note.
|
|
288
|
+
const shown = /\nRouted: /.test(body);
|
|
289
|
+
return [{ role: "note", text: `task started · ${kickoff[2]}` }, ...(request && !shown ? [{ role: "you" as const, text: request }] : [])];
|
|
288
290
|
}
|
|
291
|
+
// A new request the classifier read as a quick fix, before the oracle confirms where it goes.
|
|
292
|
+
const routing = /^bot-lobby: a new request, not a task yet\.\nRequest: ([\s\S]*?)\nRead: /.exec(body);
|
|
293
|
+
if (routing) return [{ role: "you", text: routing[1]!.trim() }, { role: "note", text: "reads as a quick fix · the oracle confirms where it goes" }];
|
|
289
294
|
const comment = /^The user left (?:a comment|\d+ comments) on (the approved plan|the proposal) of (\S+) from the lobby:/.exec(body);
|
|
290
295
|
if (comment) return [{ role: "note", text: `your comment on ${comment[1]} went to the oracle` }];
|
|
291
296
|
return [{ role: "you", text: body }];
|
package/src/lobby/layout.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
8
|
import { textWidth } from "../width.ts";
|
|
9
|
+
import type { MarkdownStyle } from "./markdown.ts";
|
|
9
10
|
|
|
10
11
|
/** Colours the lobby uses; a subset of pi's theme so tests can pass a plain stub. */
|
|
11
12
|
export type LobbyColor =
|
|
@@ -29,8 +30,8 @@ export interface LobbyTheme {
|
|
|
29
30
|
bold(text: string): string;
|
|
30
31
|
italic?(text: string): string;
|
|
31
32
|
bg?(color: "selectedBg" | "searchMatchBg" | "userMessageBg", text: string): string;
|
|
32
|
-
/** Render Markdown to styled lines; plain wrapping without it (tests). */
|
|
33
|
-
markdown?(text: string, width: number, keep?: boolean): string[];
|
|
33
|
+
/** Render Markdown to styled lines, in the look of whose words they are; plain wrapping without it (tests). */
|
|
34
|
+
markdown?(text: string, width: number, keep?: boolean, style?: MarkdownStyle): string[];
|
|
34
35
|
/** Strike text through (an abandoned task's title); plain without it (tests). */
|
|
35
36
|
strike?(text: string): string;
|
|
36
37
|
}
|
|
@@ -184,8 +185,8 @@ export function spinner(tick: number): string {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
/** Markdown for plans, reports and replies: the theme's renderer, or headings bold without their `#` and the rest wrapped. */
|
|
187
|
-
export function markdownLines(text: string, width: number, theme?: LobbyTheme, keep = true): string[] {
|
|
188
|
-
if (theme?.markdown) return theme.markdown(text, width, keep);
|
|
188
|
+
export function markdownLines(text: string, width: number, theme?: LobbyTheme, keep = true, style: MarkdownStyle = "reply"): string[] {
|
|
189
|
+
if (theme?.markdown) return theme.markdown(text, width, keep, style);
|
|
189
190
|
return text.split("\n").flatMap((line) => {
|
|
190
191
|
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
191
192
|
return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
|
|
@@ -193,9 +194,9 @@ export function markdownLines(text: string, width: number, theme?: LobbyTheme, k
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
/** Markdown behind a hanging lead (`oracle ▸ `): the first line carries the lead, the rest align under it. */
|
|
196
|
-
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme): string[] {
|
|
197
|
+
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme, style: MarkdownStyle = "reply"): string[] {
|
|
197
198
|
const indent = textWidth(lead);
|
|
198
|
-
const body = markdownLines(text, Math.max(1, width - indent), theme);
|
|
199
|
+
const body = markdownLines(text, Math.max(1, width - indent), theme, true, style);
|
|
199
200
|
if (body.length === 0) return [lead];
|
|
200
201
|
return body.map((line, index) => (index === 0 ? `${lead}${line}` : line ? `${" ".repeat(indent)}${line}` : ""));
|
|
201
202
|
}
|
|
@@ -211,6 +212,18 @@ export interface BoxOptions {
|
|
|
211
212
|
scroll?: { total: number; start: number };
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/** An entry of a pane (a message, a thought, an activity line) and how many lines run from its first line to the pane's end. */
|
|
216
|
+
export interface PaneMark {
|
|
217
|
+
entry: object;
|
|
218
|
+
below: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A pane that measures what arrives below: its newest entry now, and the lines that arrived below the one marked the frame before. */
|
|
222
|
+
export interface PaneFollow {
|
|
223
|
+
mark?: PaneMark;
|
|
224
|
+
grew: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
214
227
|
/** A scrollable pane as a render laid it out: its box in body cells, and its content against its rows. */
|
|
215
228
|
export interface PaneBox {
|
|
216
229
|
top: number;
|
|
@@ -220,14 +233,20 @@ export interface PaneBox {
|
|
|
220
233
|
/** Lines of content, and rows showing them at once. */
|
|
221
234
|
total: number;
|
|
222
235
|
rows: number;
|
|
236
|
+
/**
|
|
237
|
+
* Set by panes whose total is estimated: lines that arrived below since the
|
|
238
|
+
* frame before, measured from an entry, not from the total (which moves as
|
|
239
|
+
* scrolling draws more or fewer of the entries it estimates).
|
|
240
|
+
*/
|
|
241
|
+
follow?: PaneFollow;
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
/** Scrollable panes by name, filled in by a tab's render so the lobby can clamp offsets and route the wheel. */
|
|
226
245
|
export type PaneLayout = Map<string, PaneBox>;
|
|
227
246
|
|
|
228
247
|
/** Record a pane that sits at `top`/`left` in the body, `width` × `height`, showing `total` lines. */
|
|
229
|
-
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number): void {
|
|
230
|
-
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2) });
|
|
248
|
+
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number, follow?: PaneFollow): void {
|
|
249
|
+
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2), ...(follow ? { follow } : {}) });
|
|
231
250
|
}
|
|
232
251
|
|
|
233
252
|
/** First line of a top-anchored pane scrolled `offset` lines down, stopping when its last line shows. */
|