@tshu-w/pi-agents 0.0.0-stage → 0.1.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/LICENSE +661 -0
- package/README.md +81 -2
- package/bin/daemon.mjs +204 -0
- package/bin/host.mjs +170 -0
- package/bin/pd.mjs +139 -0
- package/docs/PROGRAM.md +310 -0
- package/docs/SPEC.md +427 -0
- package/docs/WORKBENCH.md +165 -0
- package/package.json +62 -4
- package/src/agents/agents.ts +882 -0
- package/src/agents/registry.ts +257 -0
- package/src/agents/scheduler.ts +204 -0
- package/src/agents/search.ts +131 -0
- package/src/agents/tool.ts +205 -0
- package/src/daemon/client.mjs +84 -0
- package/src/daemon/router.mjs +103 -0
- package/src/daemon/sessions.mjs +129 -0
- package/src/daemon/version.mjs +19 -0
- package/src/host/client.mjs +139 -0
- package/src/host/extension.ts +74 -0
- package/src/host/frames.mjs +34 -0
- package/src/host/keys.mjs +19 -0
- package/src/index.ts +208 -0
- package/src/output.ts +56 -0
- package/src/params.ts +4 -0
- package/src/pi.mjs +16 -0
- package/src/programs/codemode.ts +74 -0
- package/src/programs/execute.ts +131 -0
- package/src/programs/host-patches.ts +128 -0
- package/src/programs/loadout.ts +99 -0
- package/src/programs/programs.ts +284 -0
- package/src/programs/sandbox.ts +90 -0
- package/src/programs/scope.ts +94 -0
- package/src/programs/tool.ts +198 -0
- package/src/render-call.ts +90 -0
- package/src/roots/discovery.mjs +113 -0
- package/src/roots/guard.mjs +227 -0
- package/src/roots/lock-extension.mjs +167 -0
- package/src/roots/locks.mjs +167 -0
- package/src/roots/paths.mjs +66 -0
- package/src/roots/report.ts +156 -0
- package/src/roots/runtime.ts +174 -0
- package/src/roots/snapshot.mjs +46 -0
- package/src/roots/socket-recovery.mjs +21 -0
- package/src/roots/transport.mjs +341 -0
- package/src/ui/panel.ts +96 -0
- package/src/ui/viewer.ts +445 -0
- package/src/workbench.mjs +587 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/** The Agents loaded in this process, shared across trees: who sees whom, how targets resolve, and how messages are addressed. */
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Agents } from "./agents.ts";
|
|
4
|
+
import { TreeScheduler } from "./scheduler.ts";
|
|
5
|
+
|
|
6
|
+
export const MESSAGE_TYPE = "pi-agents";
|
|
7
|
+
export const TREE_ENTRY = "pi-agents-tree";
|
|
8
|
+
|
|
9
|
+
export type AgentState = "running" | "waiting" | "queued" | "idle";
|
|
10
|
+
export type Delivery = "followUp" | "steer" | "write";
|
|
11
|
+
|
|
12
|
+
/** An Agent loaded in this process: a root Session or an owned child Session. */
|
|
13
|
+
export interface AgentNode {
|
|
14
|
+
id: string;
|
|
15
|
+
rootId: string;
|
|
16
|
+
/** The node whose tree this node sees: its root, or the Program its Agents belong to. */
|
|
17
|
+
scopeId: string;
|
|
18
|
+
ownerId?: string;
|
|
19
|
+
/** A Program that owns Agents; no Agent sees it, but the user sees it in the task panel and the Agent viewer. */
|
|
20
|
+
program?: boolean;
|
|
21
|
+
/** Whether a Program runs in the background. */
|
|
22
|
+
background?: boolean;
|
|
23
|
+
/** When a Program started. */
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
name(): string | undefined;
|
|
26
|
+
cwd(): string;
|
|
27
|
+
busy(): boolean;
|
|
28
|
+
sessionFile(): string | undefined;
|
|
29
|
+
agents: Agents;
|
|
30
|
+
/** Delivers a message to this node; used only for root Agents. */
|
|
31
|
+
receive(text: string, delivery: Delivery, messageId?: string): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Entry {
|
|
35
|
+
id: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
ownerId?: string;
|
|
38
|
+
cwd: string;
|
|
39
|
+
state: AgentState;
|
|
40
|
+
sessionFile?: string;
|
|
41
|
+
/** When an owned Agent was spawned. */
|
|
42
|
+
createdAt?: string;
|
|
43
|
+
/** A root Agent loaded in another process. */
|
|
44
|
+
remote?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Shared {
|
|
48
|
+
scheduler: TreeScheduler;
|
|
49
|
+
nodes: Map<string, AgentNode>;
|
|
50
|
+
listeners?: Set<() => void>;
|
|
51
|
+
/** Work that keeps a tree busy, by the ID of its root. */
|
|
52
|
+
work?: Map<symbol, string>;
|
|
53
|
+
/** The other root Agents last listed, for short IDs shown to a root Agent. */
|
|
54
|
+
rootIds?: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const shared = ((globalThis as Record<symbol, unknown>)[Symbol.for("pi-agents:runtime")] ??= {
|
|
58
|
+
scheduler: new TreeScheduler(),
|
|
59
|
+
nodes: new Map(),
|
|
60
|
+
}) as Shared;
|
|
61
|
+
// Child Sessions load their own copy of this module; keep shared state but adopt current methods.
|
|
62
|
+
Object.setPrototypeOf(shared.scheduler, TreeScheduler.prototype);
|
|
63
|
+
const listeners = shared.listeners ??= new Set();
|
|
64
|
+
const work = shared.work ??= new Map();
|
|
65
|
+
|
|
66
|
+
export const nodes = shared.nodes;
|
|
67
|
+
export const scheduler = shared.scheduler;
|
|
68
|
+
|
|
69
|
+
/** Calls `listener` when an Agent's state or inputs change in any tree of this process. */
|
|
70
|
+
export function onTreeChange(listener: () => void): () => void {
|
|
71
|
+
listeners.add(listener);
|
|
72
|
+
return () => listeners.delete(listener);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function treeChanged(): void {
|
|
76
|
+
for (const listener of listeners) listener();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Keeps a tree busy until the returned function is called. */
|
|
80
|
+
export function trackTreeWork(rootId: string): () => void {
|
|
81
|
+
const token = Symbol(rootId);
|
|
82
|
+
work.set(token, rootId);
|
|
83
|
+
treeChanged();
|
|
84
|
+
return () => {
|
|
85
|
+
if (work.delete(token)) treeChanged();
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The Programs a node runs, oldest first. */
|
|
90
|
+
export function programsOf(node: AgentNode): AgentNode[] {
|
|
91
|
+
return [...shared.nodes.values()]
|
|
92
|
+
.filter((program) => program.program && program.ownerId === node.id)
|
|
93
|
+
.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The node's running owned Agents and background Programs, with those under them. */
|
|
97
|
+
export function liveWork(node: AgentNode): number {
|
|
98
|
+
let count = 0;
|
|
99
|
+
for (const entry of node.agents.owned()) {
|
|
100
|
+
if (entry.state !== "idle") count += 1;
|
|
101
|
+
const loaded = shared.nodes.get(entry.id);
|
|
102
|
+
if (loaded) count += liveWork(loaded);
|
|
103
|
+
}
|
|
104
|
+
for (const program of programsOf(node)) {
|
|
105
|
+
if (program.background) count += 1;
|
|
106
|
+
count += liveWork(program);
|
|
107
|
+
}
|
|
108
|
+
return count;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `Program <short ID>`, unique among its caller's running Programs. */
|
|
112
|
+
export function programLabel(program: AgentNode): string {
|
|
113
|
+
const owner = program.ownerId === undefined ? undefined : shared.nodes.get(program.ownerId);
|
|
114
|
+
return `Program ${shortId(program.id, owner ? programsOf(owner).map((other) => other.id) : [])}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The shortest prefix of `id`, at least 8 characters, that none of the other `ids` starts with. */
|
|
118
|
+
export function shortId(id: string, ids: Iterable<string>): string {
|
|
119
|
+
let length = Math.min(8, id.length);
|
|
120
|
+
for (const other of ids) {
|
|
121
|
+
while (other !== id && length < id.length && other.startsWith(id.slice(0, length))) length++;
|
|
122
|
+
}
|
|
123
|
+
return id.slice(0, length);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** An Agent's name and short ID among `ids`, the IDs of the Agents visible to the reader. */
|
|
127
|
+
export function label(agent: { id: string; name?: string }, ids: Iterable<string>): string {
|
|
128
|
+
const id = shortId(agent.id, ids);
|
|
129
|
+
return agent.name ? `${agent.name} (${id})` : id;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function entryLine(entry: Entry, ids: Iterable<string>): string {
|
|
133
|
+
return `${label(entry, ids)} ${entry.state} ${entry.cwd}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function rememberRoots(ids: string[]): void {
|
|
137
|
+
shared.rootIds = ids;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** IDs of the Agents visible to an Agent with the given scope; a root Agent also sees the other roots. */
|
|
141
|
+
export function visibleIds(scopeId: string, root: boolean): string[] {
|
|
142
|
+
const ids = treeEntries(scopeId).map((entry) => entry.id);
|
|
143
|
+
return root ? [...ids, ...(shared.rootIds ?? [])] : ids;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** IDs of the Agents visible to `target`. */
|
|
147
|
+
function recipientIds(target: Entry): string[] {
|
|
148
|
+
if (target.ownerId === undefined) return visibleIds(target.id, true);
|
|
149
|
+
const owner = shared.nodes.get(target.ownerId);
|
|
150
|
+
return visibleIds(owner?.program ? target.id : owner?.scopeId ?? target.id, false);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface TreeMetadata {
|
|
154
|
+
rootId: string;
|
|
155
|
+
ownerId: string;
|
|
156
|
+
scopeId?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Owner, root, and scope recorded in an owned Agent's Session. */
|
|
160
|
+
export function treeMetadata(ctx: ExtensionContext): TreeMetadata | undefined {
|
|
161
|
+
let metadata: TreeMetadata | undefined;
|
|
162
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
163
|
+
if (entry.type === "custom" && entry.customType === TREE_ENTRY) metadata = entry.data as typeof metadata;
|
|
164
|
+
}
|
|
165
|
+
return metadata;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Visible Agents of a scope, its root first, then owned Agents depth-first. */
|
|
169
|
+
export function treeEntries(scopeId: string): Entry[] {
|
|
170
|
+
const root = shared.nodes.get(scopeId);
|
|
171
|
+
if (!root) return [];
|
|
172
|
+
const entries: Entry[] = root.program ? [] : [{
|
|
173
|
+
id: root.id,
|
|
174
|
+
name: root.name(),
|
|
175
|
+
cwd: root.cwd(),
|
|
176
|
+
state: root.busy() || !treeIdle(root.rootId) ? "running" : "idle",
|
|
177
|
+
sessionFile: root.sessionFile(),
|
|
178
|
+
}];
|
|
179
|
+
const visit = (node: AgentNode) => {
|
|
180
|
+
for (const agent of node.agents.owned()) {
|
|
181
|
+
entries.push({ ...agent, ownerId: node.id });
|
|
182
|
+
const loaded = shared.nodes.get(agent.id);
|
|
183
|
+
if (loaded) visit(loaded);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
visit(root);
|
|
187
|
+
return entries;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function resolveTarget(scopeId: string, target: string): Entry {
|
|
191
|
+
return resolveIn(treeEntries(scopeId), target);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolves an ID, a name, or a unique ID prefix among the given visible Agents. */
|
|
195
|
+
export function resolveIn(entries: Entry[], target: string): Entry {
|
|
196
|
+
const byId = entries.find((entry) => entry.id === target);
|
|
197
|
+
if (byId) return byId;
|
|
198
|
+
let matches = entries.filter((entry) => entry.name === target);
|
|
199
|
+
if (matches.length === 0) matches = entries.filter((entry) => entry.id.startsWith(target));
|
|
200
|
+
if (matches.length === 0) throw new Error(`No visible Agent matches "${target}". Use list to find Agents.`);
|
|
201
|
+
if (matches.length > 1) {
|
|
202
|
+
const ids = entries.map((entry) => entry.id);
|
|
203
|
+
throw new Error(`"${target}" matches several Agents:\n${matches.map((entry) => entryLine(entry, ids)).join("\n")}\n\nRetry with a longer ID prefix.`);
|
|
204
|
+
}
|
|
205
|
+
return matches[0]!;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Sends a message from one Agent to another visible Agent. */
|
|
209
|
+
export function deliver(
|
|
210
|
+
from: { id: string; name?: string } | undefined,
|
|
211
|
+
target: Entry,
|
|
212
|
+
delivery: Delivery,
|
|
213
|
+
body: string,
|
|
214
|
+
): { queued: boolean } {
|
|
215
|
+
const fromOwner = from !== undefined && target.ownerId === from.id;
|
|
216
|
+
const ids = recipientIds(target);
|
|
217
|
+
const text = from === undefined ? body : messageText(from, fromOwner, delivery, body, ids);
|
|
218
|
+
if (target.ownerId === undefined) {
|
|
219
|
+
const node = shared.nodes.get(target.id);
|
|
220
|
+
if (!node) throw new Error(`${label(target, ids)} is offline.`);
|
|
221
|
+
node.receive(text, delivery);
|
|
222
|
+
return { queued: false };
|
|
223
|
+
}
|
|
224
|
+
const owner = shared.nodes.get(target.ownerId);
|
|
225
|
+
if (!owner) throw new Error(`${label(target, ids)} is offline.`);
|
|
226
|
+
return owner.agents.accept(target.id, text, delivery, { fromOwner, notification: from === undefined });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const MESSAGE_TAG = "agent-message";
|
|
230
|
+
|
|
231
|
+
/** A message in an element naming its sender; an input from a sender other than the owner asks for a reply. */
|
|
232
|
+
export function messageText(from: { id: string; name?: string }, fromOwner: boolean, delivery: Delivery, body: string, ids: Iterable<string>): string {
|
|
233
|
+
const note = !fromOwner && delivery !== "write" ? ' note="Reply with send"' : "";
|
|
234
|
+
return `<${MESSAGE_TAG} from="${from.name ?? shortId(from.id, ids)}" id="${shortId(from.id, ids)}"${note}>\n${body}\n</${MESSAGE_TAG}>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `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 Agent viewer.
|
|
240
|
+
*/
|
|
241
|
+
export function customMessage(text: string, delivery: Delivery, messageId?: string, user?: boolean) {
|
|
242
|
+
return { customType: MESSAGE_TYPE, content: text, display: true, details: { delivery, ...(messageId ? { messageId } : {}), ...(user ? { user } : {}) } };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function receivedMessageIds(ctx: ExtensionContext): string[] {
|
|
246
|
+
return ctx.sessionManager.getEntries().flatMap((entry) =>
|
|
247
|
+
entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof (entry.details as { messageId?: unknown })?.messageId === "string"
|
|
248
|
+
? [(entry.details as { messageId: string }).messageId]
|
|
249
|
+
: []);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whether a tree has no live work: unended inputs, owned Agents that are not idle, background Programs, or undelivered notifications. */
|
|
253
|
+
export function treeIdle(rootId: string): boolean {
|
|
254
|
+
return shared.scheduler.outstanding(rootId) === 0 &&
|
|
255
|
+
![...work.values()].includes(rootId) &&
|
|
256
|
+
![...shared.nodes.values()].some((node) => node.rootId === rootId && node.agents.live());
|
|
257
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/** Execution slots and outstanding-input permits shared by the owned Agents of each root. */
|
|
2
|
+
interface SuspendedOwner {
|
|
3
|
+
count: number;
|
|
4
|
+
resuming: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface CapacityWaiter {
|
|
8
|
+
id: string;
|
|
9
|
+
resolve(): void;
|
|
10
|
+
reject(error: unknown): void;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
onAbort?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class TreeScheduler {
|
|
16
|
+
private runningByRoot = new Map<string, Set<string>>();
|
|
17
|
+
private waitersByRoot = new Map<string, CapacityWaiter[]>();
|
|
18
|
+
private resumeWaitersByRoot = new Map<string, CapacityWaiter[]>();
|
|
19
|
+
private suspendedByRoot = new Map<string, Map<string, SuspendedOwner>>();
|
|
20
|
+
private outstandingByRoot = new Map<string, Set<symbol>>();
|
|
21
|
+
private maximumByRoot = new Map<string, number>();
|
|
22
|
+
|
|
23
|
+
hasCapacity(rootId: string, maximum: number): boolean {
|
|
24
|
+
return (this.runningByRoot.get(rootId)?.size ?? 0) < maximum;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
acquire(rootId: string, id: string, maximum: number): boolean {
|
|
28
|
+
this.rememberMaximum(rootId, maximum);
|
|
29
|
+
const running = this.runningByRoot.get(rootId) ?? new Set<string>();
|
|
30
|
+
if (running.has(id)) return true;
|
|
31
|
+
if (running.size >= maximum) return false;
|
|
32
|
+
running.add(id);
|
|
33
|
+
this.runningByRoot.set(rootId, running);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
acquireQueued(rootId: string, id: string, maximum: number, signal?: AbortSignal, resume = false): Promise<void> {
|
|
38
|
+
this.rememberMaximum(rootId, maximum);
|
|
39
|
+
return new Promise<void>((resolve, reject) => {
|
|
40
|
+
if (signal?.aborted) {
|
|
41
|
+
reject(new DOMException("Capacity wait aborted", "AbortError"));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const queueMap = resume ? this.resumeWaitersByRoot : this.waitersByRoot;
|
|
45
|
+
const queue = queueMap.get(rootId) ?? [];
|
|
46
|
+
const waiter: CapacityWaiter = { id, resolve, reject, signal };
|
|
47
|
+
waiter.onAbort = () => {
|
|
48
|
+
const index = queue.indexOf(waiter);
|
|
49
|
+
if (index >= 0) queue.splice(index, 1);
|
|
50
|
+
if (queue.length === 0) queueMap.delete(rootId);
|
|
51
|
+
reject(new DOMException("Capacity wait aborted", "AbortError"));
|
|
52
|
+
this.drain(rootId, maximum);
|
|
53
|
+
};
|
|
54
|
+
queue.push(waiter);
|
|
55
|
+
queueMap.set(rootId, queue);
|
|
56
|
+
signal?.addEventListener("abort", waiter.onAbort, { once: true });
|
|
57
|
+
this.drain(rootId, maximum);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
release(rootId: string, id: string): void {
|
|
62
|
+
const running = this.runningByRoot.get(rootId);
|
|
63
|
+
if (running?.delete(id) && running.size === 0) this.runningByRoot.delete(rootId);
|
|
64
|
+
const suspended = this.suspendedByRoot.get(rootId);
|
|
65
|
+
if (suspended?.delete(id) && suspended.size === 0) this.suspendedByRoot.delete(rootId);
|
|
66
|
+
this.rejectWaiters(rootId, id);
|
|
67
|
+
this.drain(rootId, this.maximum(rootId));
|
|
68
|
+
this.cleanupRoot(rootId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
suspend(rootId: string, id: string): boolean {
|
|
72
|
+
const suspended = this.suspendedByRoot.get(rootId) ?? new Map<string, SuspendedOwner>();
|
|
73
|
+
const existing = suspended.get(id);
|
|
74
|
+
if (existing) {
|
|
75
|
+
existing.count += 1;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
const running = this.runningByRoot.get(rootId);
|
|
79
|
+
if (!running?.delete(id)) return false;
|
|
80
|
+
if (running.size === 0) this.runningByRoot.delete(rootId);
|
|
81
|
+
suspended.set(id, { count: 1, resuming: false });
|
|
82
|
+
this.suspendedByRoot.set(rootId, suspended);
|
|
83
|
+
this.drain(rootId, this.maximum(rootId));
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async resume(rootId: string, id: string, maximum: number, signal?: AbortSignal): Promise<void> {
|
|
88
|
+
const suspended = this.suspendedByRoot.get(rootId);
|
|
89
|
+
const state = suspended?.get(id);
|
|
90
|
+
if (!state) return;
|
|
91
|
+
state.count -= 1;
|
|
92
|
+
if (state.count > 0 || state.resuming) return;
|
|
93
|
+
if (signal?.aborted) {
|
|
94
|
+
suspended!.delete(id);
|
|
95
|
+
if (suspended!.size === 0) this.suspendedByRoot.delete(rootId);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
state.resuming = true;
|
|
99
|
+
try {
|
|
100
|
+
await this.acquireQueued(rootId, id, maximum, signal, true);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
state.resuming = false;
|
|
103
|
+
if (signal?.aborted || suspended?.get(id) !== state) {
|
|
104
|
+
suspended?.delete(id);
|
|
105
|
+
if (suspended?.size === 0) this.suspendedByRoot.delete(rootId);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
if (suspended?.get(id) !== state) {
|
|
111
|
+
this.dropRunning(rootId, id);
|
|
112
|
+
this.drain(rootId, maximum);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (state.count === 0) {
|
|
116
|
+
suspended.delete(id);
|
|
117
|
+
if (suspended.size === 0) this.suspendedByRoot.delete(rootId);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.dropRunning(rootId, id);
|
|
121
|
+
state.resuming = false;
|
|
122
|
+
this.drain(rootId, maximum);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
reserveOutstanding(rootId: string, maximum: number): symbol | undefined {
|
|
126
|
+
const outstanding = this.outstandingByRoot.get(rootId) ?? new Set<symbol>();
|
|
127
|
+
if (outstanding.size >= maximum) return undefined;
|
|
128
|
+
const permit = Symbol(rootId);
|
|
129
|
+
outstanding.add(permit);
|
|
130
|
+
this.outstandingByRoot.set(rootId, outstanding);
|
|
131
|
+
return permit;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
outstanding(rootId: string): number {
|
|
135
|
+
return this.outstandingByRoot.get(rootId)?.size ?? 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
releaseOutstanding(rootId: string, permit: symbol): void {
|
|
139
|
+
const outstanding = this.outstandingByRoot.get(rootId);
|
|
140
|
+
if (!outstanding?.delete(permit)) return;
|
|
141
|
+
if (outstanding.size === 0) this.outstandingByRoot.delete(rootId);
|
|
142
|
+
this.cleanupRoot(rootId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private rememberMaximum(rootId: string, maximum: number): void {
|
|
146
|
+
this.maximumByRoot.set(rootId, maximum);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private maximum(rootId: string): number {
|
|
150
|
+
return this.maximumByRoot.get(rootId) ?? 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private drain(rootId: string, maximum: number): void {
|
|
154
|
+
const running = this.runningByRoot.get(rootId) ?? new Set<string>();
|
|
155
|
+
while (running.size < maximum) {
|
|
156
|
+
const resumeQueue = this.resumeWaitersByRoot.get(rootId);
|
|
157
|
+
const normalQueue = this.waitersByRoot.get(rootId);
|
|
158
|
+
const queue = resumeQueue?.length ? resumeQueue : normalQueue;
|
|
159
|
+
const waiter = queue?.shift();
|
|
160
|
+
if (!waiter) break;
|
|
161
|
+
if (queue!.length === 0) {
|
|
162
|
+
if (queue === resumeQueue) this.resumeWaitersByRoot.delete(rootId);
|
|
163
|
+
else this.waitersByRoot.delete(rootId);
|
|
164
|
+
}
|
|
165
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort!);
|
|
166
|
+
if (waiter.signal?.aborted) {
|
|
167
|
+
waiter.reject(new DOMException("Capacity wait aborted", "AbortError"));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
running.add(waiter.id);
|
|
171
|
+
this.runningByRoot.set(rootId, running);
|
|
172
|
+
waiter.resolve();
|
|
173
|
+
}
|
|
174
|
+
if (running.size === 0) this.runningByRoot.delete(rootId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private dropRunning(rootId: string, id: string): void {
|
|
178
|
+
const running = this.runningByRoot.get(rootId);
|
|
179
|
+
if (!running?.delete(id)) return;
|
|
180
|
+
if (running.size === 0) this.runningByRoot.delete(rootId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private cleanupRoot(rootId: string): void {
|
|
184
|
+
if (this.runningByRoot.has(rootId) || this.waitersByRoot.has(rootId) ||
|
|
185
|
+
this.resumeWaitersByRoot.has(rootId) || this.suspendedByRoot.has(rootId) ||
|
|
186
|
+
this.outstandingByRoot.has(rootId)) return;
|
|
187
|
+
this.maximumByRoot.delete(rootId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private rejectWaiters(rootId: string, id: string): void {
|
|
191
|
+
for (const queues of [this.waitersByRoot, this.resumeWaitersByRoot]) {
|
|
192
|
+
const queue = queues.get(rootId);
|
|
193
|
+
if (!queue) continue;
|
|
194
|
+
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
|
195
|
+
const waiter = queue[index]!;
|
|
196
|
+
if (waiter.id !== id) continue;
|
|
197
|
+
queue.splice(index, 1);
|
|
198
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort!);
|
|
199
|
+
waiter.reject(new DOMException("Capacity wait released", "AbortError"));
|
|
200
|
+
}
|
|
201
|
+
if (queue.length === 0) queues.delete(rootId);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { entryLine, MESSAGE_TAG, MESSAGE_TYPE, type Entry } from "./registry.ts";
|
|
3
|
+
|
|
4
|
+
interface SearchText {
|
|
5
|
+
time: number;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Parsed texts by Session file, reused while the file is unchanged. */
|
|
10
|
+
const cache = new Map<string, { stamp: string; texts: SearchText[] }>();
|
|
11
|
+
|
|
12
|
+
function sessionTexts(sessionFile: string | undefined): SearchText[] {
|
|
13
|
+
if (!sessionFile) return [];
|
|
14
|
+
const stats = statSync(sessionFile, { throwIfNoEntry: false });
|
|
15
|
+
if (!stats) {
|
|
16
|
+
cache.delete(sessionFile);
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const stamp = `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}`;
|
|
20
|
+
const cached = cache.get(sessionFile);
|
|
21
|
+
if (cached?.stamp === stamp) return cached.texts;
|
|
22
|
+
const texts: SearchText[] = [];
|
|
23
|
+
for (const line of readFileSync(sessionFile, "utf8").split("\n")) {
|
|
24
|
+
if (!line) continue;
|
|
25
|
+
let entry: Record<string, any>;
|
|
26
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
27
|
+
const time = Date.parse(entry.timestamp ?? "") || 0;
|
|
28
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
29
|
+
if (typeof entry.summary === "string") texts.push({ time, text: entry.summary });
|
|
30
|
+
} else if (entry.type === "message" && entry.message?.role === "user") {
|
|
31
|
+
const content = entry.message.content;
|
|
32
|
+
const text = typeof content === "string"
|
|
33
|
+
? content
|
|
34
|
+
: Array.isArray(content) ? content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n") : "";
|
|
35
|
+
if (text) texts.push({ time, text });
|
|
36
|
+
} else if (entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
|
|
37
|
+
texts.push({ time, text: entry.content });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
cache.set(sessionFile, { stamp, texts });
|
|
41
|
+
return texts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function matcher(query: string): (text: string) => string[] | undefined {
|
|
45
|
+
const groups = query.toLowerCase().split("|").map((group) => group.trim().split(/\s+/).filter(Boolean)).filter((group) => group.length > 0);
|
|
46
|
+
return (text) => {
|
|
47
|
+
const lower = text.toLowerCase();
|
|
48
|
+
return groups.find((terms) => terms.every((term) => lower.includes(term)));
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function excerpt(text: string, term: string): string {
|
|
53
|
+
const flat = text.replace(/\s+/g, " ");
|
|
54
|
+
const index = Math.max(0, flat.toLowerCase().indexOf(term));
|
|
55
|
+
const start = Math.max(0, index - 40);
|
|
56
|
+
const end = Math.min(flat.length, index + term.length + 80);
|
|
57
|
+
return `${start > 0 ? "…" : ""}${flat.slice(start, end)}${end < flat.length ? "…" : ""}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatTime(time: number): string {
|
|
61
|
+
const date = new Date(time);
|
|
62
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
63
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ListItem {
|
|
67
|
+
entry: Entry;
|
|
68
|
+
match?: SearchText & { excerpt: string };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Filters entries by a query over ID, name, cwd, summaries, and user messages, most recent match first. */
|
|
72
|
+
export function searchEntries(entries: Entry[], query: string | undefined): ListItem[] {
|
|
73
|
+
if (!query?.trim()) return entries.map((entry) => ({ entry }));
|
|
74
|
+
const match = matcher(query);
|
|
75
|
+
const items: Array<ListItem & { time: number }> = [];
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
let latest: ListItem["match"];
|
|
78
|
+
for (const text of sessionTexts(entry.sessionFile)) {
|
|
79
|
+
const terms = match(text.text);
|
|
80
|
+
if (terms && (!latest || text.time >= latest.time)) latest = { ...text, excerpt: excerpt(text.text, terms[0]!) };
|
|
81
|
+
}
|
|
82
|
+
if (latest) items.push({ entry, match: latest, time: latest.time });
|
|
83
|
+
else if (match([entry.id, entry.name ?? "", entry.cwd].join("\n"))) items.push({ entry, time: 0 });
|
|
84
|
+
}
|
|
85
|
+
return items.sort((left, right) => right.time - left.time).map(({ time: _time, ...item }) => item);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function listLine(item: ListItem, ids: Iterable<string>): string {
|
|
89
|
+
const line = entryLine(item.entry, ids);
|
|
90
|
+
return item.match ? `${line}\n ${formatTime(item.match.time)} ${item.match.excerpt}` : line;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* An Agent's latest activity on one line: its last tool call, as `$ <command>` for bash, the tool and
|
|
95
|
+
* its path for file tools, or the `agent` action and its targets; or the last line of its last reply; before it has any, its first input without the sender header. A forked
|
|
96
|
+
* conversation is not its input.
|
|
97
|
+
*/
|
|
98
|
+
export function latestActivity(sessionFile: string | undefined): string | undefined {
|
|
99
|
+
if (!sessionFile || !existsSync(sessionFile)) return undefined;
|
|
100
|
+
let input: string | undefined;
|
|
101
|
+
let activity: string | undefined;
|
|
102
|
+
for (const line of readFileSync(sessionFile, "utf8").split("\n")) {
|
|
103
|
+
let entry: Record<string, any>;
|
|
104
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
105
|
+
if (input === undefined && entry.type === "custom_message" && entry.customType === MESSAGE_TYPE && typeof entry.content === "string") {
|
|
106
|
+
input = entry.content.startsWith(`<${MESSAGE_TAG} `) ? entry.content.slice(entry.content.indexOf("\n") + 1, entry.content.lastIndexOf("\n")) : entry.content;
|
|
107
|
+
}
|
|
108
|
+
if (input !== undefined && entry.type === "message" && entry.message?.role === "assistant" && Array.isArray(entry.message.content)) {
|
|
109
|
+
activity = replyActivity(entry.message.content) ?? activity;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return (activity ?? input)?.replace(/\s+/g, " ").trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toolActivity(name: string, args: Record<string, any>): string {
|
|
116
|
+
if (name === "bash" && typeof args.command === "string") return `$ ${args.command}`;
|
|
117
|
+
if (typeof args.path === "string") return `${name} ${args.path}`;
|
|
118
|
+
if (name === "agent" && typeof args.action === "string") {
|
|
119
|
+
const targets = [args.target, args.name].flat().filter((target) => typeof target === "string");
|
|
120
|
+
return [args.action, targets.join(", ")].filter(Boolean).join(" ");
|
|
121
|
+
}
|
|
122
|
+
return name;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function replyActivity(content: Array<Record<string, any>>): string | undefined {
|
|
126
|
+
for (const block of [...content].reverse()) {
|
|
127
|
+
if (block.type === "toolCall") return toolActivity(block.name, block.arguments ?? {});
|
|
128
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.trim()) return block.text.trim().split("\n").at(-1);
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|