@giladbarnea/pi-user-agents 0.0.4 → 0.0.6
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/INTERNALS.md +36 -6
- package/PARSER_SPEC.md +3 -3
- package/README.md +83 -26
- package/agent-viewer.ts +66 -14
- package/attach.ts +339 -0
- package/command-line.ts +10 -10
- package/index.ts +29 -7
- package/package.json +1 -1
- package/rebase.ts +67 -0
- package/runner.ts +217 -69
- package/shared.ts +104 -11
- package/transcript.ts +51 -17
- package/widget.ts +122 -47
package/attach.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createAgentSessionFromServices, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { conversationFingerprint } from "./rebase.js";
|
|
5
|
+
import {
|
|
6
|
+
assistantText,
|
|
7
|
+
buildChildResourceLoaderOptions,
|
|
8
|
+
createChildServices,
|
|
9
|
+
DISPATCH_PREAMBLE,
|
|
10
|
+
parseForwardedArgs,
|
|
11
|
+
reportAgentFailure,
|
|
12
|
+
resolveToolOptions,
|
|
13
|
+
runChildTurns,
|
|
14
|
+
subscribeToChildSession,
|
|
15
|
+
waitForInstruction,
|
|
16
|
+
} from "./runner.js";
|
|
17
|
+
import type {
|
|
18
|
+
AgentMessage,
|
|
19
|
+
AgentSession,
|
|
20
|
+
AttachedEntryData,
|
|
21
|
+
DispatchRecordData,
|
|
22
|
+
ExtensionAPI,
|
|
23
|
+
ExtensionCommandContext,
|
|
24
|
+
Model,
|
|
25
|
+
RunningAgent,
|
|
26
|
+
} from "./shared.js";
|
|
27
|
+
import {
|
|
28
|
+
ATTACHED_ENTRY_TYPE,
|
|
29
|
+
DISPATCH_ENTRY_TYPE,
|
|
30
|
+
errorMessage,
|
|
31
|
+
formatModel,
|
|
32
|
+
formatModelLabel,
|
|
33
|
+
logSteering,
|
|
34
|
+
} from "./shared.js";
|
|
35
|
+
import { buildAgentResultMessage, reportCommandError } from "./transcript.js";
|
|
36
|
+
import type { UserAgentWidget } from "./widget.js";
|
|
37
|
+
|
|
38
|
+
/** A dispatch base that can never fingerprint-match a live context, so rebase stays withheld. */
|
|
39
|
+
export const UNKNOWN_DISPATCH_BASE = "unknown-dispatch-base";
|
|
40
|
+
|
|
41
|
+
export type DispatchBoundary = {
|
|
42
|
+
/** The main-session context the child inherited at dispatch — the rebase fast-forward base. */
|
|
43
|
+
base: AgentMessage[];
|
|
44
|
+
/** The child's own conversation, starting at the preambled dispatch message. */
|
|
45
|
+
conversation: AgentMessage[];
|
|
46
|
+
/** The dispatch task, preamble stripped. */
|
|
47
|
+
task: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Split a child's resolved context at its dispatch boundary — the first user message carrying
|
|
52
|
+
* the dispatch preamble. Undefined when no message carries it (a /fork child, or a child whose
|
|
53
|
+
* own compaction summarized the boundary away).
|
|
54
|
+
*/
|
|
55
|
+
export function splitAtDispatchBoundary(
|
|
56
|
+
messages: readonly AgentMessage[],
|
|
57
|
+
): DispatchBoundary | undefined {
|
|
58
|
+
for (const [index, message] of messages.entries()) {
|
|
59
|
+
if (message.role !== "user") continue;
|
|
60
|
+
const text = userMessageText(message);
|
|
61
|
+
if (!text.startsWith(DISPATCH_PREAMBLE)) continue;
|
|
62
|
+
return {
|
|
63
|
+
base: messages.slice(0, index),
|
|
64
|
+
conversation: messages.slice(index),
|
|
65
|
+
task: text.slice(DISPATCH_PREAMBLE.length),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function userMessageText(message: Extract<AgentMessage, { role: "user" }>): string {
|
|
72
|
+
if (typeof message.content === "string") return message.content;
|
|
73
|
+
return message.content
|
|
74
|
+
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ChildSessionFileMatch = { sessionId: string; file: string };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The session files in a directory whose id matches the query exactly or by prefix.
|
|
82
|
+
* Pi names session files `<timestamp>_<session-id>.jsonl`, so matching reads names only.
|
|
83
|
+
*/
|
|
84
|
+
export function matchChildSessionFiles(
|
|
85
|
+
query: string,
|
|
86
|
+
sessionDirectory: string,
|
|
87
|
+
): ChildSessionFileMatch[] {
|
|
88
|
+
const fileNames = existsSync(sessionDirectory) ? readdirSync(sessionDirectory) : [];
|
|
89
|
+
return fileNames.flatMap((name) => {
|
|
90
|
+
const sessionId = sessionIdFromFileName(name);
|
|
91
|
+
if (sessionId === undefined || !sessionId.startsWith(query)) return [];
|
|
92
|
+
return [{ sessionId, file: join(sessionDirectory, name) }];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sessionIdFromFileName(fileName: string): string | undefined {
|
|
97
|
+
if (!fileName.endsWith(".jsonl")) return undefined;
|
|
98
|
+
const separator = fileName.indexOf("_");
|
|
99
|
+
if (separator === -1) return undefined;
|
|
100
|
+
return fileName.slice(separator + 1, -".jsonl".length);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The dispatch record persisted in the child's file, or undefined for pre-record and /fork children. */
|
|
104
|
+
export function readDispatchRecord(
|
|
105
|
+
childSessionManager: SessionManager,
|
|
106
|
+
): DispatchRecordData | undefined {
|
|
107
|
+
for (const entry of childSessionManager.getEntries()) {
|
|
108
|
+
if (entry.type === "custom" && entry.customType === DISPATCH_ENTRY_TYPE)
|
|
109
|
+
return entry.data as DispatchRecordData | undefined;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Rebuild an idle, steerable RunningAgent from a reopened child session and its resolved model.
|
|
116
|
+
* The child's own conversation, task, latest response, and rebase base all come from the file;
|
|
117
|
+
* the dispatch record refines the task and context flag, and with no recognizable dispatch
|
|
118
|
+
* boundary the whole context attaches and rebase stays withheld.
|
|
119
|
+
*/
|
|
120
|
+
export function buildAttachedAgent(
|
|
121
|
+
sequenceNumber: number,
|
|
122
|
+
childSessionManager: SessionManager,
|
|
123
|
+
model: Model,
|
|
124
|
+
invocation: string,
|
|
125
|
+
record?: DispatchRecordData,
|
|
126
|
+
): RunningAgent {
|
|
127
|
+
const contextMessages = childSessionManager.buildSessionContext().messages;
|
|
128
|
+
const boundary = splitAtDispatchBoundary(contextMessages);
|
|
129
|
+
const conversationMessages = structuredClone(boundary?.conversation ?? contextMessages);
|
|
130
|
+
const firstUserMessage = contextMessages.find((message) => message.role === "user");
|
|
131
|
+
const lastAssistantMessage = conversationMessages.findLast(
|
|
132
|
+
(message): message is Extract<AgentMessage, { role: "assistant" }> =>
|
|
133
|
+
message.role === "assistant",
|
|
134
|
+
);
|
|
135
|
+
const responseText = lastAssistantMessage ? assistantText(lastAssistantMessage).trim() : "";
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const agent: RunningAgent = {
|
|
138
|
+
id: `user-${sequenceNumber.toString(36)}`,
|
|
139
|
+
sessionId: childSessionManager.getSessionId(),
|
|
140
|
+
command: "agent",
|
|
141
|
+
inheritedContext: record
|
|
142
|
+
? !record.isolate
|
|
143
|
+
: boundary
|
|
144
|
+
? boundary.base.length > 0
|
|
145
|
+
: true,
|
|
146
|
+
model: formatModel(model),
|
|
147
|
+
modelLabel: formatModelLabel(model),
|
|
148
|
+
task:
|
|
149
|
+
record?.task ??
|
|
150
|
+
boundary?.task ??
|
|
151
|
+
(firstUserMessage === undefined ? "(no messages)" : userMessageText(firstUserMessage)),
|
|
152
|
+
invocation,
|
|
153
|
+
notifyMainAgent: false,
|
|
154
|
+
dispatchBaseFingerprint: boundary
|
|
155
|
+
? conversationFingerprint(boundary.base)
|
|
156
|
+
: UNKNOWN_DISPATCH_BASE,
|
|
157
|
+
mainContextState: "separate",
|
|
158
|
+
status: "idle",
|
|
159
|
+
startedAt: now,
|
|
160
|
+
turnStartedAt: now,
|
|
161
|
+
completedAt: now,
|
|
162
|
+
activeTools: new Map(),
|
|
163
|
+
toolUses: 0,
|
|
164
|
+
turnCount: 0,
|
|
165
|
+
responseText,
|
|
166
|
+
conversationMessages,
|
|
167
|
+
finished: Promise.resolve(),
|
|
168
|
+
};
|
|
169
|
+
if (responseText)
|
|
170
|
+
agent.pendingSquashMessage = buildAgentResultMessage(
|
|
171
|
+
agent,
|
|
172
|
+
{ ok: true, response: responseText },
|
|
173
|
+
{ display: false },
|
|
174
|
+
);
|
|
175
|
+
return agent;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The attached agent's lifecycle: bind the child's extensions, park until the user steers or
|
|
180
|
+
* retires it, then run turns exactly like a dispatched child. Every failure — the bind's
|
|
181
|
+
* included — settles through the shared error path, and the end shuts the child's extensions
|
|
182
|
+
* down.
|
|
183
|
+
*/
|
|
184
|
+
export async function runAttachedTurns(
|
|
185
|
+
pi: ExtensionAPI,
|
|
186
|
+
isShuttingDown: () => boolean,
|
|
187
|
+
session: AgentSession,
|
|
188
|
+
runningAgent: RunningAgent,
|
|
189
|
+
widget: UserAgentWidget,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
let unsubscribe: (() => void) | undefined;
|
|
192
|
+
try {
|
|
193
|
+
await session.bindExtensions({ mode: "print" });
|
|
194
|
+
unsubscribe = subscribeToChildSession(session, runningAgent, widget);
|
|
195
|
+
// The user can detach during the extension bind, before this lifecycle parks.
|
|
196
|
+
if (runningAgent.aborted) return;
|
|
197
|
+
const instruction = await waitForInstruction(runningAgent, widget);
|
|
198
|
+
if (instruction !== undefined)
|
|
199
|
+
await runChildTurns(pi, isShuttingDown, instruction, session, runningAgent, widget);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
reportAgentFailure(pi, isShuttingDown, runningAgent, widget, error);
|
|
202
|
+
} finally {
|
|
203
|
+
unsubscribe?.();
|
|
204
|
+
logSteering(runningAgent.id, "session-shutdown", { streaming: session.isStreaming });
|
|
205
|
+
await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function handleAttachCommand(
|
|
210
|
+
pi: ExtensionAPI,
|
|
211
|
+
runningAgents: Set<RunningAgent>,
|
|
212
|
+
widget: UserAgentWidget,
|
|
213
|
+
isShuttingDown: () => boolean,
|
|
214
|
+
nextAgentNumber: () => number,
|
|
215
|
+
args: string,
|
|
216
|
+
ctx: ExtensionCommandContext,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
try {
|
|
219
|
+
await attachUserAgent(pi, runningAgents, widget, isShuttingDown, nextAgentNumber, args, ctx);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
reportCommandError(pi, "agent-attach", args, ctx, errorMessage(error));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Reattach a detached child session as a live, parked agent row. Idempotent for a session
|
|
227
|
+
* that already has a row; errors for anything that is not a child session of this session.
|
|
228
|
+
* Both success states end by offering to open the agent's overlay.
|
|
229
|
+
*/
|
|
230
|
+
async function attachUserAgent(
|
|
231
|
+
pi: ExtensionAPI,
|
|
232
|
+
runningAgents: Set<RunningAgent>,
|
|
233
|
+
widget: UserAgentWidget,
|
|
234
|
+
isShuttingDown: () => boolean,
|
|
235
|
+
nextAgentNumber: () => number,
|
|
236
|
+
args: string,
|
|
237
|
+
ctx: ExtensionCommandContext,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
const query = args.trim();
|
|
240
|
+
if (!query || /\s/.test(query)) throw new Error("Usage: /agent-attach <child-session-id>");
|
|
241
|
+
if (ctx.hasUI) widget.setUI(ctx.ui);
|
|
242
|
+
|
|
243
|
+
// One resolution over the whole universe — attached rows and files on disk together — so a
|
|
244
|
+
// prefix unique among rows cannot mask an ambiguity with a detached session, and a live row
|
|
245
|
+
// whose file is not written yet still resolves. Dispatch creates children in pi's default
|
|
246
|
+
// session directory for the cwd; that is where the files are.
|
|
247
|
+
const attachedIds = widget.matchAttachedSessionIds(query);
|
|
248
|
+
const childSessionDirectory = SessionManager.create(ctx.cwd).getSessionDir();
|
|
249
|
+
const fileMatches = matchChildSessionFiles(query, childSessionDirectory);
|
|
250
|
+
const matchedSessionIds = [
|
|
251
|
+
...new Set([...attachedIds, ...fileMatches.map((match) => match.sessionId)]),
|
|
252
|
+
];
|
|
253
|
+
if (matchedSessionIds.length === 0) throw new Error(`No agent session matches "${query}"`);
|
|
254
|
+
if (matchedSessionIds.length > 1)
|
|
255
|
+
throw new Error(`"${query}" matches ${matchedSessionIds.length} sessions; use more characters`);
|
|
256
|
+
const sessionId = matchedSessionIds[0]!;
|
|
257
|
+
|
|
258
|
+
if (attachedIds.includes(sessionId)) {
|
|
259
|
+
await confirmViewAgent(ctx, widget, sessionId, "Agent already attached");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const mainSessionFile = ctx.sessionManager.getSessionFile();
|
|
264
|
+
if (!mainSessionFile)
|
|
265
|
+
throw new Error("This session has no file; only a persisted session can attach agents");
|
|
266
|
+
const childSessionFile = fileMatches.find((match) => match.sessionId === sessionId)!.file;
|
|
267
|
+
const childSessionManager = SessionManager.open(childSessionFile);
|
|
268
|
+
if (childSessionManager.getHeader()?.parentSession !== mainSessionFile)
|
|
269
|
+
throw new Error(
|
|
270
|
+
`Session ${childSessionManager.getSessionId()} is not a child session of this session`,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// The record replays through the same projections dispatch uses; a record-less child (an old
|
|
274
|
+
// dispatch, or a /fork) restores pi defaults.
|
|
275
|
+
const record = readDispatchRecord(childSessionManager);
|
|
276
|
+
const dispatchArgs = parseForwardedArgs(record?.forwardedArgs ?? []);
|
|
277
|
+
const services = await createChildServices(
|
|
278
|
+
ctx,
|
|
279
|
+
buildChildResourceLoaderOptions(dispatchArgs, ctx.cwd),
|
|
280
|
+
);
|
|
281
|
+
// A non-empty session manager takes pi's resume path: messages, model, and thinking level
|
|
282
|
+
// are restored from the child's own file; tools and resources come from the dispatch record.
|
|
283
|
+
const { session, modelFallbackMessage } = await createAgentSessionFromServices({
|
|
284
|
+
services,
|
|
285
|
+
sessionManager: childSessionManager,
|
|
286
|
+
...resolveToolOptions(dispatchArgs),
|
|
287
|
+
});
|
|
288
|
+
const model = session.agent.state.model as Model | undefined;
|
|
289
|
+
if (!model) {
|
|
290
|
+
session.dispose();
|
|
291
|
+
throw new Error(modelFallbackMessage ?? "No model is available for the attached session");
|
|
292
|
+
}
|
|
293
|
+
if (modelFallbackMessage && ctx.hasUI) ctx.ui.notify(modelFallbackMessage, "warning");
|
|
294
|
+
|
|
295
|
+
const runningAgent = buildAttachedAgent(
|
|
296
|
+
nextAgentNumber(),
|
|
297
|
+
childSessionManager,
|
|
298
|
+
model,
|
|
299
|
+
`/agent-attach ${query}`,
|
|
300
|
+
record,
|
|
301
|
+
);
|
|
302
|
+
runningAgent.session = session;
|
|
303
|
+
runningAgents.add(runningAgent);
|
|
304
|
+
logSteering(runningAgent.id, "agent-attached", { sessionId: runningAgent.sessionId });
|
|
305
|
+
if (ctx.hasUI) {
|
|
306
|
+
widget.ensureTimer();
|
|
307
|
+
widget.update();
|
|
308
|
+
}
|
|
309
|
+
// No awaits between the add above and this assignment: every later failure settles inside
|
|
310
|
+
// the lifecycle, so the row can never outlive its own removal hook.
|
|
311
|
+
runningAgent.finished = runAttachedTurns(
|
|
312
|
+
pi,
|
|
313
|
+
isShuttingDown,
|
|
314
|
+
session,
|
|
315
|
+
runningAgent,
|
|
316
|
+
widget,
|
|
317
|
+
).finally(() => {
|
|
318
|
+
logSteering(runningAgent.id, "agent-disposed", { status: runningAgent.status });
|
|
319
|
+
runningAgent.session?.dispose();
|
|
320
|
+
runningAgents.delete(runningAgent);
|
|
321
|
+
widget.update();
|
|
322
|
+
});
|
|
323
|
+
// The detach breadcrumb's counterpart: after a reload the transcript still tells the truth.
|
|
324
|
+
pi.appendEntry<AttachedEntryData>(ATTACHED_ENTRY_TYPE, { sessionId: runningAgent.sessionId });
|
|
325
|
+
if (runningAgent.aborted) return;
|
|
326
|
+
await confirmViewAgent(ctx, widget, runningAgent.sessionId, "Agent attached");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The follow-up both success states share: offer to open the agent's overlay. */
|
|
330
|
+
async function confirmViewAgent(
|
|
331
|
+
ctx: ExtensionCommandContext,
|
|
332
|
+
widget: UserAgentWidget,
|
|
333
|
+
sessionId: string,
|
|
334
|
+
title: string,
|
|
335
|
+
): Promise<void> {
|
|
336
|
+
if (!ctx.hasUI) return;
|
|
337
|
+
const view = await ctx.ui.confirm(title, "View agent?");
|
|
338
|
+
if (view) widget.openViewer(sessionId);
|
|
339
|
+
}
|
package/command-line.ts
CHANGED
|
@@ -57,7 +57,7 @@ export type AgentSemanticToken = {
|
|
|
57
57
|
|
|
58
58
|
export type AgentArgumentScan = {
|
|
59
59
|
isolate: boolean;
|
|
60
|
-
|
|
60
|
+
squash: boolean;
|
|
61
61
|
forwardedArgs: string[];
|
|
62
62
|
/** Ordered, non-overlapping semantic tokens over the argument text. Prose stays untokenized. */
|
|
63
63
|
tokens: AgentSemanticToken[];
|
|
@@ -115,12 +115,12 @@ export const AGENT_OPTIONS: readonly AgentOptionDefinition[] = [
|
|
|
115
115
|
description: "Start without parent-session context",
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
|
-
semanticId: "
|
|
119
|
-
names: ["-
|
|
118
|
+
semanticId: "squash",
|
|
119
|
+
names: ["-s", "--squash"],
|
|
120
120
|
role: "extension",
|
|
121
121
|
arity: "boolean",
|
|
122
122
|
autocomplete: true,
|
|
123
|
-
description: "
|
|
123
|
+
description: "Squash the completed result into the main agent context",
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
semanticId: "provider",
|
|
@@ -360,7 +360,7 @@ export function scanAgentArguments(
|
|
|
360
360
|
const tokens: AgentSemanticToken[] = [];
|
|
361
361
|
const forwardedArgs: string[] = [];
|
|
362
362
|
let isolate = false;
|
|
363
|
-
let
|
|
363
|
+
let squash = false;
|
|
364
364
|
let blocked: AgentSemanticToken | undefined;
|
|
365
365
|
let consumedOption = false;
|
|
366
366
|
let position = 0;
|
|
@@ -393,8 +393,8 @@ export function scanAgentArguments(
|
|
|
393
393
|
}
|
|
394
394
|
} else if (option.semanticId === "isolate") {
|
|
395
395
|
isolate = true;
|
|
396
|
-
} else if (option.semanticId === "
|
|
397
|
-
|
|
396
|
+
} else if (option.semanticId === "squash") {
|
|
397
|
+
squash = true;
|
|
398
398
|
} else if (option.arity === "value") {
|
|
399
399
|
forwardedArgs.push(option.forwardName ?? token);
|
|
400
400
|
const read = readValueSpan(args, position);
|
|
@@ -427,7 +427,7 @@ export function scanAgentArguments(
|
|
|
427
427
|
}
|
|
428
428
|
const scan: AgentArgumentScan = {
|
|
429
429
|
isolate,
|
|
430
|
-
|
|
430
|
+
squash,
|
|
431
431
|
forwardedArgs,
|
|
432
432
|
tokens,
|
|
433
433
|
proseStart,
|
|
@@ -487,11 +487,11 @@ export function parseAgentCommand(args: string, command: AgentCommandName): Pars
|
|
|
487
487
|
}
|
|
488
488
|
if (!scan.prose.trim())
|
|
489
489
|
throw new Error(
|
|
490
|
-
`Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-
|
|
490
|
+
`Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-s|--squash] "<task>"`,
|
|
491
491
|
);
|
|
492
492
|
return {
|
|
493
493
|
isolate: scan.isolate,
|
|
494
|
-
|
|
494
|
+
squash: scan.squash,
|
|
495
495
|
forwardedArgs: scan.forwardedArgs,
|
|
496
496
|
task: scan.task,
|
|
497
497
|
warnings: scan.warnings,
|
package/index.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type {
|
|
|
2
2
|
MessageEndEvent,
|
|
3
3
|
MessageEndEventResult,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { handleAttachCommand } from "./attach.js";
|
|
5
6
|
import { registerAgentAutocomplete } from "./autocomplete.js";
|
|
6
|
-
import { handleAgentCommand } from "./runner.js";
|
|
7
|
+
import { createRebaseDelivery, handleAgentCommand } from "./runner.js";
|
|
7
8
|
import type {
|
|
8
9
|
AgentCommandDetails,
|
|
9
10
|
DetachedEntryData,
|
|
10
11
|
ExtensionAPI,
|
|
12
|
+
ExtensionCommandContext,
|
|
11
13
|
RunningAgent,
|
|
12
14
|
} from "./shared.js";
|
|
13
15
|
import { DETACHED_ENTRY_TYPE, MESSAGE_TYPE } from "./shared.js";
|
|
@@ -16,10 +18,13 @@ import { UserAgentWidget } from "./widget.js";
|
|
|
16
18
|
|
|
17
19
|
export default function userAgent(pi: ExtensionAPI): void {
|
|
18
20
|
const runningAgents = new Set<RunningAgent>();
|
|
21
|
+
// The session every agent was dispatched from; rebase fast-forwards onto it.
|
|
22
|
+
let mainSessionContext: ExtensionCommandContext | undefined;
|
|
19
23
|
const widget = new UserAgentWidget(
|
|
20
24
|
runningAgents,
|
|
21
25
|
(message) => pi.sendMessage(message, { triggerTurn: true }),
|
|
22
26
|
(sessionId) => pi.appendEntry<DetachedEntryData>(DETACHED_ENTRY_TYPE, { sessionId }),
|
|
27
|
+
createRebaseDelivery(pi, () => mainSessionContext),
|
|
23
28
|
);
|
|
24
29
|
let shuttingDown = false;
|
|
25
30
|
let nextAgentNumber = 0;
|
|
@@ -27,12 +32,13 @@ export default function userAgent(pi: ExtensionAPI): void {
|
|
|
27
32
|
registerUserAgentRenderer(pi);
|
|
28
33
|
registerAgentAutocomplete(pi);
|
|
29
34
|
|
|
30
|
-
pi.on("message_end", (event) =>
|
|
35
|
+
pi.on("message_end", (event) => confirmMainContextSquash(event, widget));
|
|
31
36
|
|
|
32
37
|
pi.registerCommand("agent", {
|
|
33
38
|
description:
|
|
34
39
|
"Run a background SDK agent with current session context; forwards pi CLI options like --thinking (-i/--isolate for none)",
|
|
35
40
|
handler: async (args, ctx) => {
|
|
41
|
+
mainSessionContext = ctx;
|
|
36
42
|
await handleAgentCommand(
|
|
37
43
|
pi,
|
|
38
44
|
runningAgents,
|
|
@@ -47,6 +53,22 @@ export default function userAgent(pi: ExtensionAPI): void {
|
|
|
47
53
|
},
|
|
48
54
|
});
|
|
49
55
|
|
|
56
|
+
pi.registerCommand("agent-attach", {
|
|
57
|
+
description: "Reattach a detached agent session by id, back into the widget",
|
|
58
|
+
handler: async (args, ctx) => {
|
|
59
|
+
mainSessionContext = ctx;
|
|
60
|
+
await handleAttachCommand(
|
|
61
|
+
pi,
|
|
62
|
+
runningAgents,
|
|
63
|
+
widget,
|
|
64
|
+
() => shuttingDown,
|
|
65
|
+
() => ++nextAgentNumber,
|
|
66
|
+
args,
|
|
67
|
+
ctx,
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
50
72
|
pi.on("session_shutdown", async () => {
|
|
51
73
|
shuttingDown = true;
|
|
52
74
|
widget.dispose();
|
|
@@ -63,19 +85,19 @@ export default function userAgent(pi: ExtensionAPI): void {
|
|
|
63
85
|
});
|
|
64
86
|
}
|
|
65
87
|
|
|
66
|
-
export function
|
|
88
|
+
export function confirmMainContextSquash(
|
|
67
89
|
event: MessageEndEvent,
|
|
68
90
|
widget: UserAgentWidget,
|
|
69
91
|
): MessageEndEventResult | undefined {
|
|
70
92
|
if (event.message.role !== "custom" || event.message.customType !== MESSAGE_TYPE) return;
|
|
71
93
|
const details = event.message.details as AgentCommandDetails | undefined;
|
|
72
|
-
if (!details?.agentId || details.mainContextState !== "will-
|
|
73
|
-
widget.
|
|
74
|
-
details.mainContextState = "
|
|
94
|
+
if (!details?.agentId || details.mainContextState !== "will-squash") return;
|
|
95
|
+
widget.confirmMainContextSquash(details.agentId);
|
|
96
|
+
details.mainContextState = "squashed";
|
|
75
97
|
return {
|
|
76
98
|
message: {
|
|
77
99
|
...event.message,
|
|
78
|
-
details: { ...details, mainContextState: "
|
|
100
|
+
details: { ...details, mainContextState: "squashed" },
|
|
79
101
|
},
|
|
80
102
|
};
|
|
81
103
|
}
|
package/package.json
CHANGED
package/rebase.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { buildSessionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentMessage, ExtensionCommandContext } from "./shared.js";
|
|
3
|
+
import { MESSAGE_TYPE } from "./shared.js";
|
|
4
|
+
|
|
5
|
+
type MainSessionManager = ExtensionCommandContext["sessionManager"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fingerprint a context snapshot for the rebase fast-forward precondition: the main session's
|
|
9
|
+
* live context must still fingerprint-equal the base the child was dispatched from.
|
|
10
|
+
*
|
|
11
|
+
* Compares content, not file bookkeeping: rebuilding custom, compaction, and branch summaries
|
|
12
|
+
* from a session file re-stamps their timestamps (and a branch summary's fromId), so a base
|
|
13
|
+
* round-tripped through a child's file would never equal main's live context otherwise.
|
|
14
|
+
*
|
|
15
|
+
* @example conversationFingerprint([]) === conversationFingerprint([]) // true
|
|
16
|
+
*/
|
|
17
|
+
export function conversationFingerprint(messages: readonly AgentMessage[]): string {
|
|
18
|
+
return JSON.stringify(messages.map(fingerprintedMessage));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fingerprintedMessage(message: AgentMessage): Omit<AgentMessage, "timestamp"> {
|
|
22
|
+
const { timestamp: _timestamp, ...content } = message;
|
|
23
|
+
if (content.role !== "branchSummary") return content;
|
|
24
|
+
const { fromId: _fromId, ...branchContent } = content;
|
|
25
|
+
return branchContent;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Rebuilding a session-sized context string is only worth doing when the branch tip moved. */
|
|
29
|
+
const contextFingerprintCache = new WeakMap<object, { leafId: string | null; fingerprint: string }>();
|
|
30
|
+
|
|
31
|
+
/** Fingerprint of the main session's current LLM context, cached by branch leaf. */
|
|
32
|
+
export function mainContextFingerprint(sessionManager: MainSessionManager): string {
|
|
33
|
+
const leafId = sessionManager.getLeafId();
|
|
34
|
+
const cached = contextFingerprintCache.get(sessionManager);
|
|
35
|
+
if (cached && cached.leafId === leafId) return cached.fingerprint;
|
|
36
|
+
const fingerprint = conversationFingerprint(
|
|
37
|
+
buildSessionContext(sessionManager.getBranch()).messages,
|
|
38
|
+
);
|
|
39
|
+
contextFingerprintCache.set(sessionManager, { leafId, fingerprint });
|
|
40
|
+
return fingerprint;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Select the child conversation messages to rebase onto the main session, so the record reads
|
|
45
|
+
* as if the user prompted the main session directly. The child's history passes through
|
|
46
|
+
* verbatim — compactions included, so main inherits the child's context boundary — except the
|
|
47
|
+
* first user message, which carries the extension's internal background-process prefix and is
|
|
48
|
+
* replaced by the plain task, and this extension's own custom messages, which are the one
|
|
49
|
+
* trace of the user-agents machinery.
|
|
50
|
+
*/
|
|
51
|
+
export function selectRebaseMessages(
|
|
52
|
+
task: string,
|
|
53
|
+
messages: readonly AgentMessage[],
|
|
54
|
+
): AgentMessage[] {
|
|
55
|
+
let firstUserMessage = true;
|
|
56
|
+
const selected: AgentMessage[] = [];
|
|
57
|
+
for (const message of messages) {
|
|
58
|
+
if (message.role === "custom" && message.customType === MESSAGE_TYPE) continue;
|
|
59
|
+
if (message.role === "user" && firstUserMessage) {
|
|
60
|
+
firstUserMessage = false;
|
|
61
|
+
selected.push({ ...message, content: task });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
selected.push(message);
|
|
65
|
+
}
|
|
66
|
+
return selected;
|
|
67
|
+
}
|