@tshu-w/pi-agents 0.1.0 → 0.2.0
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 +27 -20
- package/bin/daemon.mjs +33 -15
- package/bin/host.mjs +9 -1
- package/bin/pd.mjs +80 -32
- package/docs/{WORKBENCH.md → HOSTS.md} +52 -38
- package/docs/PROGRAM.md +31 -34
- package/docs/SPEC.md +76 -39
- package/package.json +10 -6
- package/src/agents/agents.ts +103 -30
- package/src/agents/registry.ts +30 -3
- package/src/agents/search.ts +21 -4
- package/src/agents/tool.ts +10 -5
- package/src/daemon/client.mjs +4 -1
- package/src/daemon/sessions.mjs +7 -0
- package/src/host/client.mjs +25 -5
- package/src/host/extension.ts +38 -14
- package/src/host/rpc-client.mjs +360 -0
- package/src/index.ts +36 -14
- package/src/programs/codemode.ts +2 -2
- package/src/programs/execute.ts +150 -34
- package/src/programs/host-patches.ts +56 -27
- package/src/programs/programs.ts +78 -11
- package/src/programs/run-output.mjs +11 -0
- package/src/programs/sandbox.ts +17 -4
- package/src/programs/scope.ts +11 -4
- package/src/programs/tool.ts +172 -16
- package/src/roots/guard.mjs +8 -2
- package/src/roots/queue.ts +52 -0
- package/src/roots/report.ts +12 -6
- package/src/roots/rpc.ts +618 -0
- package/src/roots/runtime.ts +17 -2
- package/src/roots/transport.mjs +41 -23
- package/src/ui/panel.ts +39 -7
- package/src/ui/queue.ts +46 -0
- package/src/ui/viewer.ts +119 -11
- package/src/workbench.mjs +78 -31
package/src/agents/agents.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import {
|
|
19
19
|
customMessage,
|
|
20
20
|
deliver,
|
|
21
|
+
addProgramSpend,
|
|
22
|
+
addSpend,
|
|
21
23
|
label,
|
|
22
24
|
messageText,
|
|
23
25
|
MESSAGE_TYPE,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
type AgentState,
|
|
33
35
|
type Delivery,
|
|
34
36
|
type Entry,
|
|
37
|
+
type Spend,
|
|
35
38
|
type TreeMetadata,
|
|
36
39
|
} from "./registry.ts";
|
|
37
40
|
|
|
@@ -121,8 +124,8 @@ function lastAnswer(session: AgentSession, before: number): { outcome: Outcome;
|
|
|
121
124
|
return { outcome: "completed", result: text };
|
|
122
125
|
}
|
|
123
126
|
|
|
124
|
-
/** Takes this extension's messages
|
|
125
|
-
function takeQueued(agent: AgentSession["agent"]) {
|
|
127
|
+
/** Takes this extension's messages out of the agent queues. */
|
|
128
|
+
export function takeQueued(agent: AgentSession["agent"]) {
|
|
126
129
|
// peekQueuedMessages returns one message in one-at-a-time mode; read the queues whole.
|
|
127
130
|
const queues = agent as unknown as Record<"steeringQueue" | "followUpQueue", { messages: QueuedMessage[] }>;
|
|
128
131
|
const steering = queues.steeringQueue.messages.slice();
|
|
@@ -135,6 +138,19 @@ function takeQueued(agent: AgentSession["agent"]) {
|
|
|
135
138
|
return [...steering, ...followUps].filter(ours);
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
/** Whether a steer, which this turn must take in before its next model call, waits in the agent's queue; a write does not count. */
|
|
142
|
+
export function steerQueued(agent: AgentSession["agent"] | undefined): boolean {
|
|
143
|
+
if (!agent) return false;
|
|
144
|
+
const queue = (agent as unknown as { steeringQueue: { messages: QueuedMessage[] } }).steeringQueue.messages;
|
|
145
|
+
return queue.some((message) => !(message.role === "custom" && message.customType === MESSAGE_TYPE
|
|
146
|
+
&& (message as { details?: { delivery?: string } }).details?.delivery === "write"));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Pi does not report steers queued by extensions, so a wait checks the queue this often.
|
|
150
|
+
export const STEER_POLL_MS = 250;
|
|
151
|
+
|
|
152
|
+
export const WAIT_INTERRUPTED = "Stopped waiting for a new message. Wait again to keep waiting.";
|
|
153
|
+
|
|
138
154
|
export interface Deferred {
|
|
139
155
|
promise: Promise<void>;
|
|
140
156
|
resolve(): void;
|
|
@@ -187,7 +203,7 @@ function ended(input: InputRecord): boolean {
|
|
|
187
203
|
|
|
188
204
|
interface Input {
|
|
189
205
|
text: string;
|
|
190
|
-
/** The user's input from the
|
|
206
|
+
/** The user's input from the task viewer. */
|
|
191
207
|
user?: boolean;
|
|
192
208
|
record?: InputRecord;
|
|
193
209
|
permit?: symbol;
|
|
@@ -208,6 +224,10 @@ interface Turn {
|
|
|
208
224
|
started: boolean;
|
|
209
225
|
/** Whether the turn holds a scheduler slot. */
|
|
210
226
|
slot: boolean;
|
|
227
|
+
/** When the turn got its slot, in milliseconds. */
|
|
228
|
+
since?: number;
|
|
229
|
+
/** What the turn's replies have cost. */
|
|
230
|
+
spent: Spend;
|
|
211
231
|
finished: boolean;
|
|
212
232
|
done: Deferred;
|
|
213
233
|
}
|
|
@@ -226,6 +246,8 @@ interface Owned {
|
|
|
226
246
|
loading?: Promise<AgentSession>;
|
|
227
247
|
prepared?: Prepared;
|
|
228
248
|
waiters: number;
|
|
249
|
+
/** Its turn was stopped: messages from other Agents become writes until its owner's or user's next input. */
|
|
250
|
+
stopped?: boolean;
|
|
229
251
|
}
|
|
230
252
|
|
|
231
253
|
export interface SpawnRequest {
|
|
@@ -246,6 +268,8 @@ export interface WaitResult {
|
|
|
246
268
|
export interface WaitOutcome {
|
|
247
269
|
results: WaitResult[];
|
|
248
270
|
pending: AgentRecord[];
|
|
271
|
+
/** A steer ended the wait. */
|
|
272
|
+
interrupted?: boolean;
|
|
249
273
|
}
|
|
250
274
|
|
|
251
275
|
/** The Agents owned by one loaded Agent. */
|
|
@@ -287,11 +311,11 @@ export class Agents {
|
|
|
287
311
|
return [...this.agents.values()].some((agent) => agent.turn !== undefined || agent.queue.length > 0);
|
|
288
312
|
}
|
|
289
313
|
|
|
290
|
-
/** An owned Agent's state,
|
|
291
|
-
counts(id: string): { state: AgentState; pending: number } | undefined {
|
|
314
|
+
/** An owned Agent's state, its inputs pending behind its current or next turn, when its turn got a slot, and what the turn has cost. */
|
|
315
|
+
counts(id: string): { state: AgentState; pending: number; since?: number; spent?: Spend } | undefined {
|
|
292
316
|
const agent = this.agents.get(id);
|
|
293
317
|
if (!agent) return undefined;
|
|
294
|
-
return { state: this.state(agent), pending: agent.queue.length };
|
|
318
|
+
return { state: this.state(agent), pending: agent.queue.length, since: agent.turn?.since, spent: agent.turn?.spent };
|
|
295
319
|
}
|
|
296
320
|
|
|
297
321
|
private state(agent: Owned): AgentState {
|
|
@@ -393,14 +417,25 @@ export class Agents {
|
|
|
393
417
|
/**
|
|
394
418
|
* Sends an input from the owner and resolves when it ends. With a schema, the Agent must call
|
|
395
419
|
* `submit_result` with a matching value, which becomes the input's `value`; `text` tells it so.
|
|
420
|
+
* An input not ended within `timeoutMs` is aborted alone, and `timedOut` is set.
|
|
396
421
|
*/
|
|
397
|
-
async request(id: string, text: string, delivery: "followUp" | "steer", schema?: unknown): Promise<InputRecord> {
|
|
422
|
+
async request(id: string, text: string, delivery: "followUp" | "steer", schema?: unknown, timeoutMs?: number): Promise<{ record: InputRecord; timedOut: boolean }> {
|
|
398
423
|
const agent = this.agent(id);
|
|
399
424
|
const input: Input = { text, permit: this.reserve(), record: this.newRecord(agent), done: deferred() };
|
|
400
425
|
if (schema !== undefined) input.schema = schema;
|
|
401
426
|
this.add(agent, input, delivery);
|
|
402
|
-
|
|
403
|
-
|
|
427
|
+
let stopping: Promise<void> | undefined;
|
|
428
|
+
const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
429
|
+
stopping = this.cancel(agent, input);
|
|
430
|
+
stopping.catch(() => {});
|
|
431
|
+
}, timeoutMs);
|
|
432
|
+
try {
|
|
433
|
+
await input.done!.promise;
|
|
434
|
+
} finally {
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
}
|
|
437
|
+
await stopping;
|
|
438
|
+
return { record: input.record!, timedOut: stopping !== undefined && input.record!.state === "aborted" };
|
|
404
439
|
}
|
|
405
440
|
|
|
406
441
|
/** Records a value from `submit_result` for the Agent's current input with a schema. */
|
|
@@ -416,6 +451,7 @@ export class Agents {
|
|
|
416
451
|
|
|
417
452
|
accept(id: string, text: string, delivery: Delivery, options: { fromOwner: boolean; notification: boolean }): { queued: boolean } {
|
|
418
453
|
const agent = this.agent(id);
|
|
454
|
+
if (agent.stopped && !options.fromOwner) delivery = "write";
|
|
419
455
|
if (delivery === "write") {
|
|
420
456
|
this.write(agent, text);
|
|
421
457
|
return { queued: false };
|
|
@@ -426,7 +462,7 @@ export class Agents {
|
|
|
426
462
|
return { queued: this.add(agent, input, delivery) };
|
|
427
463
|
}
|
|
428
464
|
|
|
429
|
-
async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal): Promise<WaitOutcome> {
|
|
465
|
+
async wait(targets: Owned[] | undefined, history: number, timeoutSeconds: number, signal?: AbortSignal, steered?: () => boolean): Promise<WaitOutcome> {
|
|
430
466
|
const selected = targets ?? [...this.agents.values()].filter((agent) =>
|
|
431
467
|
this.pending(agent) || agent.record.inputs.some((input) => ended(input) && !input.read));
|
|
432
468
|
const unique = [...new Set(selected)];
|
|
@@ -438,13 +474,18 @@ export class Agents {
|
|
|
438
474
|
const suspended = blocking && this.self.ownerId !== undefined && scheduler.suspend(this.self.rootId, this.self.id);
|
|
439
475
|
if (blocking) this.markWaiting(1);
|
|
440
476
|
let returned = false;
|
|
477
|
+
let interrupted = false;
|
|
441
478
|
try {
|
|
442
479
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
443
480
|
while (unique.some((agent) => this.pending(agent))) {
|
|
444
481
|
const remaining = deadline - Date.now();
|
|
445
482
|
if (remaining <= 0) break;
|
|
483
|
+
if (steered?.()) {
|
|
484
|
+
interrupted = true;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
446
487
|
signal?.throwIfAborted();
|
|
447
|
-
await waitForChange(this.change.promise, remaining, signal);
|
|
488
|
+
await waitForChange(this.change.promise, steered ? Math.min(remaining, STEER_POLL_MS) : remaining, signal);
|
|
448
489
|
}
|
|
449
490
|
signal?.throwIfAborted();
|
|
450
491
|
const results: WaitResult[] = [];
|
|
@@ -455,7 +496,7 @@ export class Agents {
|
|
|
455
496
|
}
|
|
456
497
|
}
|
|
457
498
|
returned = true;
|
|
458
|
-
return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record) };
|
|
499
|
+
return { results, pending: unique.filter((agent) => this.pending(agent)).map((agent) => agent.record), ...(interrupted && { interrupted }) };
|
|
459
500
|
} finally {
|
|
460
501
|
if (blocking) this.markWaiting(-1);
|
|
461
502
|
if (suspended) await scheduler.resume(this.self.rootId, this.self.id, this.limits.maxConcurrent, signal);
|
|
@@ -494,22 +535,38 @@ export class Agents {
|
|
|
494
535
|
if (silent) for (const input of [...agent.queue, ...(turn?.inputs ?? [])]) input.silent = true;
|
|
495
536
|
for (const input of agent.queue.splice(0)) this.end(agent, input, "aborted", "");
|
|
496
537
|
this.changed();
|
|
497
|
-
if (turn)
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
538
|
+
if (turn) await this.stop(agent, turn, deadline);
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Ends one input as aborted: withdraws it if queued, or else stops its turn, as `abort` does. */
|
|
543
|
+
private async cancel(agent: Owned, input: Input): Promise<void> {
|
|
544
|
+
const index = agent.queue.indexOf(input);
|
|
545
|
+
if (index !== -1) {
|
|
546
|
+
agent.queue.splice(index, 1);
|
|
547
|
+
this.end(agent, input, "aborted", "");
|
|
548
|
+
this.changed();
|
|
549
|
+
} else if (agent.turn?.inputs.includes(input)) {
|
|
550
|
+
await this.stop(agent, agent.turn, Date.now() + SHUTDOWN_TIMEOUT_MS);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Stops a turn; one that has not stopped by `deadline` is abandoned: its inputs end as aborted, and this throws. */
|
|
555
|
+
private async stop(agent: Owned, turn: Turn, deadline: number): Promise<void> {
|
|
556
|
+
agent.stopped = true;
|
|
557
|
+
turn.abort.abort();
|
|
558
|
+
const stopping = (async () => {
|
|
559
|
+
if (turn.started && agent.session) {
|
|
560
|
+
agent.session.abortCompaction();
|
|
561
|
+
agent.session.abortRetry();
|
|
562
|
+
await agent.session.abort();
|
|
510
563
|
}
|
|
564
|
+
await turn.done.promise;
|
|
565
|
+
})();
|
|
566
|
+
if (!await settlesBy(stopping, deadline)) {
|
|
567
|
+
this.finish(agent, turn, { outcome: "aborted", result: "" });
|
|
568
|
+
throw new Error(`Agent ${label(agent.record, this.ids())} did not stop within the timeout and may still be running.`);
|
|
511
569
|
}
|
|
512
|
-
return true;
|
|
513
570
|
}
|
|
514
571
|
|
|
515
572
|
restore(ctx: ExtensionContext): void {
|
|
@@ -539,9 +596,7 @@ export class Agents {
|
|
|
539
596
|
const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS;
|
|
540
597
|
const agents = [...this.agents.values()];
|
|
541
598
|
const aborted = await Promise.allSettled(agents.map((agent) => this.abort(agent, { deadline })));
|
|
542
|
-
const
|
|
543
|
-
const session = agent.session ?? await agent.loading?.catch(() => undefined);
|
|
544
|
-
if (!session) return;
|
|
599
|
+
const close = async (agent: Owned, session: AgentSession) => {
|
|
545
600
|
try {
|
|
546
601
|
if (!await settlesBy(session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }), deadline)) {
|
|
547
602
|
throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
|
|
@@ -550,6 +605,16 @@ export class Agents {
|
|
|
550
605
|
session.dispose();
|
|
551
606
|
agent.session = undefined;
|
|
552
607
|
}
|
|
608
|
+
};
|
|
609
|
+
const closed = await Promise.allSettled(agents.map(async (agent) => {
|
|
610
|
+
const loading = agent.loading;
|
|
611
|
+
if (!agent.session && loading && !await settlesBy(loading, deadline)) {
|
|
612
|
+
// A Session that loads after the timeout is closed once it does.
|
|
613
|
+
void loading.then((late) => close(agent, late).catch(() => {}), () => {});
|
|
614
|
+
throw new Error(`Agent ${label(agent.record, this.ids())} did not shut down within the timeout and may still be running.`);
|
|
615
|
+
}
|
|
616
|
+
const session = agent.session ?? await loading?.catch(() => undefined);
|
|
617
|
+
if (session) await close(agent, session);
|
|
553
618
|
}));
|
|
554
619
|
const errors = [...aborted, ...closed].flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
555
620
|
if (errors.length > 0) throw new Error(errors.map((error) => error instanceof Error ? error.message : String(error)).join("\n"));
|
|
@@ -584,6 +649,8 @@ export class Agents {
|
|
|
584
649
|
|
|
585
650
|
/** A steer joins the current turn; other inputs queue. Returns whether the input waits for a slot. */
|
|
586
651
|
private add(agent: Owned, input: Input, delivery: "followUp" | "steer"): boolean {
|
|
652
|
+
// While stopped, other senders' messages become writes, so this is the owner's or user's next input.
|
|
653
|
+
agent.stopped = false;
|
|
587
654
|
if (delivery !== "steer" || !agent.turn) return this.enqueue(agent, input);
|
|
588
655
|
this.join(agent, agent.turn, input);
|
|
589
656
|
return false;
|
|
@@ -606,7 +673,7 @@ export class Agents {
|
|
|
606
673
|
if (agent.turn || this.closing) return;
|
|
607
674
|
const head = agent.queue.shift();
|
|
608
675
|
if (!head) return;
|
|
609
|
-
const turn: Turn = { inputs: [head], pending: [], abort: new AbortController(), started: false, slot: false, finished: false, done: deferred() };
|
|
676
|
+
const turn: Turn = { inputs: [head], pending: [], abort: new AbortController(), started: false, slot: false, spent: { cost: 0, tokens: 0, unpriced: false }, finished: false, done: deferred() };
|
|
610
677
|
agent.turn = turn;
|
|
611
678
|
void this.runTurn(agent, turn);
|
|
612
679
|
}
|
|
@@ -616,6 +683,7 @@ export class Agents {
|
|
|
616
683
|
try {
|
|
617
684
|
await scheduler.acquireQueued(this.self.rootId, agent.record.id, this.limits.maxConcurrent, turn.abort.signal);
|
|
618
685
|
turn.slot = true;
|
|
686
|
+
turn.since = Date.now();
|
|
619
687
|
const session = await this.load(agent);
|
|
620
688
|
turn.abort.signal.throwIfAborted();
|
|
621
689
|
const before = session.getSessionStats().assistantMessages;
|
|
@@ -877,6 +945,11 @@ export class Agents {
|
|
|
877
945
|
finally { session.dispose(); }
|
|
878
946
|
throw error;
|
|
879
947
|
}
|
|
948
|
+
session.subscribe((event) => {
|
|
949
|
+
if (event.type !== "message_end" || event.message.role !== "assistant") return;
|
|
950
|
+
if (agent.turn) addSpend(agent.turn.spent, event.message.usage);
|
|
951
|
+
addProgramSpend(this.self.id, event.message.usage);
|
|
952
|
+
});
|
|
880
953
|
return session;
|
|
881
954
|
}
|
|
882
955
|
}
|
package/src/agents/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** The Agents loaded in this process, shared across trees: who sees whom, how targets resolve, and how messages are addressed. */
|
|
2
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { Agents } from "./agents.ts";
|
|
4
5
|
import { TreeScheduler } from "./scheduler.ts";
|
|
@@ -16,12 +17,14 @@ export interface AgentNode {
|
|
|
16
17
|
/** The node whose tree this node sees: its root, or the Program its Agents belong to. */
|
|
17
18
|
scopeId: string;
|
|
18
19
|
ownerId?: string;
|
|
19
|
-
/** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the
|
|
20
|
+
/** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the task viewer. */
|
|
20
21
|
program?: boolean;
|
|
21
22
|
/** Whether a Program runs in the background. */
|
|
22
23
|
background?: boolean;
|
|
23
24
|
/** When a Program started. */
|
|
24
25
|
createdAt?: string;
|
|
26
|
+
/** What the replies of a Program's Agents and the Agents under them have cost since it started. */
|
|
27
|
+
spent?: Spend;
|
|
25
28
|
name(): string | undefined;
|
|
26
29
|
cwd(): string;
|
|
27
30
|
busy(): boolean;
|
|
@@ -29,6 +32,8 @@ export interface AgentNode {
|
|
|
29
32
|
agents: Agents;
|
|
30
33
|
/** Delivers a message to this node; used only for root Agents. */
|
|
31
34
|
receive(text: string, delivery: Delivery, messageId?: string): void;
|
|
35
|
+
/** A root Agent's turn was stopped: messages from other Agents become writes until the user's next input. */
|
|
36
|
+
stopped?: boolean;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export interface Entry {
|
|
@@ -86,6 +91,26 @@ export function trackTreeWork(rootId: string): () => void {
|
|
|
86
91
|
};
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
/** What replies cost: dollars, input and output tokens, and whether a reply had no price. */
|
|
95
|
+
export interface Spend {
|
|
96
|
+
cost: number;
|
|
97
|
+
tokens: number;
|
|
98
|
+
unpriced: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function addSpend(spend: Spend, usage: Usage): void {
|
|
102
|
+
spend.cost += usage.cost.total;
|
|
103
|
+
spend.tokens += usage.input + usage.output;
|
|
104
|
+
if (usage.cost.total === 0 && usage.input + usage.output + usage.cacheRead + usage.cacheWrite > 0) spend.unpriced = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Adds a reply of an Agent owned by `ownerId` to the Programs above it. */
|
|
108
|
+
export function addProgramSpend(ownerId: string, usage: Usage): void {
|
|
109
|
+
for (let node = shared.nodes.get(ownerId); node; node = node.ownerId === undefined ? undefined : shared.nodes.get(node.ownerId)) {
|
|
110
|
+
if (node.spent) addSpend(node.spent, usage);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
89
114
|
/** The Programs a node runs, oldest first. */
|
|
90
115
|
export function programsOf(node: AgentNode): AgentNode[] {
|
|
91
116
|
return [...shared.nodes.values()]
|
|
@@ -169,7 +194,9 @@ export function treeMetadata(ctx: ExtensionContext): TreeMetadata | undefined {
|
|
|
169
194
|
export function treeEntries(scopeId: string): Entry[] {
|
|
170
195
|
const root = shared.nodes.get(scopeId);
|
|
171
196
|
if (!root) return [];
|
|
172
|
-
|
|
197
|
+
// A Program's Agent heads its own scope; it stays owned, so messages to it go through its owner.
|
|
198
|
+
const owned = root.ownerId === undefined ? undefined : shared.nodes.get(root.ownerId)?.agents.owned().find((agent) => agent.id === root.id);
|
|
199
|
+
const entries: Entry[] = root.program ? [] : [owned ? { ...owned, ownerId: root.ownerId } : {
|
|
173
200
|
id: root.id,
|
|
174
201
|
name: root.name(),
|
|
175
202
|
cwd: root.cwd(),
|
|
@@ -236,7 +263,7 @@ export function messageText(from: { id: string; name?: string }, fromOwner: bool
|
|
|
236
263
|
|
|
237
264
|
/**
|
|
238
265
|
* `messageId` marks a message from a root Agent in another process, so it is added once; `user`
|
|
239
|
-
* marks the user's input from the
|
|
266
|
+
* marks the user's input from the task viewer.
|
|
240
267
|
*/
|
|
241
268
|
export function customMessage(text: string, delivery: Delivery, messageId?: string, user?: boolean) {
|
|
242
269
|
return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}) } };
|
package/src/agents/search.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { entryLine, MESSAGE_TAG, MESSAGE_TYPE, type Entry } from "./registry.ts"
|
|
|
4
4
|
interface SearchText {
|
|
5
5
|
time: number;
|
|
6
6
|
text: string;
|
|
7
|
+
/** A user message or an input, not a summary, write, or notification. */
|
|
8
|
+
request?: boolean;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/** Parsed texts by Session file, reused while the file is unchanged. */
|
|
@@ -32,9 +34,10 @@ function sessionTexts(sessionFile: string | undefined): SearchText[] {
|
|
|
32
34
|
const text = typeof content === "string"
|
|
33
35
|
? content
|
|
34
36
|
: Array.isArray(content) ? content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n") : "";
|
|
35
|
-
if (text) texts.push({ time, text });
|
|
37
|
+
if (text) texts.push({ time, text, request: true });
|
|
36
38
|
} else if (entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
|
|
37
|
-
|
|
39
|
+
const request = entry.details?.delivery !== "write" && (entry.content.startsWith(`<${MESSAGE_TAG} `) || entry.details?.user === true);
|
|
40
|
+
texts.push({ time, text: entry.content, request });
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
cache.set(sessionFile, { stamp, texts });
|
|
@@ -85,9 +88,23 @@ export function searchEntries(entries: Entry[], query: string | undefined): List
|
|
|
85
88
|
return items.sort((left, right) => right.time - left.time).map(({ time: _time, ...item }) => item);
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/** An entry with its latest matching excerpt, else its latest request, and its time. */
|
|
88
92
|
export function listLine(item: ListItem, ids: Iterable<string>): string {
|
|
89
93
|
const line = entryLine(item.entry, ids);
|
|
90
|
-
|
|
94
|
+
const detail = item.match ?? latestRequest(item.entry.sessionFile);
|
|
95
|
+
return detail ? `${line}\n ${formatTime(detail.time)} ${detail.excerpt}` : line;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function latestRequest(sessionFile: string | undefined): { time: number; excerpt: string } | undefined {
|
|
99
|
+
const text = sessionTexts(sessionFile).findLast((text) => text.request);
|
|
100
|
+
if (!text) return undefined;
|
|
101
|
+
const flat = messageBody(text.text).replace(/\s+/g, " ").trim();
|
|
102
|
+
return { time: text.time, excerpt: flat.length > 120 ? `${flat.slice(0, 120)}…` : flat };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A message's body without its `agent-message` element. */
|
|
106
|
+
function messageBody(content: string): string {
|
|
107
|
+
return content.startsWith(`<${MESSAGE_TAG} `) ? content.slice(content.indexOf("\n") + 1, content.lastIndexOf("\n")) : content;
|
|
91
108
|
}
|
|
92
109
|
|
|
93
110
|
/**
|
|
@@ -103,7 +120,7 @@ export function latestActivity(sessionFile: string | undefined): string | undefi
|
|
|
103
120
|
let entry: Record<string, any>;
|
|
104
121
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
105
122
|
if (input === undefined && entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
|
|
106
|
-
input =
|
|
123
|
+
input = messageBody(entry.content);
|
|
107
124
|
}
|
|
108
125
|
if (input !== undefined && entry.type === "message" && entry.message?.role === "assistant" && Array.isArray(entry.message.content)) {
|
|
109
126
|
activity = replyActivity(entry.message.content) ?? activity;
|
package/src/agents/tool.ts
CHANGED
|
@@ -4,9 +4,12 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { boundBlocks, boundText } from "../output.ts";
|
|
5
5
|
import { rejectFields } from "../params.ts";
|
|
6
6
|
import { renderTextResult, renderToolCall, startDuration, type DurationState } from "../render-call.ts";
|
|
7
|
+
import { agentSession } from "../roots/rpc.ts";
|
|
7
8
|
import type { RootRuntime } from "../roots/runtime.ts";
|
|
8
9
|
import {
|
|
10
|
+
steerQueued,
|
|
9
11
|
THINKING_LEVELS,
|
|
12
|
+
WAIT_INTERRUPTED,
|
|
10
13
|
type Limits,
|
|
11
14
|
type WaitOutcome,
|
|
12
15
|
type WaitResult,
|
|
@@ -34,9 +37,10 @@ function resultDetails({ agent, input, history }: WaitResult, result?: string) {
|
|
|
34
37
|
|
|
35
38
|
/** Renders wait results; returns the text and the results the caller has now read. */
|
|
36
39
|
function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read: WaitResult[]; details: Record<string, unknown> } {
|
|
37
|
-
const { results, pending } = outcome;
|
|
38
|
-
const pendingLine = pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}` : "";
|
|
39
|
-
|
|
40
|
+
const { results, pending, interrupted } = outcome;
|
|
41
|
+
const pendingLine = pending.length > 0 ? `Still pending: ${pending.map((agent) => label(agent, ids)).join(", ")}${interrupted ? `\n${WAIT_INTERRUPTED}` : ""}` : "";
|
|
42
|
+
const flag = interrupted ? { interrupted } : {};
|
|
43
|
+
if (results.length === 0) return { text: pendingLine || "No results.", read: [], details: { results: [], pending: pending.map((agent) => agent.id), ...flag } };
|
|
40
44
|
const only = results[0]!;
|
|
41
45
|
if (results.length === 1 && pending.length === 0 && !only.history && only.input.state === "completed") {
|
|
42
46
|
const bounded = boundText(only.input.result ?? "", "pi-agents-wait");
|
|
@@ -56,7 +60,7 @@ function renderWait(outcome: WaitOutcome, ids: string[]): { text: string; read:
|
|
|
56
60
|
const omitted = results.slice(bounded.shown);
|
|
57
61
|
if (omitted.length > 0) text += `\n\n[Results omitted: ${omitted.map((result) => label(result.agent, ids)).join(", ")}. Use wait with fewer targets.]`;
|
|
58
62
|
if (pendingLine) text += `\n\n${pendingLine}`;
|
|
59
|
-
return { text, read, details: { results: details, pending: pending.map((agent) => agent.id), ...bounded.details } };
|
|
63
|
+
return { text, read, details: { results: details, pending: pending.map((agent) => agent.id), ...flag, ...bounded.details } };
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/** Registers the `agent` tool. */
|
|
@@ -168,7 +172,8 @@ export function registerAgentTool(
|
|
|
168
172
|
const selected = targets?.map((target) => agents.ownedTarget(target));
|
|
169
173
|
// An empty partial result lets the row show the elapsed time.
|
|
170
174
|
onUpdate?.({ content: [], details: undefined });
|
|
171
|
-
const
|
|
175
|
+
const session = agentSession(current.sessionManager);
|
|
176
|
+
const outcome = await agents.wait(selected, params.history ?? 0, params.timeout ?? 30, signal, () => steerQueued(session?.agent));
|
|
172
177
|
const rendered = renderWait(outcome, ids());
|
|
173
178
|
agents.markRead(rendered.read);
|
|
174
179
|
return { content: [{ type: "text", text: rendered.text }], details: rendered.details };
|
package/src/daemon/client.mjs
CHANGED
|
@@ -42,13 +42,16 @@ export async function ensureDaemon(paths, { sessionDir, signal }) {
|
|
|
42
42
|
if (code !== 0) launchError = new Error(`Daemon exited (${code}). See ${paths.daemonLog}`);
|
|
43
43
|
});
|
|
44
44
|
child.unref();
|
|
45
|
+
// The new daemon sets its socket's mode just after binding; a mode that stays wrong is an error.
|
|
46
|
+
let modeWait = 0;
|
|
45
47
|
for (;;) {
|
|
46
48
|
signal?.throwIfAborted();
|
|
47
49
|
if (launchError) throw launchError;
|
|
48
50
|
try {
|
|
49
51
|
return await request(paths.daemon, { action: 'status' }, { signal, timeoutMs: 1000 });
|
|
50
52
|
} catch (failure) {
|
|
51
|
-
if (
|
|
53
|
+
if (failure.code === 'SOCKET_MODE' && ++modeWait > 20) throw failure;
|
|
54
|
+
if (!['ENOENT', 'ECONNREFUSED', 'SOCKET_MODE', ...RETRY].includes(failure.code)) throw failure;
|
|
52
55
|
// Another daemon holds the lock; it answers once its socket is up.
|
|
53
56
|
if (child.exitCode === 0) break;
|
|
54
57
|
}
|
package/src/daemon/sessions.mjs
CHANGED
|
@@ -87,6 +87,13 @@ export async function createSessions(paths, sessionDir) {
|
|
|
87
87
|
(marks[id] ??= {}).listed = true;
|
|
88
88
|
save();
|
|
89
89
|
},
|
|
90
|
+
/** Marks a Session's last turn seen, as when the user previews it. */
|
|
91
|
+
see(id) {
|
|
92
|
+
const mark = marks[id];
|
|
93
|
+
if (!mark?.last || (mark.seen ?? 0) >= mark.last.at) return;
|
|
94
|
+
mark.seen = mark.last.at;
|
|
95
|
+
save();
|
|
96
|
+
},
|
|
90
97
|
/** Removes a Session from the workbench list and clears its unseen turn; its file stays. */
|
|
91
98
|
hide(id) {
|
|
92
99
|
const mark = marks[id];
|
package/src/host/client.mjs
CHANGED
|
@@ -16,7 +16,7 @@ const ALT_SCREEN = /\x1b\[\?1049([hl])/g;
|
|
|
16
16
|
// Splits terminal input into key sequences: CSI and SS3 sequences, Alt+key, or a single character.
|
|
17
17
|
const KEYS = /\x1b\[[0-?]*[ -/]*[@-~]|\x1bO[\s\S]|\x1b?[\s\S]/g;
|
|
18
18
|
|
|
19
|
-
function hostStatus(path) {
|
|
19
|
+
export function hostStatus(path) {
|
|
20
20
|
return new Promise((resolve, reject) => {
|
|
21
21
|
const socket = net.connect(path);
|
|
22
22
|
socket.setTimeout(1000, () => socket.destroy(Object.assign(new Error('Host status timed out'), { code: 'ETIMEDOUT' })));
|
|
@@ -27,7 +27,7 @@ function hostStatus(path) {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function alive(pid) {
|
|
30
|
+
export function alive(pid) {
|
|
31
31
|
try { process.kill(pid, 0); return true; }
|
|
32
32
|
catch (error) { return error.code !== 'ESRCH'; }
|
|
33
33
|
}
|
|
@@ -50,6 +50,19 @@ export async function listHosts(paths) {
|
|
|
50
50
|
return hosts;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Running hosts once `found` matches one or no host `starting` remains, waiting at most `timeoutMs`:
|
|
55
|
+
* a host whose Pi has not reported its Session yet may be starting the Session a caller looks for.
|
|
56
|
+
*/
|
|
57
|
+
export async function settledHosts(paths, found, { starting = host => !host.session, timeoutMs = START_TIMEOUT_MS } = {}) {
|
|
58
|
+
const deadline = performance.now() + timeoutMs;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const hosts = await listHosts(paths);
|
|
61
|
+
if (hosts.some(found) || !hosts.some(starting) || performance.now() > deadline) return hosts;
|
|
62
|
+
await delay(100);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
/**
|
|
54
67
|
* Starts a detached host running `pi args` in `cwd` and resolves to its socket once it accepts connections.
|
|
55
68
|
* With `waitForTerminal`, Pi starts when the first terminal attaches.
|
|
@@ -88,9 +101,10 @@ export async function startHost(paths, { pi, args, cwd, cols = 120, rows = 40, e
|
|
|
88
101
|
/**
|
|
89
102
|
* Attaches this process's terminal to a host until the host detaches it or Pi exits.
|
|
90
103
|
* The detach key detaches here, before Pi sees it, so it works whatever Pi shows.
|
|
104
|
+
* `onAttach` runs once the host has attached the terminal and told Pi.
|
|
91
105
|
* Resolves to { reason: 'detach' | 'takeover' | 'exited' | 'closed', session }.
|
|
92
106
|
*/
|
|
93
|
-
export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = process.stdout }) {
|
|
107
|
+
export function attach(socketPath, { isDetach, onAttach, stdin = process.stdin, stdout = process.stdout }) {
|
|
94
108
|
return new Promise((resolve, reject) => {
|
|
95
109
|
const socket = net.connect(socketPath);
|
|
96
110
|
const size = () => ({ cols: stdout.columns, rows: stdout.rows });
|
|
@@ -104,7 +118,7 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
104
118
|
if (at > 0) socket.write(frame(FRAME.data, Buffer.from(keys.slice(0, at).join(''), encoding)));
|
|
105
119
|
socket.write(frame(FRAME.detached));
|
|
106
120
|
};
|
|
107
|
-
let result, connected = false, altScreen = false;
|
|
121
|
+
let result, connected = false, altScreen = false, attached = false;
|
|
108
122
|
function finish() {
|
|
109
123
|
stdout.off('resize', onResize);
|
|
110
124
|
stdin.off('data', onInput);
|
|
@@ -113,7 +127,8 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
113
127
|
stdin.pause();
|
|
114
128
|
// An exiting Pi restores the terminal itself.
|
|
115
129
|
// Leaving the alternate screen returns the cursor to the line below the command; otherwise it ends a line.
|
|
116
|
-
|
|
130
|
+
// Terminals keep a keyboard mode stack per screen, so the reset comes before leaving it.
|
|
131
|
+
if (result?.reason !== 'exited') stdout.write(altScreen ? `${RESET}\x1b[?1049l` : `${RESET}\r\n`);
|
|
117
132
|
}
|
|
118
133
|
resolve(result ?? { reason: 'closed' });
|
|
119
134
|
}
|
|
@@ -129,6 +144,11 @@ export function attach(socketPath, { isDetach, stdin = process.stdin, stdout = p
|
|
|
129
144
|
if (type === FRAME.data) {
|
|
130
145
|
for (const [, mode] of body.toString('latin1').matchAll(ALT_SCREEN)) altScreen = mode === 'h';
|
|
131
146
|
stdout.write(body);
|
|
147
|
+
// The host sends the screen only after it has told Pi that a terminal is attached.
|
|
148
|
+
if (!attached) {
|
|
149
|
+
attached = true;
|
|
150
|
+
onAttach?.();
|
|
151
|
+
}
|
|
132
152
|
}
|
|
133
153
|
else if (type === FRAME.detached) result = { ...json(body) };
|
|
134
154
|
else if (type === FRAME.exited) result = { reason: 'exited', ...json(body) };
|