@giladbarnea/pi-user-agents 0.0.5 → 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 +10 -0
- package/README.md +5 -2
- package/attach.ts +339 -0
- package/index.ts +17 -0
- package/package.json +1 -1
- package/rebase.ts +12 -1
- package/runner.ts +90 -46
- package/shared.ts +18 -2
- package/transcript.ts +8 -2
- package/widget.ts +15 -0
package/INTERNALS.md
CHANGED
|
@@ -67,6 +67,16 @@ The file itself appears on the child's first assistant message (`_persist` defer
|
|
|
67
67
|
|
|
68
68
|
`d` `d` ends the live child session and drops its widget row. The session file is untouched, and the transcript gets a `pi-user-agents-detached` custom entry naming the session id so it can be resumed later. Both detach paths funnel through `closeRunning` and `removeCompleted`, which is where the announcement is made; squashing and session shutdown deliberately do not announce.
|
|
69
69
|
|
|
70
|
+
## Attaching
|
|
71
|
+
|
|
72
|
+
`/agent-attach <session-id>` reverses a detach. The id (or a unique prefix) resolves in one pass over the whole universe of candidates: the widget's rows (live and completed) united with file names in pi's default session directory for the cwd — the directory every dispatch creates children in. File names, not `SessionManager.list()`, because list() reads every session file whole, gigabytes in a mature project; the union, because a prefix unique among rows could otherwise mask an ambiguity with a detached file, and a live row whose file is not flushed yet must still resolve. A resolved id that is already a row short-circuits to the "View agent?" confirmation and nothing else — the idempotent case. The authority for "is a child of this session" is the child file's header: its `parentSession` path must equal the main session's file. That is the same field `/fork` writes, so a fork of the main session attaches too.
|
|
73
|
+
|
|
74
|
+
The session file does not persist the dispatch options, so every dispatch writes a `pi-user-agents-dispatch` custom entry into the child — the forwarded pi tokens, the plain task, and the isolate flag. At attach the record replays through the same projections dispatch used (`buildChildResourceLoaderOptions`, `resolveToolOptions`), so `--tools`, `--no-extensions`, `--system-prompt` and friends survive the round trip instead of silently widening back to pi defaults. The model and thinking level deliberately come from the file, not the record: reattachment is pi's own resume path — `SessionManager.open` plus `createAgentSessionFromServices` on a non-empty manager takes `createAgentSession`'s has-existing-session branch — and the file reflects any later changes. A record-less child (dispatched before the record existed, or `/fork`-made) still attaches, with pi's default tools and resources.
|
|
75
|
+
|
|
76
|
+
The dispatch preamble — built at the dispatch call site, sent verbatim by `runChildTurns` — doubles as the boundary marker: `splitAtDispatchBoundary` finds the first user message carrying it and recovers the child's own conversation for the overlay and the inherited base whose fingerprint re-arms the rebase fast-forward. The fingerprint compares content, not file bookkeeping: rebuilding custom, compaction, and branch summaries from a session file re-stamps their timestamps (and a branch summary's `fromId`), so `conversationFingerprint` drops those fields or a round-tripped base would never equal main's live context. Without a recognizable boundary (a fork child, or a child whose compaction summarized it away) the whole context attaches and the base is a sentinel that never matches, so `r` stays withheld.
|
|
77
|
+
|
|
78
|
+
The attached agent parks idle first (`runAttachedTurns` waits for an instruction before running the shared turn loop), and its latest persisted response seeds `pendingSquashMessage`, so steering, `s`, and `r` work immediately without a new turn. A successful attach leaves an `Attached session …` breadcrumb — the detach line's counterpart — and both success states end with a "View agent?" confirmation that opens the overlay.
|
|
79
|
+
|
|
70
80
|
## Runtime sharing
|
|
71
81
|
|
|
72
82
|
Child agent sessions share the parent's `ModelRuntime` instance. This is critical because extension-registered providers (like `claude-bridge`) use a global `Symbol.for` dedup guard — they register once per process and skip subsequent loads. A child session with its own fresh runtime would never receive the provider, leaving it without auth for bridge-backed models.
|
package/README.md
CHANGED
|
@@ -95,6 +95,8 @@ Detached session 0199c4f2-8b1a-7c3d-9e05-6a2f18d7b4ce
|
|
|
95
95
|
|
|
96
96
|
Pick it back up whenever you like — `/resume 0199c4f2`, or `pi -r` and choose it from the list. It resumes as an ordinary Pi session with its full history, its model, and its thinking level, and from there it is a normal agent you talk to directly.
|
|
97
97
|
|
|
98
|
+
Or take it back as a background agent instead: `/agent-attach 0199c4f2` puts the session back in the widget — parked idle, restored as dispatched down to its `--tools` and the rest of its options — and offers to open it. A unique id prefix is enough, only this conversation's own agents qualify, and the transcript records the return: `Attached session 0199c4f2-…`.
|
|
99
|
+
|
|
98
100
|
### 5. Squash — or don't
|
|
99
101
|
|
|
100
102
|
This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
|
|
@@ -154,7 +156,7 @@ Each row carries the agent's own footer gauge: it fills `▁▂▃▄▅▆▇
|
|
|
154
156
|
|
|
155
157
|
## Reference
|
|
156
158
|
|
|
157
|
-
Everything goes through one command: `/agent [options] <task>`.
|
|
159
|
+
Everything goes through one command: `/agent [options] <task>`. Its one companion, `/agent-attach <session-id>`, brings a detached session back into the widget.
|
|
158
160
|
|
|
159
161
|
| Flag | Effect |
|
|
160
162
|
|---|---|
|
|
@@ -187,6 +189,7 @@ Everything goes through one command: `/agent [options] <task>`.
|
|
|
187
189
|
- [x] Agents stay alive until you end them — steer, interrupt, resume, across turns.
|
|
188
190
|
- [x] Squash: a compact record of the exchange, delivered into the main context.
|
|
189
191
|
- [x] Rebase: the raw conversation grafted onto the main session — fast-forward only, compaction-faithful.
|
|
192
|
+
- [x] Attach: a detached session returns to the widget, restored as dispatched.
|
|
190
193
|
- [x] Live syntax coloring and eager autocomplete for the `/agent` line.
|
|
191
194
|
- [ ] Render an agent's compaction event in the overlay.
|
|
192
195
|
- [ ] Wire the accepted-but-inert `pi` options.
|
|
@@ -194,7 +197,7 @@ Everything goes through one command: `/agent [options] <task>`.
|
|
|
194
197
|
|
|
195
198
|
## Under the hood
|
|
196
199
|
|
|
197
|
-
Design notes — result delivery through the Pi SDK, the squashed-message format, the rebase
|
|
200
|
+
Design notes — result delivery through the Pi SDK, the squashed-message format, the rebase and attach mechanisms, runtime sharing, model resolution, and editor internals — live in [INTERNALS.md](INTERNALS.md). The complete command grammar lives in [PARSER_SPEC.md](PARSER_SPEC.md).
|
|
198
201
|
|
|
199
202
|
---
|
|
200
203
|
|
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/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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
7
|
import { createRebaseDelivery, handleAgentCommand } from "./runner.js";
|
|
7
8
|
import type {
|
|
@@ -52,6 +53,22 @@ export default function userAgent(pi: ExtensionAPI): void {
|
|
|
52
53
|
},
|
|
53
54
|
});
|
|
54
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
|
+
|
|
55
72
|
pi.on("session_shutdown", async () => {
|
|
56
73
|
shuttingDown = true;
|
|
57
74
|
widget.dispose();
|
package/package.json
CHANGED
package/rebase.ts
CHANGED
|
@@ -8,10 +8,21 @@ type MainSessionManager = ExtensionCommandContext["sessionManager"];
|
|
|
8
8
|
* Fingerprint a context snapshot for the rebase fast-forward precondition: the main session's
|
|
9
9
|
* live context must still fingerprint-equal the base the child was dispatched from.
|
|
10
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
|
+
*
|
|
11
15
|
* @example conversationFingerprint([]) === conversationFingerprint([]) // true
|
|
12
16
|
*/
|
|
13
17
|
export function conversationFingerprint(messages: readonly AgentMessage[]): string {
|
|
14
|
-
return JSON.stringify(messages);
|
|
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;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
/** Rebuilding a session-sized context string is only worth doing when the branch tip moved. */
|
package/runner.ts
CHANGED
|
@@ -33,7 +33,9 @@ import type {
|
|
|
33
33
|
RunningAgent,
|
|
34
34
|
ThinkingLevel,
|
|
35
35
|
} from "./shared.js";
|
|
36
|
+
import type { DispatchRecordData } from "./shared.js";
|
|
36
37
|
import {
|
|
38
|
+
DISPATCH_ENTRY_TYPE,
|
|
37
39
|
errorMessage,
|
|
38
40
|
formatModel,
|
|
39
41
|
formatModelLabel,
|
|
@@ -90,17 +92,10 @@ async function startUserAgent(
|
|
|
90
92
|
): Promise<void> {
|
|
91
93
|
const parsed = parseAgentCommand(args, command);
|
|
92
94
|
const parsedForwardedArgs = parseForwardedArgs(parsed.forwardedArgs);
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const services = await createAgentSessionServices({
|
|
98
|
-
cwd: ctx.cwd,
|
|
99
|
-
agentDir,
|
|
100
|
-
settingsManager,
|
|
101
|
-
modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime,
|
|
102
|
-
resourceLoaderOptions: buildChildResourceLoaderOptions(parsedForwardedArgs, ctx.cwd),
|
|
103
|
-
});
|
|
95
|
+
const services = await createChildServices(
|
|
96
|
+
ctx,
|
|
97
|
+
buildChildResourceLoaderOptions(parsedForwardedArgs, ctx.cwd),
|
|
98
|
+
);
|
|
104
99
|
const forwarded = resolveForwardedOptions(parsedForwardedArgs, services.modelRuntime);
|
|
105
100
|
const selectedModel = forwarded.model ?? ctx.model;
|
|
106
101
|
if (!selectedModel) throw new Error("No current model is selected; pass -m MODELNAME");
|
|
@@ -112,6 +107,12 @@ async function startUserAgent(
|
|
|
112
107
|
const childSessionManager = SessionManager.create(services.cwd, undefined, {
|
|
113
108
|
parentSession: ctx.sessionManager.getSessionFile(),
|
|
114
109
|
});
|
|
110
|
+
// The dispatch record: /agent-attach restores tool and resource options from it later.
|
|
111
|
+
childSessionManager.appendCustomEntry(DISPATCH_ENTRY_TYPE, {
|
|
112
|
+
forwardedArgs: parsed.forwardedArgs,
|
|
113
|
+
task: parsed.task,
|
|
114
|
+
isolate: parsed.isolate,
|
|
115
|
+
} satisfies DispatchRecordData);
|
|
115
116
|
const runningAgent = createRunningAgent(
|
|
116
117
|
nextAgentNumber(),
|
|
117
118
|
childSessionManager.getSessionId(),
|
|
@@ -263,7 +264,7 @@ export function createRebaseDelivery(
|
|
|
263
264
|
}
|
|
264
265
|
|
|
265
266
|
/** Session inputs derived from forwarded pi CLI options, ready to hand to the child session. */
|
|
266
|
-
type ChildResourceLoaderOptions = {
|
|
267
|
+
export type ChildResourceLoaderOptions = {
|
|
267
268
|
systemPrompt?: string;
|
|
268
269
|
appendSystemPrompt?: string[];
|
|
269
270
|
additionalExtensionPaths?: string[];
|
|
@@ -276,14 +277,37 @@ type ChildResourceLoaderOptions = {
|
|
|
276
277
|
noContextFiles?: boolean;
|
|
277
278
|
};
|
|
278
279
|
|
|
279
|
-
type
|
|
280
|
-
model?: Model;
|
|
281
|
-
thinkingLevel?: ThinkingLevel;
|
|
280
|
+
export type ChildToolOptions = {
|
|
282
281
|
tools?: string[];
|
|
283
282
|
excludeTools?: string[];
|
|
284
283
|
noTools?: "all" | "builtin";
|
|
285
284
|
};
|
|
286
285
|
|
|
286
|
+
type ForwardedOptions = ChildToolOptions & {
|
|
287
|
+
model?: Model;
|
|
288
|
+
thinkingLevel?: ThinkingLevel;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** The cwd-bound services for one child session; the parent's ModelRuntime rides along (see INTERNALS). */
|
|
292
|
+
export function createChildServices(
|
|
293
|
+
ctx: ExtensionCommandContext,
|
|
294
|
+
resourceLoaderOptions?: ChildResourceLoaderOptions,
|
|
295
|
+
): Promise<AgentSessionServices> {
|
|
296
|
+
const agentDir = getAgentDir();
|
|
297
|
+
const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
|
|
298
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
299
|
+
});
|
|
300
|
+
return createAgentSessionServices({
|
|
301
|
+
cwd: ctx.cwd,
|
|
302
|
+
agentDir,
|
|
303
|
+
settingsManager,
|
|
304
|
+
// The extension facade wraps the parent's runtime as a plain property; sharing it keeps
|
|
305
|
+
// extension-registered providers (their process-global dedup guard) available to the child.
|
|
306
|
+
modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime,
|
|
307
|
+
resourceLoaderOptions,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
287
311
|
/** Parse forwarded pi CLI tokens once before model and session resolution. */
|
|
288
312
|
export function parseForwardedArgs(forwardedArgs: string[]): Args {
|
|
289
313
|
const parsed = parseArgs(forwardedArgs);
|
|
@@ -382,12 +406,17 @@ export function resolveForwardedOptions(
|
|
|
382
406
|
}
|
|
383
407
|
}
|
|
384
408
|
if (parsed.thinking) forwarded.thinkingLevel = parsed.thinking;
|
|
409
|
+
return { ...forwarded, ...resolveToolOptions(parsed) };
|
|
410
|
+
}
|
|
385
411
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (parsed.
|
|
390
|
-
|
|
412
|
+
/** Map parsed pi tool options onto child-session inputs; dispatch and attach share this. */
|
|
413
|
+
export function resolveToolOptions(parsed: Args): ChildToolOptions {
|
|
414
|
+
const options: ChildToolOptions = {};
|
|
415
|
+
if (parsed.noTools) options.noTools = "all";
|
|
416
|
+
else if (parsed.noBuiltinTools) options.noTools = "builtin";
|
|
417
|
+
if (parsed.tools) options.tools = parsed.tools;
|
|
418
|
+
if (parsed.excludeTools) options.excludeTools = parsed.excludeTools;
|
|
419
|
+
return options;
|
|
391
420
|
}
|
|
392
421
|
|
|
393
422
|
export function buildChildResourceLoaderOptions(
|
|
@@ -449,29 +478,9 @@ async function runAgentLifecycle(
|
|
|
449
478
|
widget,
|
|
450
479
|
);
|
|
451
480
|
unsubscribe = subscribeToChildSession(session, runningAgent, widget);
|
|
452
|
-
await runChildTurns(pi, isShuttingDown, task, session, runningAgent, widget);
|
|
481
|
+
await runChildTurns(pi, isShuttingDown, DISPATCH_PREAMBLE + task, session, runningAgent, widget);
|
|
453
482
|
} catch (error) {
|
|
454
|
-
|
|
455
|
-
logSteering(runningAgent.id, "agent-aborted");
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
const message = errorMessage(error);
|
|
459
|
-
logSteering(runningAgent.id, "agent-error", { error: message });
|
|
460
|
-
runningAgent.status = "error";
|
|
461
|
-
runningAgent.completedAt = Date.now();
|
|
462
|
-
runningAgent.error = message;
|
|
463
|
-
if (runningAgent.notifyMainAgent && isShuttingDown()) {
|
|
464
|
-
runningAgent.mainContextState = "separate";
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
const resultMessage = buildAgentResultMessage(
|
|
468
|
-
runningAgent,
|
|
469
|
-
{ ok: false, error: message },
|
|
470
|
-
{ display: runningAgent.notifyMainAgent },
|
|
471
|
-
);
|
|
472
|
-
widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
|
|
473
|
-
if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
|
|
474
|
-
runningAgent.status = "posted";
|
|
483
|
+
reportAgentFailure(pi, isShuttingDown, runningAgent, widget, error);
|
|
475
484
|
} finally {
|
|
476
485
|
unsubscribe?.();
|
|
477
486
|
if (session) {
|
|
@@ -481,16 +490,51 @@ async function runAgentLifecycle(
|
|
|
481
490
|
}
|
|
482
491
|
}
|
|
483
492
|
|
|
484
|
-
/**
|
|
493
|
+
/** Settle a failed lifecycle: record the error and post the error card, unless the user aborted. */
|
|
494
|
+
export function reportAgentFailure(
|
|
495
|
+
pi: ExtensionAPI,
|
|
496
|
+
isShuttingDown: () => boolean,
|
|
497
|
+
runningAgent: RunningAgent,
|
|
498
|
+
widget: UserAgentWidget,
|
|
499
|
+
error: unknown,
|
|
500
|
+
): void {
|
|
501
|
+
if (runningAgent.aborted) {
|
|
502
|
+
logSteering(runningAgent.id, "agent-aborted");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const message = errorMessage(error);
|
|
506
|
+
logSteering(runningAgent.id, "agent-error", { error: message });
|
|
507
|
+
runningAgent.status = "error";
|
|
508
|
+
runningAgent.completedAt = Date.now();
|
|
509
|
+
runningAgent.error = message;
|
|
510
|
+
if (runningAgent.notifyMainAgent && isShuttingDown()) {
|
|
511
|
+
runningAgent.mainContextState = "separate";
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const resultMessage = buildAgentResultMessage(
|
|
515
|
+
runningAgent,
|
|
516
|
+
{ ok: false, error: message },
|
|
517
|
+
{ display: runningAgent.notifyMainAgent },
|
|
518
|
+
);
|
|
519
|
+
widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
|
|
520
|
+
if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
|
|
521
|
+
runningAgent.status = "posted";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Every dispatch prefixes its task with this; attach recognizes it as the child's dispatch boundary. */
|
|
525
|
+
export const DISPATCH_PREAMBLE =
|
|
526
|
+
"You are running in an ephemeral, forked background process now, concurrently with the main session. ";
|
|
527
|
+
|
|
528
|
+
/** Run turns on an initialized child session until it posts, retires, or shuts down. The initial instruction is sent verbatim. */
|
|
485
529
|
export async function runChildTurns(
|
|
486
530
|
pi: ExtensionAPI,
|
|
487
531
|
isShuttingDown: () => boolean,
|
|
488
|
-
|
|
532
|
+
initialInstruction: string,
|
|
489
533
|
session: AgentSession,
|
|
490
534
|
runningAgent: RunningAgent,
|
|
491
535
|
widget: UserAgentWidget,
|
|
492
536
|
): Promise<void> {
|
|
493
|
-
let instruction =
|
|
537
|
+
let instruction = initialInstruction;
|
|
494
538
|
while (true) {
|
|
495
539
|
const turnMessageStart = session.agent.state.messages.length;
|
|
496
540
|
if (!runningAgent.interruptRequested) {
|
|
@@ -745,7 +789,7 @@ export function subscribeToChildSession(
|
|
|
745
789
|
});
|
|
746
790
|
}
|
|
747
791
|
|
|
748
|
-
function assistantText(message: Extract<AgentMessage, { role: "assistant" }>): string {
|
|
792
|
+
export function assistantText(message: Extract<AgentMessage, { role: "assistant" }>): string {
|
|
749
793
|
return message.content
|
|
750
794
|
.filter((part) => part.type === "text")
|
|
751
795
|
.map((part) => part.text)
|
package/shared.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
|
15
15
|
export type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
16
16
|
export type UIContext = ExtensionCommandContext["ui"];
|
|
17
17
|
|
|
18
|
-
export type AgentCommandName = "agent";
|
|
18
|
+
export type AgentCommandName = "agent" | "agent-attach";
|
|
19
19
|
export type MainContextState = "separate" | "will-squash" | "squashed" | "rebased";
|
|
20
20
|
export type AgentStatus =
|
|
21
21
|
| "starting"
|
|
@@ -44,6 +44,19 @@ export type DetachedEntryData = {
|
|
|
44
44
|
sessionId: string;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
export type AttachedEntryData = {
|
|
48
|
+
sessionId: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Persisted in the child session at dispatch: the facts attach cannot recover from messages alone. */
|
|
52
|
+
export type DispatchRecordData = {
|
|
53
|
+
/** The pi CLI tokens the dispatch forwarded, e.g. ["--tools", "read,grep"]. */
|
|
54
|
+
forwardedArgs: string[];
|
|
55
|
+
/** The plain task, without the dispatch preamble. */
|
|
56
|
+
task: string;
|
|
57
|
+
isolate: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
47
60
|
export type RebaseStats = {
|
|
48
61
|
messageCount: number;
|
|
49
62
|
tokenEstimate: number;
|
|
@@ -75,7 +88,8 @@ export type AgentCommandDetails = {
|
|
|
75
88
|
agentId?: string;
|
|
76
89
|
command: AgentCommandName;
|
|
77
90
|
mainContextState?: MainContextState;
|
|
78
|
-
|
|
91
|
+
/** Absent on command-error cards: an error has no inherited-or-isolated notion. */
|
|
92
|
+
inheritedContext?: boolean;
|
|
79
93
|
model: string;
|
|
80
94
|
modelLabel: string;
|
|
81
95
|
task: string;
|
|
@@ -207,6 +221,8 @@ export class TimedConfirmation<Target> {
|
|
|
207
221
|
export const MESSAGE_TYPE = "pi-user-agents";
|
|
208
222
|
export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
|
|
209
223
|
export const REBASED_ENTRY_TYPE = "pi-user-agents-rebased";
|
|
224
|
+
export const ATTACHED_ENTRY_TYPE = "pi-user-agents-attached";
|
|
225
|
+
export const DISPATCH_ENTRY_TYPE = "pi-user-agents-dispatch";
|
|
210
226
|
export const WIDGET_KEY = "pi-user-agents";
|
|
211
227
|
export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
|
|
212
228
|
|
package/transcript.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
AgentEntryData,
|
|
7
7
|
AgentMessage,
|
|
8
8
|
AgentResultMessage,
|
|
9
|
+
AttachedEntryData,
|
|
9
10
|
DetachedEntryData,
|
|
10
11
|
ExtensionAPI,
|
|
11
12
|
ExtensionCommandContext,
|
|
@@ -14,6 +15,7 @@ import type {
|
|
|
14
15
|
Theme,
|
|
15
16
|
} from "./shared.js";
|
|
16
17
|
import {
|
|
18
|
+
ATTACHED_ENTRY_TYPE,
|
|
17
19
|
contextLabel,
|
|
18
20
|
DETACHED_ENTRY_TYPE,
|
|
19
21
|
escapeAttribute,
|
|
@@ -44,7 +46,6 @@ export function reportCommandError(
|
|
|
44
46
|
display: true,
|
|
45
47
|
details: {
|
|
46
48
|
command,
|
|
47
|
-
inheritedContext: command === "agent",
|
|
48
49
|
model: "",
|
|
49
50
|
modelLabel: "",
|
|
50
51
|
task: args.trim(),
|
|
@@ -222,6 +223,11 @@ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
|
|
|
222
223
|
? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 1, 0)
|
|
223
224
|
: undefined,
|
|
224
225
|
);
|
|
226
|
+
pi.registerEntryRenderer<AttachedEntryData>(ATTACHED_ENTRY_TYPE, (entry, _state, theme) =>
|
|
227
|
+
entry.data
|
|
228
|
+
? new Text(theme.fg("dim", `Attached session ${entry.data.sessionId}`), 1, 0)
|
|
229
|
+
: undefined,
|
|
230
|
+
);
|
|
225
231
|
pi.registerEntryRenderer<RebasedEntryData>(REBASED_ENTRY_TYPE, (entry, _state, theme) =>
|
|
226
232
|
entry.data ? new Text(theme.fg("dim", formatRebasedLine(entry.data)), 1, 0) : undefined,
|
|
227
233
|
);
|
|
@@ -324,7 +330,7 @@ function buildRendererParts(content: string, details: AgentCommandDetails | unde
|
|
|
324
330
|
const parts: string[] = [];
|
|
325
331
|
if (details?.modelLabel) parts.push(details.modelLabel);
|
|
326
332
|
else if (details?.model) parts.push(details.model);
|
|
327
|
-
if (details) parts.push(contextLabel(details.inheritedContext));
|
|
333
|
+
if (details?.inheritedContext !== undefined) parts.push(contextLabel(details.inheritedContext));
|
|
328
334
|
const mainContext = mainContextLabel(details?.mainContextState);
|
|
329
335
|
if (mainContext) parts.push(mainContext);
|
|
330
336
|
if (details?.turnCount) parts.push(formatTurns(details.turnCount));
|
package/widget.ts
CHANGED
|
@@ -266,6 +266,21 @@ export class UserAgentWidget {
|
|
|
266
266
|
this.update();
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/** Session ids with a live row or completed card, matched by exact id or prefix. */
|
|
270
|
+
matchAttachedSessionIds(query: string): string[] {
|
|
271
|
+
return this.entries()
|
|
272
|
+
.map((entry) => entry.agent.sessionId)
|
|
273
|
+
.filter((sessionId) => sessionId.startsWith(query));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Open the overlay of the row holding this session, as if the user selected it. */
|
|
277
|
+
openViewer(sessionId: string): boolean {
|
|
278
|
+
const entry = this.entries().find((candidate) => candidate.agent.sessionId === sessionId);
|
|
279
|
+
if (!entry) return false;
|
|
280
|
+
this.openSelected(entry.agent);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
269
284
|
private clampSelection(): void {
|
|
270
285
|
const total = this.entries().length;
|
|
271
286
|
if (total === 0) {
|