@giladbarnea/pi-user-agents 0.0.3 → 0.0.4
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 +17 -0
- package/README.md +15 -4
- package/agent-viewer.ts +15 -14
- package/index.ts +11 -4
- package/package.json +1 -1
- package/runner.ts +47 -2
- package/shared.ts +8 -0
- package/transcript.ts +7 -0
- package/widget.ts +27 -22
package/INTERNALS.md
CHANGED
|
@@ -30,6 +30,23 @@ A plain `<user_agent>` tag follows it. The tag contains only the model and inher
|
|
|
30
30
|
|
|
31
31
|
One selection rule builds the body. It keeps every user message. Before each next user message, it keeps only the latest assistant response. It applies the same rule after the final user message. Tool calls, thinking, tool results, and other message roles stay out.
|
|
32
32
|
|
|
33
|
+
## Child session persistence
|
|
34
|
+
|
|
35
|
+
Every dispatched agent gets a persisted `SessionManager.create(cwd, undefined, { parentSession })` rather than an in-memory one, so its conversation is an ordinary Pi session file from birth. `parentSession` is a *path*, and it records the main session that dispatched the agent — the same field `/fork` uses, surfaced by `SessionManager.list()` as `parentSessionPath`. There is no API to promote an in-memory session to disk later: `SessionManager` fixes `persist` at construction, and both `_persist()` and `_rewriteFile()` return early when it is false, so `setSessionFile()` on an in-memory manager sets a path and writes nothing.
|
|
36
|
+
|
|
37
|
+
Two ordering constraints follow from `createAgentSession`:
|
|
38
|
+
|
|
39
|
+
1. **Build the session while its manager is still empty.** Pi writes the `model_change` and `thinking_level_change` entries only on the empty-session path. Seed messages first and those entries never appear, so a resumed child falls back to `findInitialModel` instead of the model it was dispatched with.
|
|
40
|
+
2. **Route the inherited snapshot through the session manager.** Persistence runs off message events, so the old `session.agent.state.messages = inherited` assignment never reached the file. `persistInheritedMessages` appends it entry by entry, then the agent state is set to the same list.
|
|
41
|
+
|
|
42
|
+
That snapshot is already compaction-resolved, so at most one `compactionSummary` leads it. Each role takes its own append method — `appendCompaction`, `branchWithSummary`, `appendCustomMessageEntry`, `appendMessage` — because pi's own compaction and branch bookkeeping locates boundaries by entry type, and a summary written as a plain message entry would be invisible to it. `appendCompaction` wants a `firstKeptEntryId` the replay cannot know, but `buildContextEntries` only consults it for entries *before* the compaction entry; a leading compaction has none, so the value is never read.
|
|
43
|
+
|
|
44
|
+
The file itself appears on the child's first assistant message (`_persist` defers until then). An agent dispatched with `-i` that never answers therefore leaves nothing behind, while an agent that inherited context has a file immediately.
|
|
45
|
+
|
|
46
|
+
## Detaching
|
|
47
|
+
|
|
48
|
+
`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; joining and session shutdown deliberately do not announce.
|
|
49
|
+
|
|
33
50
|
## Runtime sharing
|
|
34
51
|
|
|
35
52
|
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
|
@@ -64,9 +64,19 @@ A background agent is not fire-and-forget — it's a session you can talk to.
|
|
|
64
64
|
|
|
65
65
|
Press `Enter` in the overlay to open the steer composer. Mid-turn, your message queues in after the current tool batch, before the next model call — exactly like steering the main agent. After the turn completes, the same composer starts **another turn** on the same live session. Follow up as many times as you need; every completed turn posts its own result card.
|
|
66
66
|
|
|
67
|
-
`Ctrl+x` interrupts only the current turn — the agent goes idle and stays available for steering, joining, or
|
|
67
|
+
`Ctrl+x` interrupts only the current turn — the agent goes idle and stays available for steering, joining, or detaching. In fact, nothing ends an agent except you: pressing `d` twice, joining it, or ending the Pi session.
|
|
68
68
|
|
|
69
|
-
### 4.
|
|
69
|
+
### 4. Detach
|
|
70
|
+
|
|
71
|
+
Every dispatched agent gets its own Pi session on disk, exactly like the one you're sitting in. When a row has served its purpose, press `d` twice: the agent stops, its row leaves the widget, and its session file is left untouched. The transcript records which one it was:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Detached session 0199c4f2-8b1a-7c3d-9e05-6a2f18d7b4ce
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
### 5. Join — or don't
|
|
70
80
|
|
|
71
81
|
This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
|
|
72
82
|
|
|
@@ -113,7 +123,7 @@ Everything goes through one command: `/agent [options] <task>`.
|
|
|
113
123
|
| Editor | `←` / `↓` | Focus the agents widget |
|
|
114
124
|
| Widget | `↑` `↓` · `Enter` | Select an agent · open its overlay |
|
|
115
125
|
| Widget / overlay | `Ctrl+x` | Interrupt the current turn (agent stays alive) |
|
|
116
|
-
| Widget / overlay | `
|
|
126
|
+
| Widget / overlay | `d` `d` | Detach the agent, keeping its session (twice to confirm) |
|
|
117
127
|
| Widget / overlay | `Esc` | Back |
|
|
118
128
|
| Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
|
|
119
129
|
| Overlay | `j` | Join the conversation into the main context |
|
|
@@ -122,7 +132,8 @@ Everything goes through one command: `/agent [options] <task>`.
|
|
|
122
132
|
|
|
123
133
|
## Good to know
|
|
124
134
|
|
|
125
|
-
-
|
|
135
|
+
- Every dispatch writes a real session file, so dispatched agents show up in `/resume` and `pi -r` alongside your own sessions. An agent that never answered leaves no file.
|
|
136
|
+
- Result cards persist across session reloads; the widget doesn't. After a reload you keep every card, and every agent's session is still on disk — but the steerable rows are gone. Reach them with `/resume`.
|
|
126
137
|
- Some accepted `pi` options have no effect on a background run (the session, approval, offline, and API-key families). They parse; they just don't do anything yet.
|
|
127
138
|
- `c` copies via `pbcopy`, so it's macOS-only for now.
|
|
128
139
|
- The overlay caps very large tool outputs and omits thinking entries.
|
package/agent-viewer.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "./tool-render/index.js";
|
|
33
33
|
|
|
34
34
|
export type ViewableAgent = RunningAgent | CompletedAgent;
|
|
35
|
-
export type AgentViewerAction = "hide" | "
|
|
35
|
+
export type AgentViewerAction = "hide" | "detach";
|
|
36
36
|
|
|
37
37
|
export type ActivityDescription = {
|
|
38
38
|
text: string;
|
|
@@ -141,7 +141,7 @@ function truncateMiddle(
|
|
|
141
141
|
|
|
142
142
|
const VIEWER_CHROME_LINES = 6;
|
|
143
143
|
const VIEWER_MIN_VIEWPORT = 3;
|
|
144
|
-
const VIEWER_HEIGHT_PCT =
|
|
144
|
+
const VIEWER_HEIGHT_PCT = 85;
|
|
145
145
|
|
|
146
146
|
/** Focus-capturing overlay that shows an agent's live or finished response. */
|
|
147
147
|
export class AgentViewer implements Component {
|
|
@@ -149,7 +149,7 @@ export class AgentViewer implements Component {
|
|
|
149
149
|
private autoScroll = true;
|
|
150
150
|
private lastWidth = 0;
|
|
151
151
|
private justCopied = false;
|
|
152
|
-
private
|
|
152
|
+
private confirmingDetach = false;
|
|
153
153
|
private composer: Input | undefined;
|
|
154
154
|
/** Rich tool rendering is far too costly to repeat on every 100 ms widget tick. */
|
|
155
155
|
private renderedContent: { key: string; lines: string[] } | undefined;
|
|
@@ -166,22 +166,22 @@ export class AgentViewer implements Component {
|
|
|
166
166
|
|
|
167
167
|
handleInput(data: string): void {
|
|
168
168
|
if (matchesKey(data, "ctrl+x") && isLiveAgent(this.agent)) {
|
|
169
|
-
this.
|
|
169
|
+
this.confirmingDetach = false;
|
|
170
170
|
this.interrupt();
|
|
171
171
|
this.tui.requestRender();
|
|
172
172
|
return;
|
|
173
173
|
}
|
|
174
174
|
if (this.composer) {
|
|
175
|
-
this.
|
|
175
|
+
this.confirmingDetach = false;
|
|
176
176
|
this.composer.handleInput(data);
|
|
177
177
|
this.tui.requestRender();
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
180
|
-
if (matchesKey(data, "
|
|
181
|
-
this.
|
|
180
|
+
if (matchesKey(data, "d")) {
|
|
181
|
+
this.requestDetach();
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
|
-
this.
|
|
184
|
+
this.confirmingDetach = false;
|
|
185
185
|
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
186
186
|
this.done("hide");
|
|
187
187
|
return;
|
|
@@ -253,6 +253,7 @@ export class AgentViewer implements Component {
|
|
|
253
253
|
renderAgentContextMeter(this.agent, th),
|
|
254
254
|
th.fg("dim", status),
|
|
255
255
|
...stats.map((stat) => th.fg("dim", stat)),
|
|
256
|
+
th.fg("dim", this.agent.sessionId),
|
|
256
257
|
]
|
|
257
258
|
.filter(Boolean)
|
|
258
259
|
.join(` ${th.fg("dim", "·")} `);
|
|
@@ -310,8 +311,8 @@ export class AgentViewer implements Component {
|
|
|
310
311
|
mainContext ? th.fg("dim", mainContext) : "",
|
|
311
312
|
isLiveAgent(this.agent) ? th.fg("dim", "Esc hide") : "",
|
|
312
313
|
th.fg(
|
|
313
|
-
this.
|
|
314
|
-
this.
|
|
314
|
+
this.confirmingDetach ? "error" : "dim",
|
|
315
|
+
this.confirmingDetach ? "d again to confirm" : "d detach",
|
|
315
316
|
),
|
|
316
317
|
].filter(Boolean),
|
|
317
318
|
);
|
|
@@ -325,12 +326,12 @@ export class AgentViewer implements Component {
|
|
|
325
326
|
|
|
326
327
|
dispose(): void {}
|
|
327
328
|
|
|
328
|
-
private
|
|
329
|
-
if (this.
|
|
330
|
-
this.done("
|
|
329
|
+
private requestDetach(): void {
|
|
330
|
+
if (this.confirmingDetach) {
|
|
331
|
+
this.done("detach");
|
|
331
332
|
return;
|
|
332
333
|
}
|
|
333
|
-
this.
|
|
334
|
+
this.confirmingDetach = true;
|
|
334
335
|
this.tui.requestRender();
|
|
335
336
|
}
|
|
336
337
|
|
package/index.ts
CHANGED
|
@@ -4,15 +4,22 @@ import type {
|
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { registerAgentAutocomplete } from "./autocomplete.js";
|
|
6
6
|
import { handleAgentCommand } from "./runner.js";
|
|
7
|
-
import type {
|
|
8
|
-
|
|
7
|
+
import type {
|
|
8
|
+
AgentCommandDetails,
|
|
9
|
+
DetachedEntryData,
|
|
10
|
+
ExtensionAPI,
|
|
11
|
+
RunningAgent,
|
|
12
|
+
} from "./shared.js";
|
|
13
|
+
import { DETACHED_ENTRY_TYPE, MESSAGE_TYPE } from "./shared.js";
|
|
9
14
|
import { registerUserAgentRenderer } from "./transcript.js";
|
|
10
15
|
import { UserAgentWidget } from "./widget.js";
|
|
11
16
|
|
|
12
17
|
export default function userAgent(pi: ExtensionAPI): void {
|
|
13
18
|
const runningAgents = new Set<RunningAgent>();
|
|
14
|
-
const widget = new UserAgentWidget(
|
|
15
|
-
|
|
19
|
+
const widget = new UserAgentWidget(
|
|
20
|
+
runningAgents,
|
|
21
|
+
(message) => pi.sendMessage(message, { triggerTurn: true }),
|
|
22
|
+
(sessionId) => pi.appendEntry<DetachedEntryData>(DETACHED_ENTRY_TYPE, { sessionId }),
|
|
16
23
|
);
|
|
17
24
|
let shuttingDown = false;
|
|
18
25
|
let nextAgentNumber = 0;
|
package/package.json
CHANGED
package/runner.ts
CHANGED
|
@@ -96,7 +96,18 @@ async function startUserAgent(
|
|
|
96
96
|
services.modelRuntime.getModel(selectedModel.provider, selectedModel.id) ?? selectedModel;
|
|
97
97
|
const thinkingLevel = forwarded.thinkingLevel ?? pi.getThinkingLevel();
|
|
98
98
|
const inheritedMessages = parsed.isolate ? [] : buildInheritedMessages(ctx);
|
|
99
|
-
|
|
99
|
+
// A real session file from birth: the child outlives the widget row and stays resumable.
|
|
100
|
+
const childSessionManager = SessionManager.create(services.cwd, undefined, {
|
|
101
|
+
parentSession: ctx.sessionManager.getSessionFile(),
|
|
102
|
+
});
|
|
103
|
+
const runningAgent = createRunningAgent(
|
|
104
|
+
nextAgentNumber(),
|
|
105
|
+
childSessionManager.getSessionId(),
|
|
106
|
+
command,
|
|
107
|
+
model,
|
|
108
|
+
parsed,
|
|
109
|
+
invocation,
|
|
110
|
+
);
|
|
100
111
|
|
|
101
112
|
runningAgents.add(runningAgent);
|
|
102
113
|
logSteering(runningAgent.id, "agent-created", { command, taskLength: parsed.task.length });
|
|
@@ -117,6 +128,7 @@ async function startUserAgent(
|
|
|
117
128
|
forwarded,
|
|
118
129
|
inheritedMessages,
|
|
119
130
|
services,
|
|
131
|
+
childSessionManager,
|
|
120
132
|
runningAgent,
|
|
121
133
|
widget,
|
|
122
134
|
).finally(() => {
|
|
@@ -129,6 +141,7 @@ async function startUserAgent(
|
|
|
129
141
|
|
|
130
142
|
function createRunningAgent(
|
|
131
143
|
sequenceNumber: number,
|
|
144
|
+
sessionId: string,
|
|
132
145
|
command: AgentCommandName,
|
|
133
146
|
model: Model,
|
|
134
147
|
parsed: ParsedAgentCommand,
|
|
@@ -136,6 +149,7 @@ function createRunningAgent(
|
|
|
136
149
|
): RunningAgent {
|
|
137
150
|
return {
|
|
138
151
|
id: `user-${sequenceNumber.toString(36)}`,
|
|
152
|
+
sessionId,
|
|
139
153
|
command,
|
|
140
154
|
inheritedContext: !parsed.isolate,
|
|
141
155
|
model: formatModel(model),
|
|
@@ -342,6 +356,7 @@ async function runAgentLifecycle(
|
|
|
342
356
|
forwarded: ForwardedOptions,
|
|
343
357
|
inheritedMessages: AgentMessage[],
|
|
344
358
|
services: AgentSessionServices,
|
|
359
|
+
childSessionManager: SessionManager,
|
|
345
360
|
runningAgent: RunningAgent,
|
|
346
361
|
widget: UserAgentWidget,
|
|
347
362
|
): Promise<void> {
|
|
@@ -354,6 +369,7 @@ async function runAgentLifecycle(
|
|
|
354
369
|
forwarded,
|
|
355
370
|
inheritedMessages,
|
|
356
371
|
services,
|
|
372
|
+
childSessionManager,
|
|
357
373
|
runningAgent,
|
|
358
374
|
widget,
|
|
359
375
|
);
|
|
@@ -487,12 +503,15 @@ async function createChildSession(
|
|
|
487
503
|
forwarded: ForwardedOptions,
|
|
488
504
|
inheritedMessages: AgentMessage[],
|
|
489
505
|
services: AgentSessionServices,
|
|
506
|
+
childSessionManager: SessionManager,
|
|
490
507
|
runningAgent: RunningAgent,
|
|
491
508
|
widget: UserAgentWidget,
|
|
492
509
|
): Promise<AgentSession> {
|
|
510
|
+
// Built while the session is still empty: that is the only path on which pi records the
|
|
511
|
+
// model and thinking level, and without those entries a resumed child falls back to defaults.
|
|
493
512
|
const { session } = await createAgentSessionFromServices({
|
|
494
513
|
services,
|
|
495
|
-
sessionManager:
|
|
514
|
+
sessionManager: childSessionManager,
|
|
496
515
|
model,
|
|
497
516
|
thinkingLevel,
|
|
498
517
|
tools: forwarded.tools,
|
|
@@ -509,12 +528,38 @@ async function createChildSession(
|
|
|
509
528
|
await session.bindExtensions({ mode: "print" });
|
|
510
529
|
logSteering(runningAgent.id, "session-bound", { streaming: session.isStreaming });
|
|
511
530
|
if (inheritedMessages.length > 0) {
|
|
531
|
+
persistInheritedMessages(childSessionManager, inheritedMessages);
|
|
512
532
|
session.agent.state.messages = inheritedMessages;
|
|
513
533
|
logSteering(runningAgent.id, "context-inherited", { messageCount: inheritedMessages.length });
|
|
514
534
|
}
|
|
515
535
|
return session;
|
|
516
536
|
}
|
|
517
537
|
|
|
538
|
+
/**
|
|
539
|
+
* Mirror the inherited snapshot into the child's session file so a resumed child sees the
|
|
540
|
+
* same conversation the live one saw. Each role goes through its own append method: the
|
|
541
|
+
* snapshot is already compaction-resolved, so its summary leads the child's own branch.
|
|
542
|
+
*/
|
|
543
|
+
export function persistInheritedMessages(
|
|
544
|
+
sessionManager: SessionManager,
|
|
545
|
+
messages: readonly AgentMessage[],
|
|
546
|
+
): void {
|
|
547
|
+
for (const message of messages) {
|
|
548
|
+
if (message.role === "compactionSummary")
|
|
549
|
+
sessionManager.appendCompaction(message.summary, "", message.tokensBefore);
|
|
550
|
+
else if (message.role === "branchSummary")
|
|
551
|
+
sessionManager.branchWithSummary(sessionManager.getLeafId(), message.summary);
|
|
552
|
+
else if (message.role === "custom")
|
|
553
|
+
sessionManager.appendCustomMessageEntry(
|
|
554
|
+
message.customType,
|
|
555
|
+
message.content,
|
|
556
|
+
message.display,
|
|
557
|
+
message.details,
|
|
558
|
+
);
|
|
559
|
+
else sessionManager.appendMessage(message);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
518
563
|
/** Parks an idle agent until the user resumes it with another instruction (resolves with it) or retires it (resolves undefined). */
|
|
519
564
|
export function waitForInstruction(
|
|
520
565
|
agent: RunningAgent,
|
package/shared.ts
CHANGED
|
@@ -40,6 +40,10 @@ export type AgentEntryData = {
|
|
|
40
40
|
details: AgentCommandDetails;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
export type DetachedEntryData = {
|
|
44
|
+
sessionId: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
43
47
|
export type AgentResultMessage = {
|
|
44
48
|
customType: string;
|
|
45
49
|
content: string;
|
|
@@ -67,6 +71,8 @@ export type AgentCommandDetails = {
|
|
|
67
71
|
|
|
68
72
|
export type RunningAgent = {
|
|
69
73
|
id: string;
|
|
74
|
+
/** The child's own Pi session, resumable with `/resume <sessionId>` after the agent is detached. */
|
|
75
|
+
sessionId: string;
|
|
70
76
|
command: AgentCommandName;
|
|
71
77
|
inheritedContext: boolean;
|
|
72
78
|
model: string;
|
|
@@ -106,6 +112,7 @@ export type RunningAgent = {
|
|
|
106
112
|
|
|
107
113
|
export type CompletedAgent = {
|
|
108
114
|
id: string;
|
|
115
|
+
sessionId: string;
|
|
109
116
|
command: AgentCommandName;
|
|
110
117
|
modelLabel: string;
|
|
111
118
|
task: string;
|
|
@@ -123,6 +130,7 @@ export type CompletedAgent = {
|
|
|
123
130
|
};
|
|
124
131
|
|
|
125
132
|
export const MESSAGE_TYPE = "pi-user-agents";
|
|
133
|
+
export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
|
|
126
134
|
export const WIDGET_KEY = "pi-user-agents";
|
|
127
135
|
export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
|
|
128
136
|
|
package/transcript.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
AgentEntryData,
|
|
7
7
|
AgentMessage,
|
|
8
8
|
AgentResultMessage,
|
|
9
|
+
DetachedEntryData,
|
|
9
10
|
ExtensionAPI,
|
|
10
11
|
ExtensionCommandContext,
|
|
11
12
|
RunningAgent,
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
} from "./shared.js";
|
|
14
15
|
import {
|
|
15
16
|
contextLabel,
|
|
17
|
+
DETACHED_ENTRY_TYPE,
|
|
16
18
|
escapeAttribute,
|
|
17
19
|
extractTag,
|
|
18
20
|
formatMs,
|
|
@@ -212,6 +214,11 @@ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
|
|
|
212
214
|
)
|
|
213
215
|
: undefined,
|
|
214
216
|
);
|
|
217
|
+
pi.registerEntryRenderer<DetachedEntryData>(DETACHED_ENTRY_TYPE, (entry, _state, theme) =>
|
|
218
|
+
entry.data
|
|
219
|
+
? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 0, 0)
|
|
220
|
+
: undefined,
|
|
221
|
+
);
|
|
215
222
|
}
|
|
216
223
|
|
|
217
224
|
function liveAgentCard(
|
package/widget.ts
CHANGED
|
@@ -49,12 +49,13 @@ export class UserAgentWidget {
|
|
|
49
49
|
private active = false;
|
|
50
50
|
private selectedIndex = 0;
|
|
51
51
|
private viewerOpen = false;
|
|
52
|
-
private
|
|
52
|
+
private detachConfirmationTarget: ViewableAgent | undefined;
|
|
53
53
|
private readonly completedAgents: CompletedAgent[] = [];
|
|
54
54
|
|
|
55
55
|
constructor(
|
|
56
56
|
private readonly runningAgents: Set<RunningAgent>,
|
|
57
57
|
private readonly sendJoinedResult: (message: AgentResultMessage) => void,
|
|
58
|
+
private readonly announceDetached: (sessionId: string) => void,
|
|
58
59
|
) {}
|
|
59
60
|
|
|
60
61
|
setUI(ui: UIContext): void {
|
|
@@ -63,7 +64,7 @@ export class UserAgentWidget {
|
|
|
63
64
|
this.ui = ui;
|
|
64
65
|
this.widgetRegistered = false;
|
|
65
66
|
this.tui = undefined;
|
|
66
|
-
this.
|
|
67
|
+
this.detachConfirmationTarget = undefined;
|
|
67
68
|
this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -74,6 +75,7 @@ export class UserAgentWidget {
|
|
|
74
75
|
): void {
|
|
75
76
|
this.completedAgents.push({
|
|
76
77
|
id: agent.id,
|
|
78
|
+
sessionId: agent.sessionId,
|
|
77
79
|
command: agent.command,
|
|
78
80
|
modelLabel: agent.modelLabel,
|
|
79
81
|
task: agent.task,
|
|
@@ -140,7 +142,7 @@ export class UserAgentWidget {
|
|
|
140
142
|
this.widgetRegistered = false;
|
|
141
143
|
this.tui = undefined;
|
|
142
144
|
this.active = false;
|
|
143
|
-
this.
|
|
145
|
+
this.detachConfirmationTarget = undefined;
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
private clear(): void {
|
|
@@ -155,7 +157,7 @@ export class UserAgentWidget {
|
|
|
155
157
|
}
|
|
156
158
|
this.active = false;
|
|
157
159
|
this.selectedIndex = 0;
|
|
158
|
-
this.
|
|
160
|
+
this.detachConfirmationTarget = undefined;
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
private runningAgentsForWidget(): RunningAgent[] {
|
|
@@ -199,13 +201,13 @@ export class UserAgentWidget {
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
if (matchesKey(data, "down")) {
|
|
202
|
-
this.
|
|
204
|
+
this.detachConfirmationTarget = undefined;
|
|
203
205
|
this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
|
|
204
206
|
this.update();
|
|
205
207
|
return { consume: true };
|
|
206
208
|
}
|
|
207
209
|
if (matchesKey(data, "up")) {
|
|
208
|
-
this.
|
|
210
|
+
this.detachConfirmationTarget = undefined;
|
|
209
211
|
if (this.selectedIndex === 0) {
|
|
210
212
|
this.deactivate();
|
|
211
213
|
return { consume: true };
|
|
@@ -225,17 +227,17 @@ export class UserAgentWidget {
|
|
|
225
227
|
selected?.kind === "running" &&
|
|
226
228
|
isLiveAgent(selected.agent)
|
|
227
229
|
) {
|
|
228
|
-
this.
|
|
230
|
+
this.detachConfirmationTarget = undefined;
|
|
229
231
|
this.interruptRunning(selected.agent);
|
|
230
232
|
return { consume: true };
|
|
231
233
|
}
|
|
232
234
|
if (matchesKey(data, "enter")) {
|
|
233
|
-
this.
|
|
235
|
+
this.detachConfirmationTarget = undefined;
|
|
234
236
|
if (selected) this.openSelected(selected.agent);
|
|
235
237
|
return { consume: true };
|
|
236
238
|
}
|
|
237
|
-
if (matchesKey(data, "
|
|
238
|
-
if (!this.
|
|
239
|
+
if (matchesKey(data, "d") && selected) {
|
|
240
|
+
if (!this.confirmDetach(selected.agent)) return { consume: true };
|
|
239
241
|
if (selected.kind === "running") this.closeRunning(selected.agent);
|
|
240
242
|
else this.dismissCompleted(selected.agent);
|
|
241
243
|
return { consume: true };
|
|
@@ -245,12 +247,12 @@ export class UserAgentWidget {
|
|
|
245
247
|
return undefined;
|
|
246
248
|
}
|
|
247
249
|
|
|
248
|
-
private
|
|
249
|
-
if (this.
|
|
250
|
-
this.
|
|
250
|
+
private confirmDetach(target: ViewableAgent): boolean {
|
|
251
|
+
if (this.detachConfirmationTarget === target) {
|
|
252
|
+
this.detachConfirmationTarget = undefined;
|
|
251
253
|
return true;
|
|
252
254
|
}
|
|
253
|
-
this.
|
|
255
|
+
this.detachConfirmationTarget = target;
|
|
254
256
|
this.update();
|
|
255
257
|
return false;
|
|
256
258
|
}
|
|
@@ -258,7 +260,7 @@ export class UserAgentWidget {
|
|
|
258
260
|
private deactivate(): void {
|
|
259
261
|
this.active = false;
|
|
260
262
|
this.selectedIndex = 0;
|
|
261
|
-
this.
|
|
263
|
+
this.detachConfirmationTarget = undefined;
|
|
262
264
|
this.update();
|
|
263
265
|
}
|
|
264
266
|
|
|
@@ -290,7 +292,7 @@ export class UserAgentWidget {
|
|
|
290
292
|
if (isRunningAgent(agent)) this.interruptRunning(agent);
|
|
291
293
|
},
|
|
292
294
|
),
|
|
293
|
-
{ overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "
|
|
295
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "85%" } },
|
|
294
296
|
)
|
|
295
297
|
.then(
|
|
296
298
|
(action) => this.onViewerClosed(agent, action),
|
|
@@ -298,10 +300,12 @@ export class UserAgentWidget {
|
|
|
298
300
|
);
|
|
299
301
|
}
|
|
300
302
|
|
|
303
|
+
/** Detaching ends the live session and leaves its session file on disk, untouched. */
|
|
301
304
|
private closeRunning(agent: RunningAgent): void {
|
|
302
305
|
agent.aborted = true;
|
|
303
306
|
void agent.session?.abort();
|
|
304
307
|
agent.retire?.();
|
|
308
|
+
this.announceDetached(agent.sessionId);
|
|
305
309
|
this.update();
|
|
306
310
|
}
|
|
307
311
|
|
|
@@ -368,12 +372,12 @@ export class UserAgentWidget {
|
|
|
368
372
|
|
|
369
373
|
private onViewerClosed(agent: ViewableAgent, action: AgentViewerAction): void {
|
|
370
374
|
this.viewerOpen = false;
|
|
371
|
-
if (action === "
|
|
375
|
+
if (action === "detach" && isRunningAgent(agent) && this.runningAgents.has(agent)) {
|
|
372
376
|
this.closeRunning(agent);
|
|
373
377
|
return;
|
|
374
378
|
}
|
|
375
379
|
const completedAgent = this.completedAgents.find((candidate) => candidate.id === agent.id);
|
|
376
|
-
if (action === "
|
|
380
|
+
if (action === "detach" && completedAgent) this.removeCompleted(completedAgent);
|
|
377
381
|
else this.update();
|
|
378
382
|
}
|
|
379
383
|
|
|
@@ -384,6 +388,7 @@ export class UserAgentWidget {
|
|
|
384
388
|
private removeCompleted(agent: CompletedAgent): void {
|
|
385
389
|
const index = this.completedAgents.indexOf(agent);
|
|
386
390
|
if (index >= 0) this.completedAgents.splice(index, 1);
|
|
391
|
+
this.announceDetached(agent.sessionId);
|
|
387
392
|
const total = this.entries().length;
|
|
388
393
|
if (total === 0) {
|
|
389
394
|
this.deactivate();
|
|
@@ -413,18 +418,18 @@ export class UserAgentWidget {
|
|
|
413
418
|
lines.push(truncateToWidth(` ${theme.fg("dim", "←/↓ select agent")}`, width));
|
|
414
419
|
} else {
|
|
415
420
|
const selected = entries[this.selectedIndex];
|
|
416
|
-
const
|
|
421
|
+
const confirmingDetach = this.detachConfirmationTarget === selected?.agent;
|
|
417
422
|
const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
|
|
418
423
|
if (
|
|
419
|
-
!
|
|
424
|
+
!confirmingDetach &&
|
|
420
425
|
selected?.kind === "running" &&
|
|
421
426
|
isLiveAgent(selected.agent)
|
|
422
427
|
)
|
|
423
428
|
segments.push(theme.fg("accent", "Ctrl+x interrupt"));
|
|
424
429
|
segments.push(
|
|
425
430
|
theme.fg(
|
|
426
|
-
|
|
427
|
-
|
|
431
|
+
confirmingDetach ? "error" : "accent",
|
|
432
|
+
confirmingDetach ? "d again to confirm" : "d detach",
|
|
428
433
|
),
|
|
429
434
|
theme.fg("accent", "Esc back"),
|
|
430
435
|
);
|