@kushalkhemka/opensec-pi-subagents 0.20.2 → 0.20.3
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/CHANGELOG.md +69 -0
- package/README.md +125 -7
- package/dist/agent-manager.d.ts +119 -9
- package/dist/agent-manager.js +263 -56
- package/dist/agent-runner.d.ts +11 -0
- package/dist/agent-runner.js +96 -22
- package/dist/completion-outbox.d.ts +65 -0
- package/dist/completion-outbox.js +108 -0
- package/dist/cross-extension-rpc.js +8 -0
- package/dist/env.js +3 -2
- package/dist/exec-tree.js +11 -2
- package/dist/index.d.ts +7 -1
- package/dist/index.js +827 -252
- package/dist/invocation-config.d.ts +17 -4
- package/dist/invocation-config.js +29 -2
- package/dist/nested-tools.js +10 -4
- package/dist/schedule.d.ts +1 -1
- package/dist/schedule.js +10 -4
- package/dist/session-handoff.d.ts +102 -0
- package/dist/session-handoff.js +162 -0
- package/dist/settings.d.ts +26 -0
- package/dist/settings.js +10 -0
- package/dist/types.d.ts +24 -2
- package/dist/ui/agent-widget.d.ts +4 -0
- package/dist/ui/agent-widget.js +5 -0
- package/dist/ui/conversation-viewer.d.ts +104 -3
- package/dist/ui/conversation-viewer.js +425 -52
- package/dist/ui/fleet-list.d.ts +30 -9
- package/dist/ui/fleet-list.js +37 -12
- package/dist/workflow/collisions.d.ts +3 -1
- package/dist/workflow/collisions.js +4 -1
- package/dist/workflow/host.js +30 -7
- package/dist/workflow/journal.d.ts +16 -8
- package/dist/workflow/journal.js +12 -9
- package/dist/workflow/runtime.d.ts +7 -1
- package/dist/workflow/runtime.js +38 -8
- package/dist/workflow/task.d.ts +11 -1
- package/dist/workflow/task.js +13 -1
- package/dist/workflow/tool-description.d.ts +9 -1
- package/dist/workflow/tool-description.js +21 -1
- package/docs/rpc.md +10 -10
- package/docs/workflows.md +4 -4
- package/examples/agent-tool-description.md +1 -3
- package/package.json +16 -11
- package/src/agent-manager.ts +288 -55
- package/src/agent-runner.ts +100 -21
- package/src/completion-outbox.ts +121 -0
- package/src/cross-extension-rpc.ts +8 -0
- package/src/env.ts +3 -2
- package/src/exec-tree.ts +11 -2
- package/src/index.ts +810 -237
- package/src/invocation-config.ts +44 -4
- package/src/nested-tools.ts +10 -4
- package/src/schedule.ts +10 -4
- package/src/session-handoff.ts +188 -0
- package/src/settings.ts +34 -0
- package/src/types.ts +24 -2
- package/src/ui/agent-widget.ts +8 -0
- package/src/ui/conversation-viewer.ts +419 -35
- package/src/ui/fleet-list.ts +34 -11
- package/src/workflow/collisions.ts +4 -1
- package/src/workflow/host.ts +30 -7
- package/src/workflow/journal.ts +18 -9
- package/src/workflow/runtime.ts +50 -8
- package/src/workflow/task.ts +14 -0
- package/src/workflow/tool-description.ts +27 -1
package/dist/index.js
CHANGED
|
@@ -12,21 +12,22 @@
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { isAbsolute, join } from "node:path";
|
|
14
14
|
import { defineTool, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { Container, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
16
|
-
import { Type } from "
|
|
15
|
+
import { Container, isKeyRelease, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
17
|
import { abortable } from "./abortable.js";
|
|
18
18
|
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
|
19
19
|
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
|
|
20
|
-
import { AgentManager, isTopLevelAgent, resumeStateOf, resumeStatesFromEntries } from "./agent-manager.js";
|
|
20
|
+
import { AgentManager, isTopLevelAgent, managerServices, RECORD_ENTRY_TYPE, RESUME_ENTRY_TYPE, resumeStateOf, resumeStatesFromEntries } from "./agent-manager.js";
|
|
21
21
|
import { boundBashTimeout, getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
|
|
22
22
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
23
23
|
import { inChildSessionContext } from "./child-context.js";
|
|
24
|
+
import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js";
|
|
24
25
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
25
26
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
26
27
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
27
28
|
import { useTreeKillingExec } from "./exec-tree.js";
|
|
28
29
|
import { GroupJoinManager } from "./group-join.js";
|
|
29
|
-
import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
|
|
30
|
+
import { isModelOverrideAllowed, isolationParam, resolveAgentInvocationConfig, resolveJoinMode, setModelOverrideAllowed } from "./invocation-config.js";
|
|
30
31
|
import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
|
|
31
32
|
import { runMentionClone } from "./mention-clone.js";
|
|
32
33
|
import { describeModel, resolveModel } from "./model-resolver.js";
|
|
@@ -36,10 +37,12 @@ import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, ses
|
|
|
36
37
|
import { isProjectCodeTrusted, isProjectTrustedBy, predictProjectTrust } from "./project-trust.js";
|
|
37
38
|
import { SubagentScheduler } from "./schedule.js";
|
|
38
39
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
40
|
+
import { appendToSessionFile, HANDOFF_PROTOCOL, isCompatible, isSessionOnDisk, isSessionSwitch, PACKAGE_VERSION, parkSubagents, sessionFileOf, takeParkedSubagents } from "./session-handoff.js";
|
|
39
41
|
import { applyAndEmitLoaded, applySettings, loadSettings, saveAndEmitChanged } from "./settings.js";
|
|
40
42
|
import { agentIdLine, getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
41
43
|
import { createMentionProvider, mentionRoster } from "./ui/agent-mention.js";
|
|
42
44
|
import { AgentWidget, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
|
|
45
|
+
import { ConversationViewer, getConversationOverlayOptions } from "./ui/conversation-viewer.js";
|
|
43
46
|
import { FleetList } from "./ui/fleet-list.js";
|
|
44
47
|
import { RefreshTicker } from "./ui/refresh-ticker.js";
|
|
45
48
|
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
@@ -57,7 +60,7 @@ import { runWorkflow } from "./workflow/runtime.js";
|
|
|
57
60
|
import { resolveWorkflowScript } from "./workflow/saved.js";
|
|
58
61
|
import { readWorkflowScriptFile } from "./workflow/script-file.js";
|
|
59
62
|
import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, pruneSettledWorkflowTasks, resolveResumeTarget, updateWorkflowProgressBatch, workflowResultText, workflowRunId } from "./workflow/task.js";
|
|
60
|
-
import {
|
|
63
|
+
import { workflowToolDescription } from "./workflow/tool-description.js";
|
|
61
64
|
import { isWorktreeIsolationEnabled, MERGED_BRANCH_MAX_AGE_DAYS, pruneMergedAgentBranches, recoverLeftoverWorktrees, setWorktreeIsolationEnabled } from "./worktree.js";
|
|
62
65
|
import { escapeXml } from "./xml.js";
|
|
63
66
|
// ---- Shared helpers ----
|
|
@@ -71,6 +74,10 @@ export function renderRunningAgentStatus(frame, statsText, activity, theme) {
|
|
|
71
74
|
container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0));
|
|
72
75
|
return container;
|
|
73
76
|
}
|
|
77
|
+
/** How long a foreground `Agent` call runs before its row offers the background key. */
|
|
78
|
+
const BACKGROUND_HINT_DELAY_MS = 2000;
|
|
79
|
+
/** The `backgroundKey` default: Claude Code's ctrl+b (`task:background`). */
|
|
80
|
+
const DEFAULT_BACKGROUND_KEY = "ctrl+b";
|
|
74
81
|
/** Format an agent's lifetime token total, or "" when zero. */
|
|
75
82
|
function formatLifetimeTokens(o) {
|
|
76
83
|
const t = getLifetimeTotal(o.lifetimeUsage);
|
|
@@ -211,6 +218,116 @@ function buildNotificationDetails(record, resultMaxLen, activity) {
|
|
|
211
218
|
: "No output.",
|
|
212
219
|
};
|
|
213
220
|
}
|
|
221
|
+
/** Session entry marking `pending` results (see {@link recordEntryData}) as delivered. */
|
|
222
|
+
const DELIVERED_ENTRY_TYPE = "subagents:delivered";
|
|
223
|
+
/**
|
|
224
|
+
* A top-level agent's `subagents:record` entry. `pending`: its session has not
|
|
225
|
+
* been notified yet — it finished while another session was open — and
|
|
226
|
+
* delivers the notification when it starts; the extra figures let it render
|
|
227
|
+
* one from the entry alone.
|
|
228
|
+
*/
|
|
229
|
+
function recordEntryData(record, pending = false) {
|
|
230
|
+
return {
|
|
231
|
+
id: record.id, type: record.type, description: record.description,
|
|
232
|
+
status: record.status, result: record.result, error: record.error,
|
|
233
|
+
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
234
|
+
resume: resumeStateOf(record),
|
|
235
|
+
...(pending
|
|
236
|
+
? { pending: true, toolUses: record.toolUses, lifetimeUsage: { ...record.lifetimeUsage }, outputFile: record.outputFile, toolCallId: record.toolCallId }
|
|
237
|
+
: {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** An owned child's `subagents:resume` entry, or undefined without a session file to reopen. */
|
|
241
|
+
function resumeEntryData(record) {
|
|
242
|
+
const resume = resumeStateOf(record);
|
|
243
|
+
return resume ? { resume } : undefined;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The `pending` results among a session's entries that no later
|
|
247
|
+
* `subagents:delivered` entry covers, in order, as record-shaped objects a
|
|
248
|
+
* notification can be rendered from.
|
|
249
|
+
*/
|
|
250
|
+
export function pendingResultsFromEntries(entries) {
|
|
251
|
+
const pending = new Map();
|
|
252
|
+
for (const entry of entries) {
|
|
253
|
+
const e = entry;
|
|
254
|
+
if (e?.type !== "custom" || !e.data)
|
|
255
|
+
continue;
|
|
256
|
+
if (e.customType === RECORD_ENTRY_TYPE && e.data.pending === true && typeof e.data.id === "string") {
|
|
257
|
+
const d = e.data;
|
|
258
|
+
pending.set(d.id, {
|
|
259
|
+
id: d.id,
|
|
260
|
+
type: String(d.type ?? ""),
|
|
261
|
+
description: String(d.description ?? ""),
|
|
262
|
+
status: d.status ?? "completed",
|
|
263
|
+
result: typeof d.result === "string" ? d.result : undefined,
|
|
264
|
+
error: typeof d.error === "string" ? d.error : undefined,
|
|
265
|
+
startedAt: typeof d.startedAt === "number" ? d.startedAt : 0,
|
|
266
|
+
completedAt: typeof d.completedAt === "number" ? d.completedAt : undefined,
|
|
267
|
+
toolUses: typeof d.toolUses === "number" ? d.toolUses : 0,
|
|
268
|
+
lifetimeUsage: d.lifetimeUsage ?? { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
269
|
+
compactionCount: 0,
|
|
270
|
+
outputFile: typeof d.outputFile === "string" ? d.outputFile : undefined,
|
|
271
|
+
toolCallId: typeof d.toolCallId === "string" ? d.toolCallId : undefined,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
else if (e.customType === DELIVERED_ENTRY_TYPE && Array.isArray(e.data.ids)) {
|
|
275
|
+
for (const id of e.data.ids)
|
|
276
|
+
if (typeof id === "string")
|
|
277
|
+
pending.delete(id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return [...pending.values()];
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Stop a parked manager's agents with this (the parking instance's) code and
|
|
284
|
+
* record each in its own session, as a quit would. For an adopter of another
|
|
285
|
+
* version, or a parked manager something else replaced. Touches no instance
|
|
286
|
+
* state: the instance that parked it is gone.
|
|
287
|
+
*/
|
|
288
|
+
async function stopParkedAgents(manager, entry, write) {
|
|
289
|
+
manager.setObservers({});
|
|
290
|
+
const put = (record, pending) => {
|
|
291
|
+
const [type, data] = isTopLevelAgent(record)
|
|
292
|
+
? [RECORD_ENTRY_TYPE, recordEntryData(record, pending)]
|
|
293
|
+
: [RESUME_ENTRY_TYPE, resumeEntryData(record)];
|
|
294
|
+
if (!data)
|
|
295
|
+
return;
|
|
296
|
+
if (!write?.(record.ownerSession, type, data))
|
|
297
|
+
appendToSessionFile(record.ownerSession, type, data);
|
|
298
|
+
};
|
|
299
|
+
for (const record of entry.completed.splice(0))
|
|
300
|
+
put(record, isTopLevelAgent(record) && !record.resultConsumed);
|
|
301
|
+
const interrupted = manager.listAllAgents().filter(r => r.status === "running" || r.status === "queued");
|
|
302
|
+
manager.abortAll();
|
|
303
|
+
await manager.awaitWorktreeCleanup(15_000);
|
|
304
|
+
for (const record of interrupted)
|
|
305
|
+
put(record, false);
|
|
306
|
+
await manager.dispose();
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The process is exiting with a manager still parked (no instance took it
|
|
310
|
+
* over): record what can be recorded, synchronously — results that settled
|
|
311
|
+
* while parked, and the running agents as stopped. Worktrees cannot be
|
|
312
|
+
* committed this late; a later start recovers them (`recoverLeftoverWorktrees`).
|
|
313
|
+
*/
|
|
314
|
+
function recordParkedAgentsAtExit(manager, entry) {
|
|
315
|
+
manager.setObservers({});
|
|
316
|
+
const put = (record, pending) => {
|
|
317
|
+
const data = isTopLevelAgent(record) ? recordEntryData(record, pending) : resumeEntryData(record);
|
|
318
|
+
if (data)
|
|
319
|
+
appendToSessionFile(record.ownerSession, isTopLevelAgent(record) ? RECORD_ENTRY_TYPE : RESUME_ENTRY_TYPE, data);
|
|
320
|
+
};
|
|
321
|
+
for (const record of entry.completed.splice(0))
|
|
322
|
+
put(record, isTopLevelAgent(record) && !record.resultConsumed);
|
|
323
|
+
for (const record of manager.listAllAgents()) {
|
|
324
|
+
if (record.status !== "running" && record.status !== "queued")
|
|
325
|
+
continue;
|
|
326
|
+
record.status = "stopped";
|
|
327
|
+
record.completedAt ??= Date.now();
|
|
328
|
+
put(record, false);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
214
331
|
/**
|
|
215
332
|
* Format an agent's tool scope for the Agent tool description.
|
|
216
333
|
*
|
|
@@ -432,6 +549,14 @@ export default function (pi) {
|
|
|
432
549
|
let viewerMarkdown = "assistant";
|
|
433
550
|
function getViewerMarkdown() { return viewerMarkdown; }
|
|
434
551
|
function setViewerMarkdown(mode) { viewerMarkdown = mode; }
|
|
552
|
+
/** The `backgroundKey` setting as written; undefined means the default. Persisted as written. */
|
|
553
|
+
let backgroundKeySetting;
|
|
554
|
+
function setBackgroundKey(key) { backgroundKeySetting = key; }
|
|
555
|
+
/** The key that hands foreground `Agent` calls to the background, or undefined when it is off. */
|
|
556
|
+
function getBackgroundKey() {
|
|
557
|
+
const key = backgroundKeySetting ?? DEFAULT_BACKGROUND_KEY;
|
|
558
|
+
return key === "off" ? undefined : key;
|
|
559
|
+
}
|
|
435
560
|
/**
|
|
436
561
|
* The viewer's `m` key, from either entry point: set the mode and persist it,
|
|
437
562
|
* so the key and `/agents → Settings` stay one setting rather than one per
|
|
@@ -483,82 +608,86 @@ export default function (pi) {
|
|
|
483
608
|
}
|
|
484
609
|
catch { /* stale ctx */ }
|
|
485
610
|
}
|
|
486
|
-
// ----
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
cancelNudge(key);
|
|
496
|
-
pendingNudges.set(key, setTimeout(() => {
|
|
497
|
-
pendingNudges.delete(key);
|
|
498
|
-
try {
|
|
499
|
-
send();
|
|
500
|
-
}
|
|
501
|
-
catch { /* ignore stale completion side-effect errors */ }
|
|
502
|
-
}, delay));
|
|
503
|
-
}
|
|
504
|
-
function cancelNudge(key) {
|
|
505
|
-
const timer = pendingNudges.get(key);
|
|
506
|
-
if (timer != null) {
|
|
507
|
-
clearTimeout(timer);
|
|
508
|
-
pendingNudges.delete(key);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
// ---- Individual nudge helper (async join mode) ----
|
|
512
|
-
function emitIndividualNudge(record) {
|
|
513
|
-
if (record.resultConsumed)
|
|
514
|
-
return; // re-check at send time
|
|
515
|
-
const notification = formatTaskNotification(record, 500, showCost);
|
|
516
|
-
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
611
|
+
// ---- Completion delivery ----
|
|
612
|
+
// Every completion notification goes through the outbox, which sends what is
|
|
613
|
+
// pending as one message at the parent's next boundary (see
|
|
614
|
+
// completion-outbox.ts). Each is built when the agent finishes, and reading
|
|
615
|
+
// the result (get_subagent_result, rpc consume) cancels it before it is sent.
|
|
616
|
+
const outbox = new CompletionOutbox((message) => {
|
|
617
|
+
// A torn-down session cannot take a message (`pi` is stale).
|
|
618
|
+
if (disposed)
|
|
619
|
+
return;
|
|
517
620
|
pi.sendMessage({
|
|
518
621
|
customType: "subagent-notification",
|
|
519
|
-
content:
|
|
622
|
+
content: message.content,
|
|
520
623
|
display: true,
|
|
521
|
-
details:
|
|
522
|
-
}, { deliverAs: "
|
|
624
|
+
details: message.details,
|
|
625
|
+
}, { deliverAs: "steer", triggerTurn: true });
|
|
626
|
+
}, () => parentTurnActive());
|
|
627
|
+
/** Whether the parent has a run in flight, so a `turn_end` is coming. A stale ctx has none. */
|
|
628
|
+
function parentTurnActive() {
|
|
629
|
+
try {
|
|
630
|
+
return currentCtx?.signal !== undefined;
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
523
635
|
}
|
|
636
|
+
// A queued result wait must observe completion before its held notification
|
|
637
|
+
// can fire, so successful waits can still suppress that redundant nudge.
|
|
638
|
+
const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
|
|
524
639
|
function sendIndividualNudge(record) {
|
|
525
640
|
agentActivity.delete(record.id);
|
|
526
641
|
widget.markFinished(record.id);
|
|
527
642
|
fleet.onAgentFinished(record.id);
|
|
528
|
-
|
|
643
|
+
// Built now, so a resume before the send cannot rewrite it. A record that
|
|
644
|
+
// cannot be formatted is skipped, as a failed send always was.
|
|
645
|
+
try {
|
|
646
|
+
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
647
|
+
const message = {
|
|
648
|
+
content: formatTaskNotification(record, 500, showCost) + footer,
|
|
649
|
+
details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
|
|
650
|
+
};
|
|
651
|
+
outbox.add({ ids: [record.id], build: () => message });
|
|
652
|
+
}
|
|
653
|
+
catch { /* ignore stale completion side-effect errors */ }
|
|
529
654
|
widget.update();
|
|
530
655
|
}
|
|
531
|
-
// ---- Group join manager ----
|
|
656
|
+
// ---- Group join manager (`group` join mode only) ----
|
|
532
657
|
const groupJoin = new GroupJoinManager((records, partial) => {
|
|
533
658
|
for (const r of records) {
|
|
534
659
|
agentActivity.delete(r.id);
|
|
535
660
|
widget.markFinished(r.id);
|
|
536
661
|
fleet.onAgentFinished(r.id);
|
|
537
662
|
}
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const unconsumed = records.filter(r => !r.resultConsumed);
|
|
542
|
-
if (unconsumed.length === 0) {
|
|
543
|
-
widget.update();
|
|
544
|
-
return;
|
|
663
|
+
const members = records.filter(r => !r.resultConsumed).flatMap(r => {
|
|
664
|
+
try {
|
|
665
|
+
return [{ id: r.id, text: formatTaskNotification(r, 300, showCost), details: buildNotificationDetails(r, 300, agentActivity.get(r.id)) }];
|
|
545
666
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
? `${unconsumed.length} agent(s) finished (partial — others still running)`
|
|
549
|
-
: `${unconsumed.length} agent(s) finished`;
|
|
550
|
-
const [first, ...rest] = unconsumed;
|
|
551
|
-
const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
|
|
552
|
-
if (rest.length > 0) {
|
|
553
|
-
details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
|
|
667
|
+
catch {
|
|
668
|
+
return []; /* as in sendIndividualNudge */
|
|
554
669
|
}
|
|
555
|
-
pi.sendMessage({
|
|
556
|
-
customType: "subagent-notification",
|
|
557
|
-
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
558
|
-
display: true,
|
|
559
|
-
details,
|
|
560
|
-
}, { deliverAs: "followUp", triggerTurn: true });
|
|
561
670
|
});
|
|
671
|
+
if (members.length > 0) {
|
|
672
|
+
outbox.add({
|
|
673
|
+
ids: members.map(m => m.id),
|
|
674
|
+
build: (ids) => {
|
|
675
|
+
const unread = members.filter(m => ids.includes(m.id));
|
|
676
|
+
const notifications = unread.map(m => m.text).join('\n\n');
|
|
677
|
+
const label = partial
|
|
678
|
+
? `${unread.length} agent(s) finished (partial — others still running)`
|
|
679
|
+
: `${unread.length} agent(s) finished`;
|
|
680
|
+
const [first, ...rest] = unread;
|
|
681
|
+
const details = first.details;
|
|
682
|
+
if (rest.length > 0)
|
|
683
|
+
details.others = rest.map(m => m.details);
|
|
684
|
+
return {
|
|
685
|
+
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
686
|
+
details,
|
|
687
|
+
};
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
}
|
|
562
691
|
widget.update();
|
|
563
692
|
}, 30_000);
|
|
564
693
|
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
@@ -598,38 +727,86 @@ export default function (pi) {
|
|
|
598
727
|
usage,
|
|
599
728
|
};
|
|
600
729
|
}
|
|
601
|
-
// Background completion: route through group join or send individual nudge
|
|
602
730
|
/**
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
* Guarded: it also runs while the session is shutting down.
|
|
731
|
+
* The session this activation belongs to: its session file, or undefined for
|
|
732
|
+
* one kept in memory. Agents spawned here carry it as `ownerSession`; a
|
|
733
|
+
* background agent that outlives a session switch (session-handoff.ts)
|
|
734
|
+
* reports to its owner, not to whichever session is open.
|
|
608
735
|
*/
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
736
|
+
let currentSession;
|
|
737
|
+
const ownedHere = (record) => record.ownerSession === currentSession;
|
|
738
|
+
/**
|
|
739
|
+
* Write a custom entry into the session `owner`: through `pi` while that is
|
|
740
|
+
* the session this instance has open (Pi keeps it in memory), otherwise by
|
|
741
|
+
* appending to the owner's file, which Pi reads when it is opened again.
|
|
742
|
+
* Guarded: it also runs while the session is shutting down, and after.
|
|
743
|
+
*/
|
|
744
|
+
function writeEntry(owner, customType, data) {
|
|
745
|
+
if (owner === currentSession) {
|
|
746
|
+
try {
|
|
747
|
+
pi.appendEntry(customType, data);
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
catch { /* torn down: the session is no longer open, so its file is safe to append to */ }
|
|
617
751
|
}
|
|
618
|
-
|
|
752
|
+
return appendToSessionFile(owner, customType, data);
|
|
753
|
+
}
|
|
754
|
+
// Background completion: route through group join or send individual nudge
|
|
755
|
+
/**
|
|
756
|
+
* Write a top-level agent's final record into the session it belongs to.
|
|
757
|
+
* `resume` carries what reopening it needs (session file, handle, model,
|
|
758
|
+
* directory, worktree, isolation, turn limit); `session_start` reads it back,
|
|
759
|
+
* so the agent stays resumable after a restart, `/resume`, `/fork` or
|
|
760
|
+
* `/reload`. `pending` marks a result its session has not been told about
|
|
761
|
+
* yet (it finished while another session was open); that session delivers it
|
|
762
|
+
* when it starts.
|
|
763
|
+
*/
|
|
764
|
+
function appendRecordEntry(record, pending = false) {
|
|
765
|
+
writeEntry(record.ownerSession, RECORD_ENTRY_TYPE, recordEntryData(record, pending));
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Write an owned child's resume state (a nested agent's, a workflow's) into
|
|
769
|
+
* the session it belongs to, so `session_start` can rebuild it after a
|
|
770
|
+
* restart, `/resume`, `/fork` or `/reload`: its owner can then reopen it from
|
|
771
|
+
* its session file — the parent agent's nested tools, or a workflow's
|
|
772
|
+
* `agent({ resume })`. Only the resume state, never the result: the owner
|
|
773
|
+
* already has that, and the entry stays small. Nothing for an agent without
|
|
774
|
+
* a session file.
|
|
775
|
+
*/
|
|
776
|
+
function appendResumeEntry(record) {
|
|
777
|
+
const data = resumeEntryData(record);
|
|
778
|
+
if (data)
|
|
779
|
+
writeEntry(record.ownerSession, RESUME_ENTRY_TYPE, data);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* A top-level agent of another session finished while this one is open: its
|
|
783
|
+
* result belongs to that session. Record it there, marked for delivery; no
|
|
784
|
+
* notification, event or row here.
|
|
785
|
+
*/
|
|
786
|
+
function completeForeignAgent(record) {
|
|
787
|
+
agentActivity.delete(record.id);
|
|
788
|
+
appendRecordEntry(record, !record.resultConsumed);
|
|
619
789
|
}
|
|
620
|
-
|
|
790
|
+
function onAgentComplete(record) {
|
|
621
791
|
// After shutdown `pi` is stale, and the shutdown handler records the
|
|
622
792
|
// agents it interrupted itself.
|
|
623
793
|
if (disposed)
|
|
624
794
|
return;
|
|
625
|
-
// Spend that landed after the parent's last subagent tool result.
|
|
626
|
-
flushPendingUsageIfIdle();
|
|
627
795
|
// Owned children — nested, or a workflow's — report only through their
|
|
628
796
|
// owner: the parent's scoped tools, or the workflow's card, notification
|
|
629
797
|
// and dialog. Keep them out of top-level lifecycle, transcript,
|
|
630
|
-
// notification, and UI channels.
|
|
631
|
-
if (!isTopLevelAgent(record))
|
|
798
|
+
// notification, and UI channels. Their resume state is still recorded.
|
|
799
|
+
if (!isTopLevelAgent(record)) {
|
|
800
|
+
flushPendingUsageIfIdle();
|
|
801
|
+
appendResumeEntry(record);
|
|
632
802
|
return;
|
|
803
|
+
}
|
|
804
|
+
if (!ownedHere(record)) {
|
|
805
|
+
completeForeignAgent(record);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
// Spend that landed after the parent's last subagent tool result.
|
|
809
|
+
flushPendingUsageIfIdle();
|
|
633
810
|
// Emit lifecycle event based on terminal status
|
|
634
811
|
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
635
812
|
const eventData = buildEventData(record);
|
|
@@ -650,9 +827,9 @@ export default function (pi) {
|
|
|
650
827
|
widget.update();
|
|
651
828
|
return;
|
|
652
829
|
}
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
if (
|
|
830
|
+
// A `group` spawn whose turn has not ended yet: its group is registered at
|
|
831
|
+
// that turn's end, which picks this completion up retroactively.
|
|
832
|
+
if (turnGroupSpawns.includes(record.id)) {
|
|
656
833
|
widget.update();
|
|
657
834
|
return;
|
|
658
835
|
}
|
|
@@ -663,8 +840,9 @@ export default function (pi) {
|
|
|
663
840
|
// 'held' → do nothing, group will fire later
|
|
664
841
|
// 'delivered' → group callback already fired
|
|
665
842
|
widget.update();
|
|
666
|
-
}
|
|
667
|
-
|
|
843
|
+
}
|
|
844
|
+
function onAgentStart(record) {
|
|
845
|
+
if (disposed || !isTopLevelAgent(record) || !ownedHere(record))
|
|
668
846
|
return;
|
|
669
847
|
// Agent-tool spawns refresh these surfaces in their tool handler, but RPC
|
|
670
848
|
// and scheduler spawns enter through the manager directly.
|
|
@@ -680,8 +858,9 @@ export default function (pi) {
|
|
|
680
858
|
type: record.type,
|
|
681
859
|
description: record.description,
|
|
682
860
|
});
|
|
683
|
-
}
|
|
684
|
-
|
|
861
|
+
}
|
|
862
|
+
function onAgentCompact(record, info) {
|
|
863
|
+
if (disposed || !isTopLevelAgent(record) || !ownedHere(record))
|
|
685
864
|
return;
|
|
686
865
|
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
687
866
|
pi.events.emit("subagents:compacted", {
|
|
@@ -692,14 +871,28 @@ export default function (pi) {
|
|
|
692
871
|
tokensBefore: info.tokensBefore,
|
|
693
872
|
compactionCount: record.compactionCount,
|
|
694
873
|
});
|
|
695
|
-
}
|
|
874
|
+
}
|
|
875
|
+
function onAgentUsage(record, usage) {
|
|
696
876
|
// Every assistant message from every agent — nested included, exactly once.
|
|
697
877
|
// Parked here until a tool result can carry it back to the parent session;
|
|
698
878
|
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no
|
|
699
|
-
// pool grows in a session that will never drain it.
|
|
700
|
-
|
|
879
|
+
// pool grows in a session that will never drain it. Another session's
|
|
880
|
+
// agents bill that session, not this one.
|
|
881
|
+
if (reportUsage && ownedHere(record))
|
|
701
882
|
pendingUsage.add(usage);
|
|
702
|
-
}
|
|
883
|
+
}
|
|
884
|
+
const managerObservers = {
|
|
885
|
+
onComplete: onAgentComplete,
|
|
886
|
+
onStart: onAgentStart,
|
|
887
|
+
onCompact: onAgentCompact,
|
|
888
|
+
onUsage: onAgentUsage,
|
|
889
|
+
};
|
|
890
|
+
/**
|
|
891
|
+
* Replaced at `session_start` by a manager the previous session parked, so
|
|
892
|
+
* its background agents keep running (see session-handoff.ts). Everything
|
|
893
|
+
* reads it through this binding, never a copy.
|
|
894
|
+
*/
|
|
895
|
+
let manager = new AgentManager(onAgentComplete, undefined, onAgentStart, onAgentCompact, onAgentUsage);
|
|
703
896
|
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
704
897
|
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
|
705
898
|
// Documented for callers in docs/rpc.md ("The manager registry").
|
|
@@ -770,6 +963,14 @@ export default function (pi) {
|
|
|
770
963
|
delete safeOptions.reclaim;
|
|
771
964
|
// Internal to a reopen, like `reclaim`: it seeds an agent's lifetime figures.
|
|
772
965
|
delete safeOptions.carry;
|
|
966
|
+
// A caller's model and thinking level are per-call overrides, which
|
|
967
|
+
// `allowModelOverride` (off by default) refuses on every spawn path: the
|
|
968
|
+
// agent then runs on the parent's current model and level, or its agent
|
|
969
|
+
// file's.
|
|
970
|
+
if (!isModelOverrideAllowed()) {
|
|
971
|
+
delete safeOptions.model;
|
|
972
|
+
delete safeOptions.thinkingLevel;
|
|
973
|
+
}
|
|
773
974
|
// Every spawn through here is DETACHED — the caller gets an id back and
|
|
774
975
|
// awaits nothing. A forged `blocking` would charge it to the foreground
|
|
775
976
|
// pool and could defer it behind a queue whose gate nobody is holding.
|
|
@@ -785,8 +986,9 @@ export default function (pi) {
|
|
|
785
986
|
*/
|
|
786
987
|
const resolveAgentRef = (ref) => {
|
|
787
988
|
const byId = manager.getRecord(ref);
|
|
989
|
+
// Another session's agent is not this session's to read or steer.
|
|
788
990
|
if (byId)
|
|
789
|
-
return byId;
|
|
991
|
+
return manager.isVisible(byId) ? byId : undefined;
|
|
790
992
|
const resolved = manager.resolveMention(ref);
|
|
791
993
|
return resolved?.kind === "live" ? resolved.record : undefined;
|
|
792
994
|
};
|
|
@@ -796,7 +998,7 @@ export default function (pi) {
|
|
|
796
998
|
spawn: spawnTopLevel,
|
|
797
999
|
getRecord: (id) => {
|
|
798
1000
|
const record = manager.getRecord(id);
|
|
799
|
-
return record !== undefined && isTopLevelAgent(record) ? record : undefined;
|
|
1001
|
+
return record !== undefined && isTopLevelAgent(record) && manager.isVisible(record) ? record : undefined;
|
|
800
1002
|
},
|
|
801
1003
|
};
|
|
802
1004
|
const ownsManagerRegistry = globalThis[MANAGER_KEY] === undefined;
|
|
@@ -842,17 +1044,26 @@ export default function (pi) {
|
|
|
842
1044
|
pi.on("session_start", async (_event, ctx) => {
|
|
843
1045
|
disposed = false;
|
|
844
1046
|
currentCtx = ctx;
|
|
1047
|
+
currentSession = sessionFileOf(ctx);
|
|
1048
|
+
// First: everything below works on `manager`, which this may replace.
|
|
1049
|
+
adoptParkedAgents();
|
|
1050
|
+
manager.setVisibleOwner(currentSession);
|
|
845
1051
|
syncProjectTrust(ctx);
|
|
846
1052
|
void tidyAgentWorktrees(ctx);
|
|
847
1053
|
if (ctx.hasUI) {
|
|
848
1054
|
widget.setUICtx(ctx.ui);
|
|
849
1055
|
fleet.setUICtx(ctx.ui);
|
|
1056
|
+
watchHandoffKey(ctx.ui);
|
|
850
1057
|
}
|
|
851
1058
|
manager.clearCompleted(true);
|
|
852
1059
|
// Agents of this session from before a restart, `/resume`, `/fork` or
|
|
853
1060
|
// `/reload`: resumable again, by the model and by `@handle`. The branch,
|
|
854
1061
|
// not every entry: a fork keeps only the agents on its own path.
|
|
855
|
-
|
|
1062
|
+
const branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
1063
|
+
manager.restoreTombstones(resumeStatesFromEntries(branch));
|
|
1064
|
+
// Results of this session's agents that finished while another session
|
|
1065
|
+
// was open, recorded here for delivery now.
|
|
1066
|
+
deliverPendingResults(branch);
|
|
856
1067
|
// Guard mirrors the `!scheduler.isActive()` pattern below: session_start
|
|
857
1068
|
// fires once per activation, but a double-bind must not leak listeners.
|
|
858
1069
|
if (!rpcHandle) {
|
|
@@ -863,7 +1074,10 @@ export default function (pi) {
|
|
|
863
1074
|
manager: {
|
|
864
1075
|
spawn: spawnTopLevel,
|
|
865
1076
|
awaitStartup: (id) => manager.awaitStartup(id),
|
|
866
|
-
getRecord: (id) =>
|
|
1077
|
+
getRecord: (id) => {
|
|
1078
|
+
const record = manager.getRecord(id);
|
|
1079
|
+
return record && manager.isVisible(record) ? record : undefined;
|
|
1080
|
+
},
|
|
867
1081
|
// Unguarded on purpose: the stop handler now runs the top-level check
|
|
868
1082
|
// itself off `getRecord`, and reports the refusal instead of the
|
|
869
1083
|
// "Agent not found" a false from here used to be read as.
|
|
@@ -880,7 +1094,7 @@ export default function (pi) {
|
|
|
880
1094
|
if (manager.isRunActive(record.id))
|
|
881
1095
|
return false;
|
|
882
1096
|
record.resultConsumed = true;
|
|
883
|
-
|
|
1097
|
+
outbox.cancel(record.id);
|
|
884
1098
|
return true;
|
|
885
1099
|
},
|
|
886
1100
|
},
|
|
@@ -1143,9 +1357,130 @@ export default function (pi) {
|
|
|
1143
1357
|
// it returns. Pi confirms replacement by emitting session_shutdown; keep
|
|
1144
1358
|
// the current schedules and agent records intact until then.
|
|
1145
1359
|
});
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1360
|
+
/**
|
|
1361
|
+
* Take over the manager the previous session parked at its switch, if it was
|
|
1362
|
+
* parked for this session by this same version: its background agents keep
|
|
1363
|
+
* running and show here when they are this session's. Another version cannot
|
|
1364
|
+
* share the manager's code, so it stops its agents itself, as a quit would.
|
|
1365
|
+
* See session-handoff.ts.
|
|
1366
|
+
*/
|
|
1367
|
+
function adoptParkedAgents() {
|
|
1368
|
+
const parked = takeParkedSubagents(currentSession);
|
|
1369
|
+
if (!parked)
|
|
1370
|
+
return;
|
|
1371
|
+
if (!isCompatible(parked)) {
|
|
1372
|
+
void parked.stop(writeEntry).catch(() => { });
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const adopted = parked.manager;
|
|
1376
|
+
const fresh = manager;
|
|
1377
|
+
// New work runs with this instance's modules (agent registry, settings).
|
|
1378
|
+
adopted.useServices(managerServices());
|
|
1379
|
+
adopted.setObservers(managerObservers);
|
|
1380
|
+
adopted.setMaxConcurrent(fresh.getMaxConcurrent());
|
|
1381
|
+
adopted.setMaxConcurrentForeground(fresh.getMaxConcurrentForeground());
|
|
1382
|
+
manager = adopted;
|
|
1383
|
+
widget.setManager(adopted);
|
|
1384
|
+
fleet.setManager(adopted);
|
|
1385
|
+
for (const [id, state] of parked.activity) {
|
|
1386
|
+
if (!agentActivity.has(id))
|
|
1387
|
+
agentActivity.set(id, state);
|
|
1388
|
+
}
|
|
1389
|
+
void fresh.dispose();
|
|
1390
|
+
// Runs that settled between the two instances, handled as if just now —
|
|
1391
|
+
// once this start has finished setting up.
|
|
1392
|
+
const settled = parked.completed;
|
|
1393
|
+
if (settled.length > 0)
|
|
1394
|
+
setTimeout(() => { for (const record of settled)
|
|
1395
|
+
onAgentComplete(record); }, 0);
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* At a session switch: leave the background agents running in a manager
|
|
1399
|
+
* parked for the next instance (session-handoff.ts). Nothing parked here
|
|
1400
|
+
* may touch this instance's `pi` or ctx, which Pi invalidates next: until
|
|
1401
|
+
* adopted, completions are queued in the entry.
|
|
1402
|
+
*/
|
|
1403
|
+
function parkAgents(reason, targetSessionFile) {
|
|
1404
|
+
const parkedManager = manager;
|
|
1405
|
+
const entry = {
|
|
1406
|
+
protocol: HANDOFF_PROTOCOL,
|
|
1407
|
+
version: PACKAGE_VERSION,
|
|
1408
|
+
reason,
|
|
1409
|
+
sessionFile: currentSession,
|
|
1410
|
+
targetSessionFile,
|
|
1411
|
+
manager: parkedManager,
|
|
1412
|
+
activity: new Map(agentActivity),
|
|
1413
|
+
completed: [],
|
|
1414
|
+
stop: (write) => stopParkedAgents(parkedManager, entry, write),
|
|
1415
|
+
};
|
|
1416
|
+
parkedManager.setObservers({ onComplete: (record) => { entry.completed.push(record); } });
|
|
1417
|
+
parkedManager.detachPendingContexts();
|
|
1418
|
+
parkedManager.setVisibleOwner(null);
|
|
1419
|
+
parkSubagents(entry, () => recordParkedAgentsAtExit(parkedManager, entry));
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Notify this session of its agents that finished while another session was
|
|
1423
|
+
* open (`pending` record entries), once, after this start: a turn follows,
|
|
1424
|
+
* as for any background completion. Marked delivered at once, so a second
|
|
1425
|
+
* start does not repeat it.
|
|
1426
|
+
*/
|
|
1427
|
+
function deliverPendingResults(branch) {
|
|
1428
|
+
const pending = pendingResultsFromEntries(branch);
|
|
1429
|
+
if (pending.length === 0)
|
|
1430
|
+
return;
|
|
1431
|
+
const ids = pending.map(r => r.id);
|
|
1432
|
+
try {
|
|
1433
|
+
pi.appendEntry(DELIVERED_ENTRY_TYPE, { ids });
|
|
1434
|
+
}
|
|
1435
|
+
catch {
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
// The record still in memory carries more (context, compactions) than its entry.
|
|
1439
|
+
const records = pending.map(entry => {
|
|
1440
|
+
const live = manager.getRecord(entry.id);
|
|
1441
|
+
return live && manager.isVisible(live) ? live : entry;
|
|
1442
|
+
});
|
|
1443
|
+
// Through the completion outbox, like every other completion: delivered at
|
|
1444
|
+
// this session's next boundary (a turn when idle), coalesced with anything
|
|
1445
|
+
// else that finished, and cancelled per agent by get_subagent_result.
|
|
1446
|
+
// An entry is all there is of an evicted agent, and get_subagent_result
|
|
1447
|
+
// cannot fetch more, so its whole result goes in.
|
|
1448
|
+
const maxLen = (r) => (manager.getRecord(r.id) === r ? 500 : 20_000);
|
|
1449
|
+
for (const r of records) {
|
|
1450
|
+
agentActivity.delete(r.id);
|
|
1451
|
+
widget.markFinished(r.id);
|
|
1452
|
+
fleet.onAgentFinished(r.id);
|
|
1453
|
+
}
|
|
1454
|
+
const parts = records.flatMap(r => {
|
|
1455
|
+
try {
|
|
1456
|
+
return [{ id: r.id, text: formatTaskNotification(r, maxLen(r), showCost), details: buildNotificationDetails(r, 300) }];
|
|
1457
|
+
}
|
|
1458
|
+
catch {
|
|
1459
|
+
return []; /* as in sendIndividualNudge */
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
if (parts.length > 0) {
|
|
1463
|
+
outbox.add({
|
|
1464
|
+
ids: parts.map(p => p.id),
|
|
1465
|
+
build: (unreadIds) => {
|
|
1466
|
+
const unread = parts.filter(p => unreadIds.includes(p.id));
|
|
1467
|
+
const [first, ...rest] = unread;
|
|
1468
|
+
const details = { ...first.details };
|
|
1469
|
+
if (rest.length > 0)
|
|
1470
|
+
details.others = rest.map(p => p.details);
|
|
1471
|
+
const label = unread.length === 1 ? "An agent" : `${unread.length} agents`;
|
|
1472
|
+
return {
|
|
1473
|
+
content: `${label} of this session finished while another session was open.\n\n${unread.map(p => p.text).join("\n\n")}`,
|
|
1474
|
+
details,
|
|
1475
|
+
};
|
|
1476
|
+
},
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
widget.update();
|
|
1480
|
+
}
|
|
1481
|
+
// On shutdown: on a session switch, background agents keep running (parked
|
|
1482
|
+
// for the next instance); on quit, every agent is stopped and recorded.
|
|
1483
|
+
pi.on("session_shutdown", async (event) => {
|
|
1149
1484
|
// Spend no tool result carried back yet: record it while the session can.
|
|
1150
1485
|
flushPendingUsage();
|
|
1151
1486
|
disposed = true;
|
|
@@ -1171,26 +1506,73 @@ export default function (pi) {
|
|
|
1171
1506
|
for (const task of workflowTasks.values())
|
|
1172
1507
|
task.abortController.abort();
|
|
1173
1508
|
workflowTasks.clear();
|
|
1174
|
-
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
|
|
1178
|
-
|
|
1509
|
+
const live = manager.listAllAgents().filter(r => r.status === "running" || r.status === "queued");
|
|
1510
|
+
// Pi says why (Pi >= 1.0): `new`, `resume`, `fork` and `reload` replace the
|
|
1511
|
+
// session within this process; `quit` (also /quit and SIGTERM/SIGHUP) or no
|
|
1512
|
+
// reason ends it. On a switch, a background agent whose session is on disk
|
|
1513
|
+
// keeps running — its results can still be recorded there — along with
|
|
1514
|
+
// the agents it owns. Foreground agents (Pi aborted their tool call before
|
|
1515
|
+
// this), workflows and agents of a session kept in memory stop as before.
|
|
1516
|
+
const { reason, targetSessionFile } = (event ?? {});
|
|
1517
|
+
// The next instance finds the parked manager by the session it starts, so
|
|
1518
|
+
// a switch must name it (Pi 1.0 does; `/reload` keeps this one).
|
|
1519
|
+
const switching = isSessionSwitch(reason)
|
|
1520
|
+
&& (reason === "reload" ? currentSession !== undefined : typeof targetSessionFile === "string");
|
|
1521
|
+
const kept = new Set(switching
|
|
1522
|
+
? live.filter(r => isTopLevelAgent(r) && r.isBackground !== false && isSessionOnDisk(r.ownerSession)).map(r => r.id)
|
|
1523
|
+
: []);
|
|
1524
|
+
const rootOf = (record) => {
|
|
1525
|
+
let current = record;
|
|
1526
|
+
for (let parent = current.parentAgentId; parent !== undefined; parent = current.parentAgentId) {
|
|
1527
|
+
const next = manager.getRecord(parent);
|
|
1528
|
+
if (!next)
|
|
1529
|
+
break;
|
|
1530
|
+
current = next;
|
|
1531
|
+
}
|
|
1532
|
+
return current.id;
|
|
1533
|
+
};
|
|
1534
|
+
const interrupted = live.filter(r => !kept.has(rootOf(r)));
|
|
1535
|
+
// Also worth keeping: another session's agents that finished while this
|
|
1536
|
+
// one was open, so their session shows them in full when it is reopened.
|
|
1537
|
+
const carried = switching && manager.listAllAgents().some(r => isTopLevelAgent(r) && !ownedHere(r) && !r.resultConsumed && !manager.isRunActive(r.id) && isSessionOnDisk(r.ownerSession));
|
|
1538
|
+
const parking = kept.size > 0 || carried;
|
|
1539
|
+
if (kept.size > 0) {
|
|
1540
|
+
for (const record of interrupted)
|
|
1541
|
+
manager.abort(record.id);
|
|
1542
|
+
}
|
|
1543
|
+
else {
|
|
1544
|
+
manager.abortAll();
|
|
1545
|
+
}
|
|
1179
1546
|
// A stopped worktree agent still commits its changes to a branch while it
|
|
1180
|
-
// settles; dispose() does not wait for that, and the process
|
|
1181
|
-
await manager.awaitWorktreeCleanup(15_000);
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1547
|
+
// settles; dispose() does not wait for that, and the process may exit next.
|
|
1548
|
+
await manager.awaitWorktreeCleanup(15_000, kept.size > 0 ? new Set(interrupted.map(r => r.id)) : undefined);
|
|
1549
|
+
// Each recorded as stopped and resumable, in the session it belongs to.
|
|
1550
|
+
for (const record of interrupted) {
|
|
1551
|
+
if (isTopLevelAgent(record))
|
|
1552
|
+
appendRecordEntry(record);
|
|
1553
|
+
else
|
|
1554
|
+
appendResumeEntry(record);
|
|
1555
|
+
}
|
|
1556
|
+
if (parking)
|
|
1557
|
+
parkAgents(reason, targetSessionFile);
|
|
1558
|
+
// Completions still waiting for this session's next boundary (a busy or
|
|
1559
|
+
// interrupted parent): marked pending in the session they belong to, so
|
|
1560
|
+
// its next start (a switch back, a restart) delivers them like any result
|
|
1561
|
+
// that landed while another session was open.
|
|
1562
|
+
for (const id of outbox.pendingIds()) {
|
|
1563
|
+
const record = manager.getRecord(id);
|
|
1564
|
+
if (record && isTopLevelAgent(record) && !record.resultConsumed && !manager.isRunActive(id))
|
|
1565
|
+
appendRecordEntry(record, true);
|
|
1566
|
+
}
|
|
1187
1567
|
// Neither may fire into a torn-down instance (or hold `pi -p` open).
|
|
1568
|
+
outbox.dispose();
|
|
1188
1569
|
groupJoin.dispose();
|
|
1189
|
-
|
|
1190
|
-
clearTimeout(batchFinalizeTimer);
|
|
1191
|
-
batchFinalizeTimer = undefined;
|
|
1192
|
-
currentBatchAgents = [];
|
|
1570
|
+
turnGroupSpawns = [];
|
|
1193
1571
|
fleet.dispose();
|
|
1572
|
+
unsubHandoffKey?.();
|
|
1573
|
+
unsubHandoffKey = undefined;
|
|
1574
|
+
handoffKeyUi = undefined;
|
|
1575
|
+
foregroundHandoffs.clear();
|
|
1194
1576
|
// Its 80 ms refresh interval otherwise outlives the session across /reload.
|
|
1195
1577
|
// The interval is cleared first; the UI calls after it may meet a context
|
|
1196
1578
|
// that is already gone.
|
|
@@ -1199,6 +1581,9 @@ export default function (pi) {
|
|
|
1199
1581
|
}
|
|
1200
1582
|
catch { /* stale UI context at shutdown */ }
|
|
1201
1583
|
refreshTicker.clear();
|
|
1584
|
+
// A parked manager lives on, with its agents' sessions.
|
|
1585
|
+
if (parking)
|
|
1586
|
+
return;
|
|
1202
1587
|
// Awaited: it emits `session_shutdown` into every retained child session so
|
|
1203
1588
|
// extensions bound there can release what they armed in `session_start` (#242).
|
|
1204
1589
|
// pi awaits this handler, and the process exits right after — unawaited, those
|
|
@@ -1217,10 +1602,102 @@ export default function (pi) {
|
|
|
1217
1602
|
const refreshTicker = new RefreshTicker();
|
|
1218
1603
|
const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled, refreshTicker);
|
|
1219
1604
|
function setWidgetMode(m) { widgetMode = m; widget.update(); }
|
|
1605
|
+
/**
|
|
1606
|
+
* Foreground `Agent` calls whose run can be handed to the background, by
|
|
1607
|
+
* agent id. Each entry wakes its blocked tool call, which then returns as a
|
|
1608
|
+
* background spawn would. Only the Agent tool registers here, so a spawn with
|
|
1609
|
+
* no tool call to return — the agent generator, a workflow's agents, nested
|
|
1610
|
+
* children — never offers the handoff.
|
|
1611
|
+
*/
|
|
1612
|
+
const foregroundHandoffs = new Map();
|
|
1613
|
+
/** Hand these runs to the background and wake their tool calls; returns how many took. */
|
|
1614
|
+
function sendForegroundToBackground(ids) {
|
|
1615
|
+
let sent = 0;
|
|
1616
|
+
for (const id of [...ids]) {
|
|
1617
|
+
const wake = foregroundHandoffs.get(id);
|
|
1618
|
+
if (!wake || !manager.sendToBackground(id))
|
|
1619
|
+
continue;
|
|
1620
|
+
foregroundHandoffs.delete(id);
|
|
1621
|
+
wake();
|
|
1622
|
+
sent++;
|
|
1623
|
+
// It is a background agent from here on, so announce it as one — as a
|
|
1624
|
+
// detached resume re-announces an existing id.
|
|
1625
|
+
const record = manager.getRecord(id);
|
|
1626
|
+
if (record) {
|
|
1627
|
+
pi.events.emit("subagents:created", {
|
|
1628
|
+
id,
|
|
1629
|
+
type: record.type,
|
|
1630
|
+
description: record.description,
|
|
1631
|
+
isBackground: true,
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (sent > 0) {
|
|
1636
|
+
widget.ensureTimer();
|
|
1637
|
+
widget.update();
|
|
1638
|
+
fleet.ensureTimer();
|
|
1639
|
+
fleet.update();
|
|
1640
|
+
}
|
|
1641
|
+
return sent;
|
|
1642
|
+
}
|
|
1643
|
+
/** The conversation viewer's `b`: hand just this run to the background. */
|
|
1644
|
+
function backgroundActionFor(record, notify) {
|
|
1645
|
+
return {
|
|
1646
|
+
// Registered while the run is live, removed the moment its call returns.
|
|
1647
|
+
available: () => foregroundHandoffs.has(record.id),
|
|
1648
|
+
send: () => {
|
|
1649
|
+
if (sendForegroundToBackground([record.id]) > 0)
|
|
1650
|
+
notify(`Sent "${record.description}" to the background.`);
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
// Claude Code's ctrl+b (`task:background`), configurable as `backgroundKey`:
|
|
1655
|
+
// hand every running foreground `Agent` call to the background. Claimed only
|
|
1656
|
+
// while one is pending and the prompt editor has focus, so the rest of the
|
|
1657
|
+
// time the key keeps its editor meaning (ctrl+b is pi's cursor-left).
|
|
1658
|
+
let handoffKeyUi;
|
|
1659
|
+
let unsubHandoffKey;
|
|
1660
|
+
function watchHandoffKey(ui) {
|
|
1661
|
+
if (ui === handoffKeyUi)
|
|
1662
|
+
return;
|
|
1663
|
+
unsubHandoffKey?.();
|
|
1664
|
+
handoffKeyUi = ui;
|
|
1665
|
+
unsubHandoffKey = ui.onTerminalInput(data => {
|
|
1666
|
+
// Cheapest checks first: this listener sees every key the user types.
|
|
1667
|
+
if (foregroundHandoffs.size === 0)
|
|
1668
|
+
return undefined;
|
|
1669
|
+
const key = getBackgroundKey();
|
|
1670
|
+
// Kitty reports a release too, and matchesKey matches it — act on the
|
|
1671
|
+
// press only, or a refused handoff would be retried by its own release.
|
|
1672
|
+
if (key === undefined || isKeyRelease(data) || !matchesKey(data, key))
|
|
1673
|
+
return undefined;
|
|
1674
|
+
// Listeners run before the focused component, so a dialog or the
|
|
1675
|
+
// viewer's composer would lose the key. The viewer has its own `b`.
|
|
1676
|
+
if (!fleet.editorHasFocus())
|
|
1677
|
+
return undefined;
|
|
1678
|
+
const sent = sendForegroundToBackground(foregroundHandoffs.keys());
|
|
1679
|
+
if (sent > 0)
|
|
1680
|
+
ui.notify(`Sent ${sent === 1 ? "1 agent" : `${sent} agents`} to the background.`, "info");
|
|
1681
|
+
return { consume: true };
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Pi's fullscreen scrollbar preference, read from the main session's live,
|
|
1686
|
+
* trust-aware settings and normalized as Pi's own `getFullscreenScrollbar()` does.
|
|
1687
|
+
*/
|
|
1688
|
+
function getFullscreenScrollbar() {
|
|
1689
|
+
try {
|
|
1690
|
+
const mode = pi.getSettings().fullscreenScrollbar;
|
|
1691
|
+
return mode === "always" || mode === "hidden" ? mode : "auto";
|
|
1692
|
+
}
|
|
1693
|
+
catch {
|
|
1694
|
+
return "auto";
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1220
1697
|
// Claude Code-style FleetView: navigable list of main + subagents below the editor.
|
|
1221
1698
|
// The last two arguments keep a conversation overlay opened here identical to
|
|
1222
1699
|
// one opened from `/agents`: same setting on the way in, same persist out.
|
|
1223
|
-
const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, currentCtx), refreshTicker);
|
|
1700
|
+
const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, currentCtx), refreshTicker, backgroundActionFor, getFullscreenScrollbar);
|
|
1224
1701
|
let fleetViewEnabled = true;
|
|
1225
1702
|
function isFleetViewEnabled() { return fleetViewEnabled; }
|
|
1226
1703
|
function setFleetViewEnabled(b) { fleetViewEnabled = b; fleet.setEnabled(b); }
|
|
@@ -1295,45 +1772,42 @@ export default function (pi) {
|
|
|
1295
1772
|
let toolDescriptionMode = "full";
|
|
1296
1773
|
function getToolDescriptionMode() { return toolDescriptionMode; }
|
|
1297
1774
|
function setToolDescriptionMode(mode) { toolDescriptionMode = mode; }
|
|
1298
|
-
// ----
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
let
|
|
1304
|
-
let
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
continue;
|
|
1323
|
-
record.groupId = groupId;
|
|
1324
|
-
if (record.completedAt != null && !record.resultConsumed) {
|
|
1325
|
-
groupJoin.onAgentComplete(record);
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1775
|
+
// ---- Turn batches for `group` join mode ----
|
|
1776
|
+
// Background spawns the parent made in its current turn. The turn's end is
|
|
1777
|
+
// the batch boundary: every `Agent` call of the batch has returned by then,
|
|
1778
|
+
// so a spawn whose startup failed has already thrown and was never recorded,
|
|
1779
|
+
// and a slow worktree copy cannot split the batch.
|
|
1780
|
+
let turnGroupSpawns = [];
|
|
1781
|
+
let turnGroupCounter = 0;
|
|
1782
|
+
/** Record a `group`-mode spawn made inside a parent turn. */
|
|
1783
|
+
function joinTurnGroup(id, joinMode) {
|
|
1784
|
+
// Outside a parent turn (an `@handle` mention) no turn end is coming, and a
|
|
1785
|
+
// group of one is delivered exactly like a single completion.
|
|
1786
|
+
if (joinMode === 'group' && parentTurnActive())
|
|
1787
|
+
turnGroupSpawns.push(id);
|
|
1788
|
+
}
|
|
1789
|
+
/** Register the ending turn's `group` spawns, two or more, as one group. */
|
|
1790
|
+
function registerTurnGroup() {
|
|
1791
|
+
const ids = turnGroupSpawns;
|
|
1792
|
+
turnGroupSpawns = [];
|
|
1793
|
+
// One spawn is no group: it reports like any single completion.
|
|
1794
|
+
if (ids.length < 2) {
|
|
1795
|
+
const record = ids.length === 1 ? manager.getRecord(ids[0]) : undefined;
|
|
1796
|
+
if (record?.completedAt != null && !record.resultConsumed)
|
|
1797
|
+
sendIndividualNudge(record);
|
|
1798
|
+
return;
|
|
1328
1799
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1800
|
+
const groupId = `turn-${++turnGroupCounter}`;
|
|
1801
|
+
groupJoin.registerGroup(groupId, ids);
|
|
1802
|
+
// Members that finished before their turn ended had their completion
|
|
1803
|
+
// deferred (see the manager's onComplete); feed them in now.
|
|
1804
|
+
for (const id of ids) {
|
|
1805
|
+
const record = manager.getRecord(id);
|
|
1806
|
+
if (!record)
|
|
1807
|
+
continue;
|
|
1808
|
+
record.groupId = groupId;
|
|
1809
|
+
if (record.completedAt != null && !record.resultConsumed) {
|
|
1810
|
+
groupJoin.onAgentComplete(record);
|
|
1337
1811
|
}
|
|
1338
1812
|
}
|
|
1339
1813
|
}
|
|
@@ -1429,12 +1903,9 @@ export default function (pi) {
|
|
|
1429
1903
|
});
|
|
1430
1904
|
if (!record)
|
|
1431
1905
|
return undefined;
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
clearTimeout(batchFinalizeTimer);
|
|
1436
|
-
batchFinalizeTimer = setTimeout(finalizeBatch, 100);
|
|
1437
|
-
}
|
|
1906
|
+
// Only a resume from an Agent call belongs to that call's turn.
|
|
1907
|
+
if (opts.toolCallId !== undefined)
|
|
1908
|
+
joinTurnGroup(id, joinMode);
|
|
1438
1909
|
agentActivity.set(id, bgState);
|
|
1439
1910
|
// This agent already finished once, so the widget holds a finished-age
|
|
1440
1911
|
// for it that is past the linger limit — without clearing it, the
|
|
@@ -1470,11 +1941,20 @@ export default function (pi) {
|
|
|
1470
1941
|
// result (background agents, workflows) reaches the session stats now.
|
|
1471
1942
|
pi.on("agent_end", (_event, ctx) => {
|
|
1472
1943
|
flushPendingUsage(ctx);
|
|
1944
|
+
outbox.onAgentEnd();
|
|
1945
|
+
});
|
|
1946
|
+
pi.on("agent_settled", () => outbox.onAgentEnd());
|
|
1947
|
+
// The parent's tool batch is done and its next model call not yet made: the
|
|
1948
|
+
// boundary where completions are delivered, as in Claude Code.
|
|
1949
|
+
pi.on("turn_end", (event) => {
|
|
1950
|
+
registerTurnGroup();
|
|
1951
|
+
outbox.onTurnEnd(event.message.role === "assistant" ? event.message.stopReason : undefined);
|
|
1473
1952
|
});
|
|
1474
1953
|
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
1475
1954
|
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
1476
1955
|
widget.setUICtx(ctx.ui);
|
|
1477
1956
|
fleet.setUICtx(ctx.ui);
|
|
1957
|
+
watchHandoffKey(ctx.ui);
|
|
1478
1958
|
widget.onTurnStart();
|
|
1479
1959
|
});
|
|
1480
1960
|
/** Build the full type list text dynamically from available agents only. */
|
|
@@ -1519,6 +1999,7 @@ export default function (pi) {
|
|
|
1519
1999
|
setBackgroundByDefault,
|
|
1520
2000
|
setSchedulingEnabled,
|
|
1521
2001
|
setScopeModels: setScopeModelsEnabled,
|
|
2002
|
+
setAllowModelOverride: setModelOverrideAllowed,
|
|
1522
2003
|
setStrictAgentFiles: (b) => { strictAgentFiles = b; },
|
|
1523
2004
|
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
1524
2005
|
setToolDescriptionMode: setToolDescriptionMode,
|
|
@@ -1535,6 +2016,7 @@ export default function (pi) {
|
|
|
1535
2016
|
setShowCost,
|
|
1536
2017
|
setShowModel,
|
|
1537
2018
|
setViewerMarkdown,
|
|
2019
|
+
setBackgroundKey,
|
|
1538
2020
|
};
|
|
1539
2021
|
applyAndEmitLoaded(settingsAppliers, (event, payload) => pi.events.emit(event, payload), process.cwd(), projectTrusted);
|
|
1540
2022
|
/**
|
|
@@ -1609,6 +2091,22 @@ export default function (pi) {
|
|
|
1609
2091
|
const isolationGuideline = isWorktreeIsolationEnabled()
|
|
1610
2092
|
? `\n- Use isolation: "worktree" to give the agent its own git worktree (safe parallel file modifications); leave it unset, or pass "off", for none. The worktree is removed when the agent finishes; if it made changes, they are committed to a branch and the branch is named in the result.`
|
|
1611
2093
|
: "";
|
|
2094
|
+
// Same trade again, for `allowModelOverride`: with overrides off the `model`
|
|
2095
|
+
// and `thinking` parameters are not in the schema, so the prose that teaches
|
|
2096
|
+
// them goes too — a model told it may pick a model will keep trying.
|
|
2097
|
+
const modelOverrideAllowed = isModelOverrideAllowed();
|
|
2098
|
+
const modelGuideline = modelOverrideAllowed
|
|
2099
|
+
? `- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").\n- Use thinking to control extended thinking level.\n`
|
|
2100
|
+
: "";
|
|
2101
|
+
const modelParamShape = {
|
|
2102
|
+
model: Type.Optional(Type.String({
|
|
2103
|
+
description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
2104
|
+
})),
|
|
2105
|
+
thinking: Type.Optional(Type.String({
|
|
2106
|
+
description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
|
|
2107
|
+
})),
|
|
2108
|
+
};
|
|
2109
|
+
const modelParams = modelOverrideAllowed ? modelParamShape : {};
|
|
1612
2110
|
const isolationCompactGuideline = isWorktreeIsolationEnabled()
|
|
1613
2111
|
? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
|
|
1614
2112
|
: "";
|
|
@@ -1652,9 +2150,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
1652
2150
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
1653
2151
|
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
|
|
1654
2152
|
- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
|
|
1655
|
-
- Use
|
|
1656
|
-
- Use thinking to control extended thinking level.
|
|
1657
|
-
- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
|
|
2153
|
+
${modelGuideline}- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
|
|
1658
2154
|
|
|
1659
2155
|
## Writing the prompt
|
|
1660
2156
|
|
|
@@ -1679,6 +2175,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1679
2175
|
agentDir: getAgentDir,
|
|
1680
2176
|
isolationGuideline: () => isolationGuideline,
|
|
1681
2177
|
scheduleGuideline: () => scheduleGuideline,
|
|
2178
|
+
modelGuideline: () => modelGuideline,
|
|
1682
2179
|
};
|
|
1683
2180
|
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
1684
2181
|
return template.replace(/\{\{(\w+)\}\}/g, (raw, name) => {
|
|
@@ -1746,12 +2243,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1746
2243
|
subagent_type: Type.String({
|
|
1747
2244
|
description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
|
|
1748
2245
|
}),
|
|
1749
|
-
|
|
1750
|
-
description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
1751
|
-
})),
|
|
1752
|
-
thinking: Type.Optional(Type.String({
|
|
1753
|
-
description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
|
|
1754
|
-
})),
|
|
2246
|
+
...modelParams,
|
|
1755
2247
|
max_turns: Type.Optional(Type.Number({
|
|
1756
2248
|
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
1757
2249
|
minimum: 1,
|
|
@@ -1826,7 +2318,14 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1826
2318
|
if (isPartial || details.status === "running") {
|
|
1827
2319
|
const frame = SPINNER[details.spinnerFrame ?? 0];
|
|
1828
2320
|
const s = stats(details);
|
|
1829
|
-
|
|
2321
|
+
const status = renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
|
|
2322
|
+
// Claude Code's hint, after the same 2s: a call that finishes quickly
|
|
2323
|
+
// never flashes it.
|
|
2324
|
+
const backgroundKey = getBackgroundKey();
|
|
2325
|
+
if (details.backgroundHint && backgroundKey && details.durationMs >= BACKGROUND_HINT_DELAY_MS) {
|
|
2326
|
+
status.addChild(new Text(theme.fg("dim", ` (${backgroundKey} to run in background)`), 0, 0));
|
|
2327
|
+
}
|
|
2328
|
+
return status;
|
|
1830
2329
|
}
|
|
1831
2330
|
// ---- Background agent launched ----
|
|
1832
2331
|
if (details.status === "background") {
|
|
@@ -2066,7 +2565,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2066
2565
|
// at fire time, and the original is what a user edits.
|
|
2067
2566
|
subagent_type: requestedType,
|
|
2068
2567
|
prompt: params.prompt,
|
|
2069
|
-
|
|
2568
|
+
// Only when overrides are on: a job keeps the call's model choice
|
|
2569
|
+
// for every later fire, which is exactly what the setting refuses.
|
|
2570
|
+
model: isModelOverrideAllowed() ? params.model : undefined,
|
|
2070
2571
|
thinking: thinking,
|
|
2071
2572
|
max_turns: effectiveMaxTurns,
|
|
2072
2573
|
isolated: isolated,
|
|
@@ -2193,6 +2694,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2193
2694
|
(record.result?.trim() || "No output.") +
|
|
2194
2695
|
agentIdLine(record.id), buildDetails(detailBaseFor(record), record));
|
|
2195
2696
|
}
|
|
2697
|
+
// What the model sees for a run it no longer waits on — spawned in the
|
|
2698
|
+
// background, or handed there mid-run (background key / viewer `b`). One
|
|
2699
|
+
// text for both, as in Claude Code: from here on they are the same kind of agent.
|
|
2700
|
+
const backgroundResult = (id, record) => {
|
|
2701
|
+
const isQueued = record?.status === "queued";
|
|
2702
|
+
return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
2703
|
+
`Agent ID: ${id}\n` +
|
|
2704
|
+
`Type: ${displayName}\n` +
|
|
2705
|
+
`Description: ${params.description}\n` +
|
|
2706
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2707
|
+
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
2708
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
2709
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
2710
|
+
`Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
|
|
2711
|
+
};
|
|
2196
2712
|
// Background execution
|
|
2197
2713
|
if (runInBackground) {
|
|
2198
2714
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
@@ -2200,12 +2716,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2200
2716
|
// The callback lazily reads record.outputFile (set right after spawn)
|
|
2201
2717
|
// rather than closing over a value that doesn't exist yet.
|
|
2202
2718
|
let id;
|
|
2719
|
+
// Read now: a queued agent may start after a session switch, when this
|
|
2720
|
+
// ctx is stale and every read of it throws.
|
|
2721
|
+
const bgCwd = ctx.cwd;
|
|
2203
2722
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
2204
2723
|
bgCallbacks.onSessionCreated = (session) => {
|
|
2205
2724
|
origBgOnSession(session);
|
|
2206
2725
|
const rec = manager.getRecord(id);
|
|
2207
2726
|
if (rec?.outputFile) {
|
|
2208
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id,
|
|
2727
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, bgCwd);
|
|
2209
2728
|
}
|
|
2210
2729
|
};
|
|
2211
2730
|
// A throw here means the agent never started. Let it out: pi marks a
|
|
@@ -2239,18 +2758,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2239
2758
|
// wiring above, so a strict-isolation failure still fails THIS tool
|
|
2240
2759
|
// call instead of being reported as a subagent that ran (#179).
|
|
2241
2760
|
await manager.awaitStartup(id);
|
|
2242
|
-
|
|
2243
|
-
// Foreground/no join mode or explicit async — not part of any batch
|
|
2244
|
-
}
|
|
2245
|
-
else {
|
|
2246
|
-
// smart or group — add to current batch
|
|
2247
|
-
currentBatchAgents.push({ id, joinMode });
|
|
2248
|
-
// Debounce: reset timer on each new agent so parallel tool calls
|
|
2249
|
-
// dispatched across multiple event loop ticks are captured together
|
|
2250
|
-
if (batchFinalizeTimer)
|
|
2251
|
-
clearTimeout(batchFinalizeTimer);
|
|
2252
|
-
batchFinalizeTimer = setTimeout(finalizeBatch, 100);
|
|
2253
|
-
}
|
|
2761
|
+
joinTurnGroup(id, joinMode);
|
|
2254
2762
|
agentActivity.set(id, bgState);
|
|
2255
2763
|
widget.ensureTimer();
|
|
2256
2764
|
widget.update();
|
|
@@ -2263,26 +2771,26 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2263
2771
|
description: params.description,
|
|
2264
2772
|
isBackground: true,
|
|
2265
2773
|
});
|
|
2266
|
-
|
|
2267
|
-
return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
2268
|
-
`Agent ID: ${id}\n` +
|
|
2269
|
-
`Type: ${displayName}\n` +
|
|
2270
|
-
`Description: ${params.description}\n` +
|
|
2271
|
-
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2272
|
-
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
2273
|
-
`\nYou will be notified when this agent completes.\n` +
|
|
2274
|
-
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
2275
|
-
`Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
|
|
2774
|
+
return backgroundResult(id, record);
|
|
2276
2775
|
}
|
|
2277
2776
|
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
2278
2777
|
let spinnerFrame = 0;
|
|
2279
2778
|
const startedAt = Date.now();
|
|
2280
2779
|
let fgId;
|
|
2780
|
+
// Set once the run is live and can be handed to the background — see
|
|
2781
|
+
// `foregroundHandoffs`. Unset while queued: there is no run to hand over.
|
|
2782
|
+
let handoffId;
|
|
2783
|
+
// Set by the handoff. From then on `onUpdate` is a dead handle (pi drops
|
|
2784
|
+
// updates once `execute` has returned), so `streamUpdate` stops building
|
|
2785
|
+
// them; the tracker keeps `fgState` live for the widget and FleetView.
|
|
2786
|
+
let backgrounded = false;
|
|
2281
2787
|
// Set only while the spawn is parked on a foreground concurrency slot
|
|
2282
2788
|
// (maxConcurrentForeground); undefined the rest of the time, including
|
|
2283
2789
|
// always when the limit is unset.
|
|
2284
2790
|
let queuedAhead;
|
|
2285
2791
|
const streamUpdate = () => {
|
|
2792
|
+
if (backgrounded)
|
|
2793
|
+
return;
|
|
2286
2794
|
// Spend from the record, everything else from the live tracker. `fgId`
|
|
2287
2795
|
// is set in onSessionCreated below, which fires before the first
|
|
2288
2796
|
// assistant message — so nothing is spent while this reads zero.
|
|
@@ -2305,6 +2813,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2305
2813
|
? describeActivity(fgState.activeTools, fgState.responseText)
|
|
2306
2814
|
: `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
|
|
2307
2815
|
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
2816
|
+
// One hint per batch, as in Claude Code: on the earliest call still
|
|
2817
|
+
// pending (a Map iterates in insertion order). The key takes them all.
|
|
2818
|
+
backgroundHint: handoffId !== undefined && foregroundHandoffs.keys().next().value === handoffId,
|
|
2308
2819
|
};
|
|
2309
2820
|
onUpdate?.({
|
|
2310
2821
|
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
@@ -2353,44 +2864,76 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2353
2864
|
streamUpdate();
|
|
2354
2865
|
});
|
|
2355
2866
|
streamUpdate();
|
|
2356
|
-
|
|
2867
|
+
// The background key or the viewer's `b` wakes this with the run already
|
|
2868
|
+
// handed over (`sendForegroundToBackground`), raced against the run
|
|
2869
|
+
// below. The manager only accepts a handoff of a run that has not
|
|
2870
|
+
// settled, and the wake-up resolves synchronously with it, so whichever
|
|
2871
|
+
// promise wins is the truth: the result is never also delivered as a
|
|
2872
|
+
// notification.
|
|
2873
|
+
let wakeBackgrounded;
|
|
2874
|
+
const handedOff = new Promise(resolve => { wakeBackgrounded = () => resolve(undefined); });
|
|
2875
|
+
let fgResult;
|
|
2357
2876
|
try {
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2877
|
+
fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
2878
|
+
description: params.description,
|
|
2879
|
+
name: params.name,
|
|
2880
|
+
model,
|
|
2881
|
+
maxTurns: effectiveMaxTurns,
|
|
2882
|
+
isolated,
|
|
2883
|
+
inheritContext,
|
|
2884
|
+
thinkingLevel: thinking,
|
|
2885
|
+
isolation,
|
|
2886
|
+
invocation: agentInvocation,
|
|
2887
|
+
signal,
|
|
2888
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2889
|
+
// Deliberately does NOT set fgId: that drives agentActivity, the
|
|
2890
|
+
// widget and the `finally` cleanup below, none of which should see an
|
|
2891
|
+
// agent that has no session and may never get one.
|
|
2892
|
+
onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
|
|
2893
|
+
...fgCallbacks,
|
|
2894
|
+
}, (fgAgentId) => {
|
|
2895
|
+
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
2896
|
+
// Set up the output file so streamToOutputFile can pick it up.
|
|
2897
|
+
const fgRec = manager.getRecord(fgAgentId);
|
|
2898
|
+
attachTranscript(fgRec, fgAgentId);
|
|
2899
|
+
// Fires once the run promise exists, i.e. the agent is running. From
|
|
2900
|
+
// here until this call returns, the run can be handed off.
|
|
2901
|
+
handoffId = fgAgentId;
|
|
2902
|
+
foregroundHandoffs.set(fgAgentId, () => {
|
|
2903
|
+
// What a background spawn sets up front: the notification names
|
|
2904
|
+
// this tool call, and it reports alone — its turn's batch is long
|
|
2905
|
+
// closed.
|
|
2906
|
+
if (fgRec) {
|
|
2907
|
+
fgRec.toolCallId = toolCallId;
|
|
2908
|
+
fgRec.joinMode = "async";
|
|
2909
|
+
}
|
|
2910
|
+
backgrounded = true;
|
|
2911
|
+
wakeBackgrounded();
|
|
2912
|
+
});
|
|
2913
|
+
}), handedOff]);
|
|
2382
2914
|
}
|
|
2383
2915
|
finally {
|
|
2384
2916
|
// Runs on both paths, so a startup throw — which now propagates, see
|
|
2385
2917
|
// the background spawn above (#179) — no longer leaves the spinner
|
|
2386
2918
|
// ticking or a finished agent on the widget.
|
|
2387
2919
|
refreshTicker.delete(spinnerOwner);
|
|
2388
|
-
if (
|
|
2920
|
+
if (handoffId !== undefined)
|
|
2921
|
+
foregroundHandoffs.delete(handoffId);
|
|
2922
|
+
// A handed-off run is still working: its row stays, and the
|
|
2923
|
+
// background settle in `onComplete` tears it down when it really ends.
|
|
2924
|
+
if (fgId && !backgrounded) {
|
|
2389
2925
|
agentActivity.delete(fgId);
|
|
2390
2926
|
widget.markFinished(fgId);
|
|
2391
2927
|
fleet.onAgentFinished(fgId);
|
|
2392
2928
|
}
|
|
2393
2929
|
}
|
|
2930
|
+
if (fgResult === undefined) {
|
|
2931
|
+
// Shown as a background agent from here on (the widget's default mode
|
|
2932
|
+
// hides foreground rows).
|
|
2933
|
+
widget.update();
|
|
2934
|
+
return backgroundResult(handoffId, manager.getRecord(handoffId));
|
|
2935
|
+
}
|
|
2936
|
+
const record = fgResult.record;
|
|
2394
2937
|
// Get final token count — from the record, like the cost below it, so the
|
|
2395
2938
|
// two describe the same work when the agent delegated to nested children.
|
|
2396
2939
|
const tokenText = formatLifetimeTokens(record);
|
|
@@ -2516,33 +3059,30 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2516
3059
|
}
|
|
2517
3060
|
/**
|
|
2518
3061
|
* Hand a finished run back to the model through the SAME channel a background
|
|
2519
|
-
* agent uses —
|
|
2520
|
-
*
|
|
3062
|
+
* agent uses — the completion outbox, which delivers at the parent's next
|
|
3063
|
+
* boundary — rendered by the existing `subagent-notification` renderer.
|
|
2521
3064
|
*/
|
|
2522
3065
|
function notifyWorkflowFinished(task) {
|
|
2523
3066
|
widget.update();
|
|
2524
3067
|
fleet.update();
|
|
2525
3068
|
flushPendingUsageIfIdle();
|
|
2526
3069
|
const result = workflowResultText(task);
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
},
|
|
2544
|
-
}, { deliverAs: "followUp", triggerTurn: true });
|
|
2545
|
-
});
|
|
3070
|
+
const message = {
|
|
3071
|
+
content: formatWorkflowNotification(task),
|
|
3072
|
+
details: {
|
|
3073
|
+
id: task.id,
|
|
3074
|
+
description: `Workflow ${task.workflowName ?? task.id}`,
|
|
3075
|
+
status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
|
|
3076
|
+
toolUses: task.totalToolCalls,
|
|
3077
|
+
// A workflow has agents, not turns; rendering "↻0" would be noise.
|
|
3078
|
+
turnCount: 0,
|
|
3079
|
+
totalTokens: task.totalTokens,
|
|
3080
|
+
durationMs: elapsedMs(task, Date.now()),
|
|
3081
|
+
error: task.error,
|
|
3082
|
+
resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
|
|
3083
|
+
},
|
|
3084
|
+
};
|
|
3085
|
+
outbox.add({ ids: [task.id], build: () => message });
|
|
2546
3086
|
}
|
|
2547
3087
|
// Defined unconditionally, registered only when the feature is on — the same
|
|
2548
3088
|
// shape the Agent tool uses. Keeping the definition out of the `if` means the
|
|
@@ -2550,7 +3090,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2550
3090
|
const workflowTool = defineTool({
|
|
2551
3091
|
name: SUBAGENT_TOOL_NAMES.WORKFLOW,
|
|
2552
3092
|
label: "SubagentWorkflow",
|
|
2553
|
-
description: renderToolDescriptionTemplate(
|
|
3093
|
+
description: renderToolDescriptionTemplate(workflowToolDescription(isModelOverrideAllowed())),
|
|
2554
3094
|
promptSnippet: "Run a deterministic script that orchestrates many subagents",
|
|
2555
3095
|
promptGuidelines: [
|
|
2556
3096
|
"Use SubagentWorkflow when the number of agents depends on something discovered at runtime, when work flows through stages, or when findings should be independently verified. Use Agent for one delegated task or a handful you can name up front.",
|
|
@@ -2611,7 +3151,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2611
3151
|
}, theme);
|
|
2612
3152
|
},
|
|
2613
3153
|
execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
2614
|
-
|
|
3154
|
+
// A run from before a restart, `/resume` or `/reload` is found by its
|
|
3155
|
+
// files: its journal and saved script sit in this session's task dir.
|
|
3156
|
+
const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks, (id) => {
|
|
3157
|
+
try {
|
|
3158
|
+
const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
3159
|
+
const journal = join(dir, `${id}.workflow.jsonl`);
|
|
3160
|
+
if (!existsSync(journal))
|
|
3161
|
+
return undefined;
|
|
3162
|
+
const script = join(dir, `${id}.workflow.js`);
|
|
3163
|
+
return { journalPath: journal, ...(existsSync(script) ? { scriptPath: script } : {}) };
|
|
3164
|
+
}
|
|
3165
|
+
catch {
|
|
3166
|
+
return undefined;
|
|
3167
|
+
}
|
|
3168
|
+
});
|
|
2615
3169
|
if (resumeFrom !== undefined && !resumeFrom.ok)
|
|
2616
3170
|
return textResult(resumeFrom.message);
|
|
2617
3171
|
// A resume with no source of its own re-runs what that run ran. The
|
|
@@ -2921,7 +3475,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2921
3475
|
// Mark result as consumed — suppresses the completion notification
|
|
2922
3476
|
if (!active) {
|
|
2923
3477
|
record.resultConsumed = true;
|
|
2924
|
-
|
|
3478
|
+
outbox.cancel(record.id);
|
|
2925
3479
|
}
|
|
2926
3480
|
// Verbose: include full conversation
|
|
2927
3481
|
if (params.verbose && record.session) {
|
|
@@ -3167,18 +3721,19 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3167
3721
|
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
|
|
3168
3722
|
return;
|
|
3169
3723
|
}
|
|
3170
|
-
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
3171
3724
|
const session = record.session;
|
|
3172
3725
|
const activity = agentActivity.get(record.id);
|
|
3726
|
+
let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
|
|
3173
3727
|
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
3728
|
+
overlayOptions = getConversationOverlayOptions(tui);
|
|
3174
3729
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
3175
3730
|
if (manager.abort(record.id)) {
|
|
3176
3731
|
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
3177
3732
|
}
|
|
3178
|
-
}, keybindings, (message) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx));
|
|
3733
|
+
}, keybindings, (message) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx), backgroundActionFor(record, message => ctx.ui.notify(message, "info")), getFullscreenScrollbar());
|
|
3179
3734
|
}, {
|
|
3180
3735
|
overlay: true,
|
|
3181
|
-
overlayOptions:
|
|
3736
|
+
overlayOptions: () => overlayOptions,
|
|
3182
3737
|
});
|
|
3183
3738
|
}
|
|
3184
3739
|
async function showAgentDetail(ctx, name) {
|
|
@@ -3540,6 +4095,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3540
4095
|
backgroundByDefault: getBackgroundByDefault(),
|
|
3541
4096
|
schedulingEnabled: isSchedulingEnabled(),
|
|
3542
4097
|
scopeModels: isScopeModelsEnabled(),
|
|
4098
|
+
allowModelOverride: isModelOverrideAllowed(),
|
|
3543
4099
|
strictAgentFiles,
|
|
3544
4100
|
disableDefaultAgents: isDefaultsDisabled(),
|
|
3545
4101
|
toolDescriptionMode: getToolDescriptionMode(),
|
|
@@ -3567,6 +4123,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3567
4123
|
showCost: isShowCostEnabled(),
|
|
3568
4124
|
showModel: isShowModelEnabled(),
|
|
3569
4125
|
viewerMarkdown: getViewerMarkdown(),
|
|
4126
|
+
// As written: undefined (the default) stays out of the file.
|
|
4127
|
+
backgroundKey: backgroundKeySetting,
|
|
3570
4128
|
};
|
|
3571
4129
|
}
|
|
3572
4130
|
const _settingsSnapshotIsComplete = true;
|
|
@@ -3635,9 +4193,10 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3635
4193
|
{
|
|
3636
4194
|
id: "joinMode",
|
|
3637
4195
|
label: "Join mode",
|
|
3638
|
-
description: "
|
|
3639
|
-
|
|
3640
|
-
|
|
4196
|
+
description: "smart = each completion at the parent's next boundary, as in Claude Code; group = one notification per turn's batch",
|
|
4197
|
+
// `async` is an older name for `smart`; settings files may still hold it.
|
|
4198
|
+
currentValue: getDefaultJoinMode() === "group" ? "group" : "smart",
|
|
4199
|
+
values: ["smart", "group"],
|
|
3641
4200
|
},
|
|
3642
4201
|
{
|
|
3643
4202
|
id: "backgroundByDefault",
|
|
@@ -3668,6 +4227,15 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3668
4227
|
currentValue: isScopeModelsEnabled() ? "on" : "off",
|
|
3669
4228
|
values: ["on", "off"],
|
|
3670
4229
|
},
|
|
4230
|
+
{
|
|
4231
|
+
id: "allowModelOverride",
|
|
4232
|
+
label: "Model override",
|
|
4233
|
+
description: "Let Agent calls, workflows and scheduled jobs pick a subagent's model and thinking level; "
|
|
4234
|
+
+ "off runs every subagent on your current model and thinking level unless its agent file sets one "
|
|
4235
|
+
+ "(the tool parameters change on next pi session)",
|
|
4236
|
+
currentValue: isModelOverrideAllowed() ? "on" : "off",
|
|
4237
|
+
values: ["on", "off"],
|
|
4238
|
+
},
|
|
3671
4239
|
{
|
|
3672
4240
|
id: "strictAgentFiles",
|
|
3673
4241
|
label: "Strict agent files",
|
|
@@ -3861,6 +4429,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3861
4429
|
setScopeModelsEnabled(enabled);
|
|
3862
4430
|
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
3863
4431
|
}
|
|
4432
|
+
else if (id === "allowModelOverride") {
|
|
4433
|
+
const enabled = value === "on";
|
|
4434
|
+
setModelOverrideAllowed(enabled);
|
|
4435
|
+
notifyApplied(ctx, enabled
|
|
4436
|
+
? "Subagent calls may pick a model and thinking level. Tool spec change takes effect on next pi session."
|
|
4437
|
+
: "Subagents run on your current model and thinking level. Tool spec change takes effect on next pi session.");
|
|
4438
|
+
}
|
|
3864
4439
|
else if (id === "strictAgentFiles") {
|
|
3865
4440
|
const enabled = value === "on";
|
|
3866
4441
|
strictAgentFiles = enabled;
|