@kushalkhemka/opensec-pi-subagents 0.20.2 → 0.20.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/CHANGELOG.md +105 -0
- package/README.md +148 -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 +846 -301
- 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 +24 -12
- package/dist/settings.js +10 -5
- package/dist/types.d.ts +24 -16
- package/dist/ui/agent-widget.d.ts +4 -0
- package/dist/ui/agent-widget.js +5 -0
- package/dist/ui/conversation-viewer.d.ts +113 -74
- package/dist/ui/conversation-viewer.js +495 -377
- package/dist/ui/fleet-list.d.ts +35 -28
- package/dist/ui/fleet-list.js +41 -25
- package/dist/ui/viewer-keys.d.ts +10 -1
- package/dist/ui/viewer-keys.js +5 -0
- package/dist/ui/viewer-transcript.d.ts +152 -0
- package/dist/ui/viewer-transcript.js +721 -0
- 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 +830 -292
- 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 +32 -16
- package/src/types.ts +24 -17
- package/src/ui/agent-widget.ts +8 -0
- package/src/ui/conversation-viewer.ts +487 -373
- package/src/ui/fleet-list.ts +39 -24
- package/src/ui/viewer-keys.ts +13 -1
- package/src/ui/viewer-transcript.ts +779 -0
- 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
|
*
|
|
@@ -424,23 +541,13 @@ export default function (pi) {
|
|
|
424
541
|
let showModel = false;
|
|
425
542
|
function isShowModelEnabled() { return showModel; }
|
|
426
543
|
function setShowModel(b) { showModel = b; widget.update(); }
|
|
427
|
-
/**
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
function setViewerMarkdown(mode) { viewerMarkdown = mode; }
|
|
435
|
-
/**
|
|
436
|
-
* The viewer's `m` key, from either entry point: set the mode and persist it,
|
|
437
|
-
* so the key and `/agents → Settings` stay one setting rather than one per
|
|
438
|
-
* entry point. `ctx` carries only the warning a failed write notifies with,
|
|
439
|
-
* and the fleet list may be acting without one.
|
|
440
|
-
*/
|
|
441
|
-
function chooseViewerMarkdown(mode, ctx) {
|
|
442
|
-
setViewerMarkdown(mode);
|
|
443
|
-
persistSettings(ctx, `Viewer markdown set to ${mode}`);
|
|
544
|
+
/** The `backgroundKey` setting as written; undefined means the default. Persisted as written. */
|
|
545
|
+
let backgroundKeySetting;
|
|
546
|
+
function setBackgroundKey(key) { backgroundKeySetting = key; }
|
|
547
|
+
/** The key that hands foreground `Agent` calls to the background, or undefined when it is off. */
|
|
548
|
+
function getBackgroundKey() {
|
|
549
|
+
const key = backgroundKeySetting ?? DEFAULT_BACKGROUND_KEY;
|
|
550
|
+
return key === "off" ? undefined : key;
|
|
444
551
|
}
|
|
445
552
|
const pendingUsage = new PendingUsagePool();
|
|
446
553
|
/**
|
|
@@ -483,82 +590,86 @@ export default function (pi) {
|
|
|
483
590
|
}
|
|
484
591
|
catch { /* stale ctx */ }
|
|
485
592
|
}
|
|
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}` : '';
|
|
593
|
+
// ---- Completion delivery ----
|
|
594
|
+
// Every completion notification goes through the outbox, which sends what is
|
|
595
|
+
// pending as one message at the parent's next boundary (see
|
|
596
|
+
// completion-outbox.ts). Each is built when the agent finishes, and reading
|
|
597
|
+
// the result (get_subagent_result, rpc consume) cancels it before it is sent.
|
|
598
|
+
const outbox = new CompletionOutbox((message) => {
|
|
599
|
+
// A torn-down session cannot take a message (`pi` is stale).
|
|
600
|
+
if (disposed)
|
|
601
|
+
return;
|
|
517
602
|
pi.sendMessage({
|
|
518
603
|
customType: "subagent-notification",
|
|
519
|
-
content:
|
|
604
|
+
content: message.content,
|
|
520
605
|
display: true,
|
|
521
|
-
details:
|
|
522
|
-
}, { deliverAs: "
|
|
606
|
+
details: message.details,
|
|
607
|
+
}, { deliverAs: "steer", triggerTurn: true });
|
|
608
|
+
}, () => parentTurnActive());
|
|
609
|
+
/** Whether the parent has a run in flight, so a `turn_end` is coming. A stale ctx has none. */
|
|
610
|
+
function parentTurnActive() {
|
|
611
|
+
try {
|
|
612
|
+
return currentCtx?.signal !== undefined;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
523
617
|
}
|
|
618
|
+
// A queued result wait must observe completion before its held notification
|
|
619
|
+
// can fire, so successful waits can still suppress that redundant nudge.
|
|
620
|
+
const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
|
|
524
621
|
function sendIndividualNudge(record) {
|
|
525
622
|
agentActivity.delete(record.id);
|
|
526
623
|
widget.markFinished(record.id);
|
|
527
624
|
fleet.onAgentFinished(record.id);
|
|
528
|
-
|
|
625
|
+
// Built now, so a resume before the send cannot rewrite it. A record that
|
|
626
|
+
// cannot be formatted is skipped, as a failed send always was.
|
|
627
|
+
try {
|
|
628
|
+
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
629
|
+
const message = {
|
|
630
|
+
content: formatTaskNotification(record, 500, showCost) + footer,
|
|
631
|
+
details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
|
|
632
|
+
};
|
|
633
|
+
outbox.add({ ids: [record.id], build: () => message });
|
|
634
|
+
}
|
|
635
|
+
catch { /* ignore stale completion side-effect errors */ }
|
|
529
636
|
widget.update();
|
|
530
637
|
}
|
|
531
|
-
// ---- Group join manager ----
|
|
638
|
+
// ---- Group join manager (`group` join mode only) ----
|
|
532
639
|
const groupJoin = new GroupJoinManager((records, partial) => {
|
|
533
640
|
for (const r of records) {
|
|
534
641
|
agentActivity.delete(r.id);
|
|
535
642
|
widget.markFinished(r.id);
|
|
536
643
|
fleet.onAgentFinished(r.id);
|
|
537
644
|
}
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const unconsumed = records.filter(r => !r.resultConsumed);
|
|
542
|
-
if (unconsumed.length === 0) {
|
|
543
|
-
widget.update();
|
|
544
|
-
return;
|
|
645
|
+
const members = records.filter(r => !r.resultConsumed).flatMap(r => {
|
|
646
|
+
try {
|
|
647
|
+
return [{ id: r.id, text: formatTaskNotification(r, 300, showCost), details: buildNotificationDetails(r, 300, agentActivity.get(r.id)) }];
|
|
545
648
|
}
|
|
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)));
|
|
649
|
+
catch {
|
|
650
|
+
return []; /* as in sendIndividualNudge */
|
|
554
651
|
}
|
|
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
652
|
});
|
|
653
|
+
if (members.length > 0) {
|
|
654
|
+
outbox.add({
|
|
655
|
+
ids: members.map(m => m.id),
|
|
656
|
+
build: (ids) => {
|
|
657
|
+
const unread = members.filter(m => ids.includes(m.id));
|
|
658
|
+
const notifications = unread.map(m => m.text).join('\n\n');
|
|
659
|
+
const label = partial
|
|
660
|
+
? `${unread.length} agent(s) finished (partial — others still running)`
|
|
661
|
+
: `${unread.length} agent(s) finished`;
|
|
662
|
+
const [first, ...rest] = unread;
|
|
663
|
+
const details = first.details;
|
|
664
|
+
if (rest.length > 0)
|
|
665
|
+
details.others = rest.map(m => m.details);
|
|
666
|
+
return {
|
|
667
|
+
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
668
|
+
details,
|
|
669
|
+
};
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
}
|
|
562
673
|
widget.update();
|
|
563
674
|
}, 30_000);
|
|
564
675
|
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
@@ -598,38 +709,86 @@ export default function (pi) {
|
|
|
598
709
|
usage,
|
|
599
710
|
};
|
|
600
711
|
}
|
|
601
|
-
// Background completion: route through group join or send individual nudge
|
|
602
712
|
/**
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
* Guarded: it also runs while the session is shutting down.
|
|
713
|
+
* The session this activation belongs to: its session file, or undefined for
|
|
714
|
+
* one kept in memory. Agents spawned here carry it as `ownerSession`; a
|
|
715
|
+
* background agent that outlives a session switch (session-handoff.ts)
|
|
716
|
+
* reports to its owner, not to whichever session is open.
|
|
608
717
|
*/
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
718
|
+
let currentSession;
|
|
719
|
+
const ownedHere = (record) => record.ownerSession === currentSession;
|
|
720
|
+
/**
|
|
721
|
+
* Write a custom entry into the session `owner`: through `pi` while that is
|
|
722
|
+
* the session this instance has open (Pi keeps it in memory), otherwise by
|
|
723
|
+
* appending to the owner's file, which Pi reads when it is opened again.
|
|
724
|
+
* Guarded: it also runs while the session is shutting down, and after.
|
|
725
|
+
*/
|
|
726
|
+
function writeEntry(owner, customType, data) {
|
|
727
|
+
if (owner === currentSession) {
|
|
728
|
+
try {
|
|
729
|
+
pi.appendEntry(customType, data);
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
catch { /* torn down: the session is no longer open, so its file is safe to append to */ }
|
|
617
733
|
}
|
|
618
|
-
|
|
734
|
+
return appendToSessionFile(owner, customType, data);
|
|
735
|
+
}
|
|
736
|
+
// Background completion: route through group join or send individual nudge
|
|
737
|
+
/**
|
|
738
|
+
* Write a top-level agent's final record into the session it belongs to.
|
|
739
|
+
* `resume` carries what reopening it needs (session file, handle, model,
|
|
740
|
+
* directory, worktree, isolation, turn limit); `session_start` reads it back,
|
|
741
|
+
* so the agent stays resumable after a restart, `/resume`, `/fork` or
|
|
742
|
+
* `/reload`. `pending` marks a result its session has not been told about
|
|
743
|
+
* yet (it finished while another session was open); that session delivers it
|
|
744
|
+
* when it starts.
|
|
745
|
+
*/
|
|
746
|
+
function appendRecordEntry(record, pending = false) {
|
|
747
|
+
writeEntry(record.ownerSession, RECORD_ENTRY_TYPE, recordEntryData(record, pending));
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Write an owned child's resume state (a nested agent's, a workflow's) into
|
|
751
|
+
* the session it belongs to, so `session_start` can rebuild it after a
|
|
752
|
+
* restart, `/resume`, `/fork` or `/reload`: its owner can then reopen it from
|
|
753
|
+
* its session file — the parent agent's nested tools, or a workflow's
|
|
754
|
+
* `agent({ resume })`. Only the resume state, never the result: the owner
|
|
755
|
+
* already has that, and the entry stays small. Nothing for an agent without
|
|
756
|
+
* a session file.
|
|
757
|
+
*/
|
|
758
|
+
function appendResumeEntry(record) {
|
|
759
|
+
const data = resumeEntryData(record);
|
|
760
|
+
if (data)
|
|
761
|
+
writeEntry(record.ownerSession, RESUME_ENTRY_TYPE, data);
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* A top-level agent of another session finished while this one is open: its
|
|
765
|
+
* result belongs to that session. Record it there, marked for delivery; no
|
|
766
|
+
* notification, event or row here.
|
|
767
|
+
*/
|
|
768
|
+
function completeForeignAgent(record) {
|
|
769
|
+
agentActivity.delete(record.id);
|
|
770
|
+
appendRecordEntry(record, !record.resultConsumed);
|
|
619
771
|
}
|
|
620
|
-
|
|
772
|
+
function onAgentComplete(record) {
|
|
621
773
|
// After shutdown `pi` is stale, and the shutdown handler records the
|
|
622
774
|
// agents it interrupted itself.
|
|
623
775
|
if (disposed)
|
|
624
776
|
return;
|
|
625
|
-
// Spend that landed after the parent's last subagent tool result.
|
|
626
|
-
flushPendingUsageIfIdle();
|
|
627
777
|
// Owned children — nested, or a workflow's — report only through their
|
|
628
778
|
// owner: the parent's scoped tools, or the workflow's card, notification
|
|
629
779
|
// and dialog. Keep them out of top-level lifecycle, transcript,
|
|
630
|
-
// notification, and UI channels.
|
|
631
|
-
if (!isTopLevelAgent(record))
|
|
780
|
+
// notification, and UI channels. Their resume state is still recorded.
|
|
781
|
+
if (!isTopLevelAgent(record)) {
|
|
782
|
+
flushPendingUsageIfIdle();
|
|
783
|
+
appendResumeEntry(record);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (!ownedHere(record)) {
|
|
787
|
+
completeForeignAgent(record);
|
|
632
788
|
return;
|
|
789
|
+
}
|
|
790
|
+
// Spend that landed after the parent's last subagent tool result.
|
|
791
|
+
flushPendingUsageIfIdle();
|
|
633
792
|
// Emit lifecycle event based on terminal status
|
|
634
793
|
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
635
794
|
const eventData = buildEventData(record);
|
|
@@ -650,9 +809,9 @@ export default function (pi) {
|
|
|
650
809
|
widget.update();
|
|
651
810
|
return;
|
|
652
811
|
}
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
if (
|
|
812
|
+
// A `group` spawn whose turn has not ended yet: its group is registered at
|
|
813
|
+
// that turn's end, which picks this completion up retroactively.
|
|
814
|
+
if (turnGroupSpawns.includes(record.id)) {
|
|
656
815
|
widget.update();
|
|
657
816
|
return;
|
|
658
817
|
}
|
|
@@ -663,8 +822,9 @@ export default function (pi) {
|
|
|
663
822
|
// 'held' → do nothing, group will fire later
|
|
664
823
|
// 'delivered' → group callback already fired
|
|
665
824
|
widget.update();
|
|
666
|
-
}
|
|
667
|
-
|
|
825
|
+
}
|
|
826
|
+
function onAgentStart(record) {
|
|
827
|
+
if (disposed || !isTopLevelAgent(record) || !ownedHere(record))
|
|
668
828
|
return;
|
|
669
829
|
// Agent-tool spawns refresh these surfaces in their tool handler, but RPC
|
|
670
830
|
// and scheduler spawns enter through the manager directly.
|
|
@@ -680,8 +840,9 @@ export default function (pi) {
|
|
|
680
840
|
type: record.type,
|
|
681
841
|
description: record.description,
|
|
682
842
|
});
|
|
683
|
-
}
|
|
684
|
-
|
|
843
|
+
}
|
|
844
|
+
function onAgentCompact(record, info) {
|
|
845
|
+
if (disposed || !isTopLevelAgent(record) || !ownedHere(record))
|
|
685
846
|
return;
|
|
686
847
|
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
687
848
|
pi.events.emit("subagents:compacted", {
|
|
@@ -692,14 +853,28 @@ export default function (pi) {
|
|
|
692
853
|
tokensBefore: info.tokensBefore,
|
|
693
854
|
compactionCount: record.compactionCount,
|
|
694
855
|
});
|
|
695
|
-
}
|
|
856
|
+
}
|
|
857
|
+
function onAgentUsage(record, usage) {
|
|
696
858
|
// Every assistant message from every agent — nested included, exactly once.
|
|
697
859
|
// Parked here until a tool result can carry it back to the parent session;
|
|
698
860
|
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no
|
|
699
|
-
// pool grows in a session that will never drain it.
|
|
700
|
-
|
|
861
|
+
// pool grows in a session that will never drain it. Another session's
|
|
862
|
+
// agents bill that session, not this one.
|
|
863
|
+
if (reportUsage && ownedHere(record))
|
|
701
864
|
pendingUsage.add(usage);
|
|
702
|
-
}
|
|
865
|
+
}
|
|
866
|
+
const managerObservers = {
|
|
867
|
+
onComplete: onAgentComplete,
|
|
868
|
+
onStart: onAgentStart,
|
|
869
|
+
onCompact: onAgentCompact,
|
|
870
|
+
onUsage: onAgentUsage,
|
|
871
|
+
};
|
|
872
|
+
/**
|
|
873
|
+
* Replaced at `session_start` by a manager the previous session parked, so
|
|
874
|
+
* its background agents keep running (see session-handoff.ts). Everything
|
|
875
|
+
* reads it through this binding, never a copy.
|
|
876
|
+
*/
|
|
877
|
+
let manager = new AgentManager(onAgentComplete, undefined, onAgentStart, onAgentCompact, onAgentUsage);
|
|
703
878
|
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
704
879
|
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
|
705
880
|
// Documented for callers in docs/rpc.md ("The manager registry").
|
|
@@ -770,6 +945,14 @@ export default function (pi) {
|
|
|
770
945
|
delete safeOptions.reclaim;
|
|
771
946
|
// Internal to a reopen, like `reclaim`: it seeds an agent's lifetime figures.
|
|
772
947
|
delete safeOptions.carry;
|
|
948
|
+
// A caller's model and thinking level are per-call overrides, which
|
|
949
|
+
// `allowModelOverride` (off by default) refuses on every spawn path: the
|
|
950
|
+
// agent then runs on the parent's current model and level, or its agent
|
|
951
|
+
// file's.
|
|
952
|
+
if (!isModelOverrideAllowed()) {
|
|
953
|
+
delete safeOptions.model;
|
|
954
|
+
delete safeOptions.thinkingLevel;
|
|
955
|
+
}
|
|
773
956
|
// Every spawn through here is DETACHED — the caller gets an id back and
|
|
774
957
|
// awaits nothing. A forged `blocking` would charge it to the foreground
|
|
775
958
|
// pool and could defer it behind a queue whose gate nobody is holding.
|
|
@@ -785,8 +968,9 @@ export default function (pi) {
|
|
|
785
968
|
*/
|
|
786
969
|
const resolveAgentRef = (ref) => {
|
|
787
970
|
const byId = manager.getRecord(ref);
|
|
971
|
+
// Another session's agent is not this session's to read or steer.
|
|
788
972
|
if (byId)
|
|
789
|
-
return byId;
|
|
973
|
+
return manager.isVisible(byId) ? byId : undefined;
|
|
790
974
|
const resolved = manager.resolveMention(ref);
|
|
791
975
|
return resolved?.kind === "live" ? resolved.record : undefined;
|
|
792
976
|
};
|
|
@@ -796,7 +980,7 @@ export default function (pi) {
|
|
|
796
980
|
spawn: spawnTopLevel,
|
|
797
981
|
getRecord: (id) => {
|
|
798
982
|
const record = manager.getRecord(id);
|
|
799
|
-
return record !== undefined && isTopLevelAgent(record) ? record : undefined;
|
|
983
|
+
return record !== undefined && isTopLevelAgent(record) && manager.isVisible(record) ? record : undefined;
|
|
800
984
|
},
|
|
801
985
|
};
|
|
802
986
|
const ownsManagerRegistry = globalThis[MANAGER_KEY] === undefined;
|
|
@@ -842,17 +1026,26 @@ export default function (pi) {
|
|
|
842
1026
|
pi.on("session_start", async (_event, ctx) => {
|
|
843
1027
|
disposed = false;
|
|
844
1028
|
currentCtx = ctx;
|
|
1029
|
+
currentSession = sessionFileOf(ctx);
|
|
1030
|
+
// First: everything below works on `manager`, which this may replace.
|
|
1031
|
+
adoptParkedAgents();
|
|
1032
|
+
manager.setVisibleOwner(currentSession);
|
|
845
1033
|
syncProjectTrust(ctx);
|
|
846
1034
|
void tidyAgentWorktrees(ctx);
|
|
847
1035
|
if (ctx.hasUI) {
|
|
848
1036
|
widget.setUICtx(ctx.ui);
|
|
849
1037
|
fleet.setUICtx(ctx.ui);
|
|
1038
|
+
watchHandoffKey(ctx.ui);
|
|
850
1039
|
}
|
|
851
1040
|
manager.clearCompleted(true);
|
|
852
1041
|
// Agents of this session from before a restart, `/resume`, `/fork` or
|
|
853
1042
|
// `/reload`: resumable again, by the model and by `@handle`. The branch,
|
|
854
1043
|
// not every entry: a fork keeps only the agents on its own path.
|
|
855
|
-
|
|
1044
|
+
const branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
1045
|
+
manager.restoreTombstones(resumeStatesFromEntries(branch));
|
|
1046
|
+
// Results of this session's agents that finished while another session
|
|
1047
|
+
// was open, recorded here for delivery now.
|
|
1048
|
+
deliverPendingResults(branch);
|
|
856
1049
|
// Guard mirrors the `!scheduler.isActive()` pattern below: session_start
|
|
857
1050
|
// fires once per activation, but a double-bind must not leak listeners.
|
|
858
1051
|
if (!rpcHandle) {
|
|
@@ -863,7 +1056,10 @@ export default function (pi) {
|
|
|
863
1056
|
manager: {
|
|
864
1057
|
spawn: spawnTopLevel,
|
|
865
1058
|
awaitStartup: (id) => manager.awaitStartup(id),
|
|
866
|
-
getRecord: (id) =>
|
|
1059
|
+
getRecord: (id) => {
|
|
1060
|
+
const record = manager.getRecord(id);
|
|
1061
|
+
return record && manager.isVisible(record) ? record : undefined;
|
|
1062
|
+
},
|
|
867
1063
|
// Unguarded on purpose: the stop handler now runs the top-level check
|
|
868
1064
|
// itself off `getRecord`, and reports the refusal instead of the
|
|
869
1065
|
// "Agent not found" a false from here used to be read as.
|
|
@@ -880,7 +1076,7 @@ export default function (pi) {
|
|
|
880
1076
|
if (manager.isRunActive(record.id))
|
|
881
1077
|
return false;
|
|
882
1078
|
record.resultConsumed = true;
|
|
883
|
-
|
|
1079
|
+
outbox.cancel(record.id);
|
|
884
1080
|
return true;
|
|
885
1081
|
},
|
|
886
1082
|
},
|
|
@@ -1143,9 +1339,130 @@ export default function (pi) {
|
|
|
1143
1339
|
// it returns. Pi confirms replacement by emitting session_shutdown; keep
|
|
1144
1340
|
// the current schedules and agent records intact until then.
|
|
1145
1341
|
});
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1342
|
+
/**
|
|
1343
|
+
* Take over the manager the previous session parked at its switch, if it was
|
|
1344
|
+
* parked for this session by this same version: its background agents keep
|
|
1345
|
+
* running and show here when they are this session's. Another version cannot
|
|
1346
|
+
* share the manager's code, so it stops its agents itself, as a quit would.
|
|
1347
|
+
* See session-handoff.ts.
|
|
1348
|
+
*/
|
|
1349
|
+
function adoptParkedAgents() {
|
|
1350
|
+
const parked = takeParkedSubagents(currentSession);
|
|
1351
|
+
if (!parked)
|
|
1352
|
+
return;
|
|
1353
|
+
if (!isCompatible(parked)) {
|
|
1354
|
+
void parked.stop(writeEntry).catch(() => { });
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
const adopted = parked.manager;
|
|
1358
|
+
const fresh = manager;
|
|
1359
|
+
// New work runs with this instance's modules (agent registry, settings).
|
|
1360
|
+
adopted.useServices(managerServices());
|
|
1361
|
+
adopted.setObservers(managerObservers);
|
|
1362
|
+
adopted.setMaxConcurrent(fresh.getMaxConcurrent());
|
|
1363
|
+
adopted.setMaxConcurrentForeground(fresh.getMaxConcurrentForeground());
|
|
1364
|
+
manager = adopted;
|
|
1365
|
+
widget.setManager(adopted);
|
|
1366
|
+
fleet.setManager(adopted);
|
|
1367
|
+
for (const [id, state] of parked.activity) {
|
|
1368
|
+
if (!agentActivity.has(id))
|
|
1369
|
+
agentActivity.set(id, state);
|
|
1370
|
+
}
|
|
1371
|
+
void fresh.dispose();
|
|
1372
|
+
// Runs that settled between the two instances, handled as if just now —
|
|
1373
|
+
// once this start has finished setting up.
|
|
1374
|
+
const settled = parked.completed;
|
|
1375
|
+
if (settled.length > 0)
|
|
1376
|
+
setTimeout(() => { for (const record of settled)
|
|
1377
|
+
onAgentComplete(record); }, 0);
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* At a session switch: leave the background agents running in a manager
|
|
1381
|
+
* parked for the next instance (session-handoff.ts). Nothing parked here
|
|
1382
|
+
* may touch this instance's `pi` or ctx, which Pi invalidates next: until
|
|
1383
|
+
* adopted, completions are queued in the entry.
|
|
1384
|
+
*/
|
|
1385
|
+
function parkAgents(reason, targetSessionFile) {
|
|
1386
|
+
const parkedManager = manager;
|
|
1387
|
+
const entry = {
|
|
1388
|
+
protocol: HANDOFF_PROTOCOL,
|
|
1389
|
+
version: PACKAGE_VERSION,
|
|
1390
|
+
reason,
|
|
1391
|
+
sessionFile: currentSession,
|
|
1392
|
+
targetSessionFile,
|
|
1393
|
+
manager: parkedManager,
|
|
1394
|
+
activity: new Map(agentActivity),
|
|
1395
|
+
completed: [],
|
|
1396
|
+
stop: (write) => stopParkedAgents(parkedManager, entry, write),
|
|
1397
|
+
};
|
|
1398
|
+
parkedManager.setObservers({ onComplete: (record) => { entry.completed.push(record); } });
|
|
1399
|
+
parkedManager.detachPendingContexts();
|
|
1400
|
+
parkedManager.setVisibleOwner(null);
|
|
1401
|
+
parkSubagents(entry, () => recordParkedAgentsAtExit(parkedManager, entry));
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Notify this session of its agents that finished while another session was
|
|
1405
|
+
* open (`pending` record entries), once, after this start: a turn follows,
|
|
1406
|
+
* as for any background completion. Marked delivered at once, so a second
|
|
1407
|
+
* start does not repeat it.
|
|
1408
|
+
*/
|
|
1409
|
+
function deliverPendingResults(branch) {
|
|
1410
|
+
const pending = pendingResultsFromEntries(branch);
|
|
1411
|
+
if (pending.length === 0)
|
|
1412
|
+
return;
|
|
1413
|
+
const ids = pending.map(r => r.id);
|
|
1414
|
+
try {
|
|
1415
|
+
pi.appendEntry(DELIVERED_ENTRY_TYPE, { ids });
|
|
1416
|
+
}
|
|
1417
|
+
catch {
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
// The record still in memory carries more (context, compactions) than its entry.
|
|
1421
|
+
const records = pending.map(entry => {
|
|
1422
|
+
const live = manager.getRecord(entry.id);
|
|
1423
|
+
return live && manager.isVisible(live) ? live : entry;
|
|
1424
|
+
});
|
|
1425
|
+
// Through the completion outbox, like every other completion: delivered at
|
|
1426
|
+
// this session's next boundary (a turn when idle), coalesced with anything
|
|
1427
|
+
// else that finished, and cancelled per agent by get_subagent_result.
|
|
1428
|
+
// An entry is all there is of an evicted agent, and get_subagent_result
|
|
1429
|
+
// cannot fetch more, so its whole result goes in.
|
|
1430
|
+
const maxLen = (r) => (manager.getRecord(r.id) === r ? 500 : 20_000);
|
|
1431
|
+
for (const r of records) {
|
|
1432
|
+
agentActivity.delete(r.id);
|
|
1433
|
+
widget.markFinished(r.id);
|
|
1434
|
+
fleet.onAgentFinished(r.id);
|
|
1435
|
+
}
|
|
1436
|
+
const parts = records.flatMap(r => {
|
|
1437
|
+
try {
|
|
1438
|
+
return [{ id: r.id, text: formatTaskNotification(r, maxLen(r), showCost), details: buildNotificationDetails(r, 300) }];
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
return []; /* as in sendIndividualNudge */
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
if (parts.length > 0) {
|
|
1445
|
+
outbox.add({
|
|
1446
|
+
ids: parts.map(p => p.id),
|
|
1447
|
+
build: (unreadIds) => {
|
|
1448
|
+
const unread = parts.filter(p => unreadIds.includes(p.id));
|
|
1449
|
+
const [first, ...rest] = unread;
|
|
1450
|
+
const details = { ...first.details };
|
|
1451
|
+
if (rest.length > 0)
|
|
1452
|
+
details.others = rest.map(p => p.details);
|
|
1453
|
+
const label = unread.length === 1 ? "An agent" : `${unread.length} agents`;
|
|
1454
|
+
return {
|
|
1455
|
+
content: `${label} of this session finished while another session was open.\n\n${unread.map(p => p.text).join("\n\n")}`,
|
|
1456
|
+
details,
|
|
1457
|
+
};
|
|
1458
|
+
},
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
widget.update();
|
|
1462
|
+
}
|
|
1463
|
+
// On shutdown: on a session switch, background agents keep running (parked
|
|
1464
|
+
// for the next instance); on quit, every agent is stopped and recorded.
|
|
1465
|
+
pi.on("session_shutdown", async (event) => {
|
|
1149
1466
|
// Spend no tool result carried back yet: record it while the session can.
|
|
1150
1467
|
flushPendingUsage();
|
|
1151
1468
|
disposed = true;
|
|
@@ -1171,26 +1488,73 @@ export default function (pi) {
|
|
|
1171
1488
|
for (const task of workflowTasks.values())
|
|
1172
1489
|
task.abortController.abort();
|
|
1173
1490
|
workflowTasks.clear();
|
|
1174
|
-
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
|
|
1178
|
-
|
|
1491
|
+
const live = manager.listAllAgents().filter(r => r.status === "running" || r.status === "queued");
|
|
1492
|
+
// Pi says why (Pi >= 1.0): `new`, `resume`, `fork` and `reload` replace the
|
|
1493
|
+
// session within this process; `quit` (also /quit and SIGTERM/SIGHUP) or no
|
|
1494
|
+
// reason ends it. On a switch, a background agent whose session is on disk
|
|
1495
|
+
// keeps running — its results can still be recorded there — along with
|
|
1496
|
+
// the agents it owns. Foreground agents (Pi aborted their tool call before
|
|
1497
|
+
// this), workflows and agents of a session kept in memory stop as before.
|
|
1498
|
+
const { reason, targetSessionFile } = (event ?? {});
|
|
1499
|
+
// The next instance finds the parked manager by the session it starts, so
|
|
1500
|
+
// a switch must name it (Pi 1.0 does; `/reload` keeps this one).
|
|
1501
|
+
const switching = isSessionSwitch(reason)
|
|
1502
|
+
&& (reason === "reload" ? currentSession !== undefined : typeof targetSessionFile === "string");
|
|
1503
|
+
const kept = new Set(switching
|
|
1504
|
+
? live.filter(r => isTopLevelAgent(r) && r.isBackground !== false && isSessionOnDisk(r.ownerSession)).map(r => r.id)
|
|
1505
|
+
: []);
|
|
1506
|
+
const rootOf = (record) => {
|
|
1507
|
+
let current = record;
|
|
1508
|
+
for (let parent = current.parentAgentId; parent !== undefined; parent = current.parentAgentId) {
|
|
1509
|
+
const next = manager.getRecord(parent);
|
|
1510
|
+
if (!next)
|
|
1511
|
+
break;
|
|
1512
|
+
current = next;
|
|
1513
|
+
}
|
|
1514
|
+
return current.id;
|
|
1515
|
+
};
|
|
1516
|
+
const interrupted = live.filter(r => !kept.has(rootOf(r)));
|
|
1517
|
+
// Also worth keeping: another session's agents that finished while this
|
|
1518
|
+
// one was open, so their session shows them in full when it is reopened.
|
|
1519
|
+
const carried = switching && manager.listAllAgents().some(r => isTopLevelAgent(r) && !ownedHere(r) && !r.resultConsumed && !manager.isRunActive(r.id) && isSessionOnDisk(r.ownerSession));
|
|
1520
|
+
const parking = kept.size > 0 || carried;
|
|
1521
|
+
if (kept.size > 0) {
|
|
1522
|
+
for (const record of interrupted)
|
|
1523
|
+
manager.abort(record.id);
|
|
1524
|
+
}
|
|
1525
|
+
else {
|
|
1526
|
+
manager.abortAll();
|
|
1527
|
+
}
|
|
1179
1528
|
// 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
|
-
|
|
1529
|
+
// settles; dispose() does not wait for that, and the process may exit next.
|
|
1530
|
+
await manager.awaitWorktreeCleanup(15_000, kept.size > 0 ? new Set(interrupted.map(r => r.id)) : undefined);
|
|
1531
|
+
// Each recorded as stopped and resumable, in the session it belongs to.
|
|
1532
|
+
for (const record of interrupted) {
|
|
1533
|
+
if (isTopLevelAgent(record))
|
|
1534
|
+
appendRecordEntry(record);
|
|
1535
|
+
else
|
|
1536
|
+
appendResumeEntry(record);
|
|
1537
|
+
}
|
|
1538
|
+
if (parking)
|
|
1539
|
+
parkAgents(reason, targetSessionFile);
|
|
1540
|
+
// Completions still waiting for this session's next boundary (a busy or
|
|
1541
|
+
// interrupted parent): marked pending in the session they belong to, so
|
|
1542
|
+
// its next start (a switch back, a restart) delivers them like any result
|
|
1543
|
+
// that landed while another session was open.
|
|
1544
|
+
for (const id of outbox.pendingIds()) {
|
|
1545
|
+
const record = manager.getRecord(id);
|
|
1546
|
+
if (record && isTopLevelAgent(record) && !record.resultConsumed && !manager.isRunActive(id))
|
|
1547
|
+
appendRecordEntry(record, true);
|
|
1548
|
+
}
|
|
1187
1549
|
// Neither may fire into a torn-down instance (or hold `pi -p` open).
|
|
1550
|
+
outbox.dispose();
|
|
1188
1551
|
groupJoin.dispose();
|
|
1189
|
-
|
|
1190
|
-
clearTimeout(batchFinalizeTimer);
|
|
1191
|
-
batchFinalizeTimer = undefined;
|
|
1192
|
-
currentBatchAgents = [];
|
|
1552
|
+
turnGroupSpawns = [];
|
|
1193
1553
|
fleet.dispose();
|
|
1554
|
+
unsubHandoffKey?.();
|
|
1555
|
+
unsubHandoffKey = undefined;
|
|
1556
|
+
handoffKeyUi = undefined;
|
|
1557
|
+
foregroundHandoffs.clear();
|
|
1194
1558
|
// Its 80 ms refresh interval otherwise outlives the session across /reload.
|
|
1195
1559
|
// The interval is cleared first; the UI calls after it may meet a context
|
|
1196
1560
|
// that is already gone.
|
|
@@ -1199,6 +1563,9 @@ export default function (pi) {
|
|
|
1199
1563
|
}
|
|
1200
1564
|
catch { /* stale UI context at shutdown */ }
|
|
1201
1565
|
refreshTicker.clear();
|
|
1566
|
+
// A parked manager lives on, with its agents' sessions.
|
|
1567
|
+
if (parking)
|
|
1568
|
+
return;
|
|
1202
1569
|
// Awaited: it emits `session_shutdown` into every retained child session so
|
|
1203
1570
|
// extensions bound there can release what they armed in `session_start` (#242).
|
|
1204
1571
|
// pi awaits this handler, and the process exits right after — unawaited, those
|
|
@@ -1217,10 +1584,120 @@ export default function (pi) {
|
|
|
1217
1584
|
const refreshTicker = new RefreshTicker();
|
|
1218
1585
|
const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled, refreshTicker);
|
|
1219
1586
|
function setWidgetMode(m) { widgetMode = m; widget.update(); }
|
|
1587
|
+
/**
|
|
1588
|
+
* Foreground `Agent` calls whose run can be handed to the background, by
|
|
1589
|
+
* agent id. Each entry wakes its blocked tool call, which then returns as a
|
|
1590
|
+
* background spawn would. Only the Agent tool registers here, so a spawn with
|
|
1591
|
+
* no tool call to return — the agent generator, a workflow's agents, nested
|
|
1592
|
+
* children — never offers the handoff.
|
|
1593
|
+
*/
|
|
1594
|
+
const foregroundHandoffs = new Map();
|
|
1595
|
+
/** Hand these runs to the background and wake their tool calls; returns how many took. */
|
|
1596
|
+
function sendForegroundToBackground(ids) {
|
|
1597
|
+
let sent = 0;
|
|
1598
|
+
for (const id of [...ids]) {
|
|
1599
|
+
const wake = foregroundHandoffs.get(id);
|
|
1600
|
+
if (!wake || !manager.sendToBackground(id))
|
|
1601
|
+
continue;
|
|
1602
|
+
foregroundHandoffs.delete(id);
|
|
1603
|
+
wake();
|
|
1604
|
+
sent++;
|
|
1605
|
+
// It is a background agent from here on, so announce it as one — as a
|
|
1606
|
+
// detached resume re-announces an existing id.
|
|
1607
|
+
const record = manager.getRecord(id);
|
|
1608
|
+
if (record) {
|
|
1609
|
+
pi.events.emit("subagents:created", {
|
|
1610
|
+
id,
|
|
1611
|
+
type: record.type,
|
|
1612
|
+
description: record.description,
|
|
1613
|
+
isBackground: true,
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
if (sent > 0) {
|
|
1618
|
+
widget.ensureTimer();
|
|
1619
|
+
widget.update();
|
|
1620
|
+
fleet.ensureTimer();
|
|
1621
|
+
fleet.update();
|
|
1622
|
+
}
|
|
1623
|
+
return sent;
|
|
1624
|
+
}
|
|
1625
|
+
/** The conversation viewer's `b`: hand just this run to the background. */
|
|
1626
|
+
function backgroundActionFor(record, notify) {
|
|
1627
|
+
return {
|
|
1628
|
+
// Registered while the run is live, removed the moment its call returns.
|
|
1629
|
+
available: () => foregroundHandoffs.has(record.id),
|
|
1630
|
+
send: () => {
|
|
1631
|
+
if (sendForegroundToBackground([record.id]) > 0)
|
|
1632
|
+
notify(`Sent "${record.description}" to the background.`);
|
|
1633
|
+
},
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
// Claude Code's ctrl+b (`task:background`), configurable as `backgroundKey`:
|
|
1637
|
+
// hand every running foreground `Agent` call to the background. Claimed only
|
|
1638
|
+
// while one is pending and the prompt editor has focus, so the rest of the
|
|
1639
|
+
// time the key keeps its editor meaning (ctrl+b is pi's cursor-left).
|
|
1640
|
+
let handoffKeyUi;
|
|
1641
|
+
let unsubHandoffKey;
|
|
1642
|
+
function watchHandoffKey(ui) {
|
|
1643
|
+
if (ui === handoffKeyUi)
|
|
1644
|
+
return;
|
|
1645
|
+
unsubHandoffKey?.();
|
|
1646
|
+
handoffKeyUi = ui;
|
|
1647
|
+
unsubHandoffKey = ui.onTerminalInput(data => {
|
|
1648
|
+
// Cheapest checks first: this listener sees every key the user types.
|
|
1649
|
+
if (foregroundHandoffs.size === 0)
|
|
1650
|
+
return undefined;
|
|
1651
|
+
const key = getBackgroundKey();
|
|
1652
|
+
// Kitty reports a release too, and matchesKey matches it — act on the
|
|
1653
|
+
// press only, or a refused handoff would be retried by its own release.
|
|
1654
|
+
if (key === undefined || isKeyRelease(data) || !matchesKey(data, key))
|
|
1655
|
+
return undefined;
|
|
1656
|
+
// Listeners run before the focused component, so a dialog or the
|
|
1657
|
+
// viewer's composer would lose the key. The viewer has its own `b`.
|
|
1658
|
+
if (!fleet.editorHasFocus())
|
|
1659
|
+
return undefined;
|
|
1660
|
+
const sent = sendForegroundToBackground(foregroundHandoffs.keys());
|
|
1661
|
+
if (sent > 0)
|
|
1662
|
+
ui.notify(`Sent ${sent === 1 ? "1 agent" : `${sent} agents`} to the background.`, "info");
|
|
1663
|
+
return { consume: true };
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Pi's fullscreen scrollbar preference, read from the main session's live,
|
|
1668
|
+
* trust-aware settings and normalized as Pi's own `getFullscreenScrollbar()` does.
|
|
1669
|
+
*/
|
|
1670
|
+
function getFullscreenScrollbar() {
|
|
1671
|
+
try {
|
|
1672
|
+
const mode = pi.getSettings().fullscreenScrollbar;
|
|
1673
|
+
return mode === "always" || mode === "hidden" ? mode : "auto";
|
|
1674
|
+
}
|
|
1675
|
+
catch {
|
|
1676
|
+
return "auto";
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Pi's chat settings the conversation viewer draws with, read from the main
|
|
1681
|
+
* session's live settings and normalized as Pi's SettingsManager getters do,
|
|
1682
|
+
* so a subagent's transcript looks like the main one.
|
|
1683
|
+
*/
|
|
1684
|
+
function getViewerRenderOptions() {
|
|
1685
|
+
try {
|
|
1686
|
+
const settings = pi.getSettings();
|
|
1687
|
+
return {
|
|
1688
|
+
hideThinkingBlock: settings.hideThinkingBlock ?? false,
|
|
1689
|
+
outputPad: settings.outputPad === 0 ? 0 : 1,
|
|
1690
|
+
codeBlockIndent: settings.markdown?.codeBlockIndent ?? " ",
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
catch {
|
|
1694
|
+
return {};
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1220
1697
|
// Claude Code-style FleetView: navigable list of main + subagents below the editor.
|
|
1221
|
-
// The
|
|
1222
|
-
//
|
|
1223
|
-
const fleet = new FleetList(manager, agentActivity, isShowCostEnabled,
|
|
1698
|
+
// The render options keep a conversation overlay opened here identical to one
|
|
1699
|
+
// opened from `/agents`.
|
|
1700
|
+
const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerRenderOptions, 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,
|
|
@@ -1534,7 +2015,7 @@ export default function (pi) {
|
|
|
1534
2015
|
setReportUsage,
|
|
1535
2016
|
setShowCost,
|
|
1536
2017
|
setShowModel,
|
|
1537
|
-
|
|
2018
|
+
setBackgroundKey,
|
|
1538
2019
|
};
|
|
1539
2020
|
applyAndEmitLoaded(settingsAppliers, (event, payload) => pi.events.emit(event, payload), process.cwd(), projectTrusted);
|
|
1540
2021
|
/**
|
|
@@ -1609,6 +2090,22 @@ export default function (pi) {
|
|
|
1609
2090
|
const isolationGuideline = isWorktreeIsolationEnabled()
|
|
1610
2091
|
? `\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
2092
|
: "";
|
|
2093
|
+
// Same trade again, for `allowModelOverride`: with overrides off the `model`
|
|
2094
|
+
// and `thinking` parameters are not in the schema, so the prose that teaches
|
|
2095
|
+
// them goes too — a model told it may pick a model will keep trying.
|
|
2096
|
+
const modelOverrideAllowed = isModelOverrideAllowed();
|
|
2097
|
+
const modelGuideline = modelOverrideAllowed
|
|
2098
|
+
? `- 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`
|
|
2099
|
+
: "";
|
|
2100
|
+
const modelParamShape = {
|
|
2101
|
+
model: Type.Optional(Type.String({
|
|
2102
|
+
description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
2103
|
+
})),
|
|
2104
|
+
thinking: Type.Optional(Type.String({
|
|
2105
|
+
description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
|
|
2106
|
+
})),
|
|
2107
|
+
};
|
|
2108
|
+
const modelParams = modelOverrideAllowed ? modelParamShape : {};
|
|
1612
2109
|
const isolationCompactGuideline = isWorktreeIsolationEnabled()
|
|
1613
2110
|
? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
|
|
1614
2111
|
: "";
|
|
@@ -1652,9 +2149,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
1652
2149
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
1653
2150
|
- 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
2151
|
- 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}
|
|
2152
|
+
${modelGuideline}- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
|
|
1658
2153
|
|
|
1659
2154
|
## Writing the prompt
|
|
1660
2155
|
|
|
@@ -1679,6 +2174,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1679
2174
|
agentDir: getAgentDir,
|
|
1680
2175
|
isolationGuideline: () => isolationGuideline,
|
|
1681
2176
|
scheduleGuideline: () => scheduleGuideline,
|
|
2177
|
+
modelGuideline: () => modelGuideline,
|
|
1682
2178
|
};
|
|
1683
2179
|
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
1684
2180
|
return template.replace(/\{\{(\w+)\}\}/g, (raw, name) => {
|
|
@@ -1746,12 +2242,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1746
2242
|
subagent_type: Type.String({
|
|
1747
2243
|
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
2244
|
}),
|
|
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
|
-
})),
|
|
2245
|
+
...modelParams,
|
|
1755
2246
|
max_turns: Type.Optional(Type.Number({
|
|
1756
2247
|
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
1757
2248
|
minimum: 1,
|
|
@@ -1826,7 +2317,14 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1826
2317
|
if (isPartial || details.status === "running") {
|
|
1827
2318
|
const frame = SPINNER[details.spinnerFrame ?? 0];
|
|
1828
2319
|
const s = stats(details);
|
|
1829
|
-
|
|
2320
|
+
const status = renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
|
|
2321
|
+
// Claude Code's hint, after the same 2s: a call that finishes quickly
|
|
2322
|
+
// never flashes it.
|
|
2323
|
+
const backgroundKey = getBackgroundKey();
|
|
2324
|
+
if (details.backgroundHint && backgroundKey && details.durationMs >= BACKGROUND_HINT_DELAY_MS) {
|
|
2325
|
+
status.addChild(new Text(theme.fg("dim", ` (${backgroundKey} to run in background)`), 0, 0));
|
|
2326
|
+
}
|
|
2327
|
+
return status;
|
|
1830
2328
|
}
|
|
1831
2329
|
// ---- Background agent launched ----
|
|
1832
2330
|
if (details.status === "background") {
|
|
@@ -2066,7 +2564,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2066
2564
|
// at fire time, and the original is what a user edits.
|
|
2067
2565
|
subagent_type: requestedType,
|
|
2068
2566
|
prompt: params.prompt,
|
|
2069
|
-
|
|
2567
|
+
// Only when overrides are on: a job keeps the call's model choice
|
|
2568
|
+
// for every later fire, which is exactly what the setting refuses.
|
|
2569
|
+
model: isModelOverrideAllowed() ? params.model : undefined,
|
|
2070
2570
|
thinking: thinking,
|
|
2071
2571
|
max_turns: effectiveMaxTurns,
|
|
2072
2572
|
isolated: isolated,
|
|
@@ -2193,6 +2693,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2193
2693
|
(record.result?.trim() || "No output.") +
|
|
2194
2694
|
agentIdLine(record.id), buildDetails(detailBaseFor(record), record));
|
|
2195
2695
|
}
|
|
2696
|
+
// What the model sees for a run it no longer waits on — spawned in the
|
|
2697
|
+
// background, or handed there mid-run (background key / viewer `b`). One
|
|
2698
|
+
// text for both, as in Claude Code: from here on they are the same kind of agent.
|
|
2699
|
+
const backgroundResult = (id, record) => {
|
|
2700
|
+
const isQueued = record?.status === "queued";
|
|
2701
|
+
return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
2702
|
+
`Agent ID: ${id}\n` +
|
|
2703
|
+
`Type: ${displayName}\n` +
|
|
2704
|
+
`Description: ${params.description}\n` +
|
|
2705
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2706
|
+
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
2707
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
2708
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
2709
|
+
`Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
|
|
2710
|
+
};
|
|
2196
2711
|
// Background execution
|
|
2197
2712
|
if (runInBackground) {
|
|
2198
2713
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
@@ -2200,12 +2715,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2200
2715
|
// The callback lazily reads record.outputFile (set right after spawn)
|
|
2201
2716
|
// rather than closing over a value that doesn't exist yet.
|
|
2202
2717
|
let id;
|
|
2718
|
+
// Read now: a queued agent may start after a session switch, when this
|
|
2719
|
+
// ctx is stale and every read of it throws.
|
|
2720
|
+
const bgCwd = ctx.cwd;
|
|
2203
2721
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
2204
2722
|
bgCallbacks.onSessionCreated = (session) => {
|
|
2205
2723
|
origBgOnSession(session);
|
|
2206
2724
|
const rec = manager.getRecord(id);
|
|
2207
2725
|
if (rec?.outputFile) {
|
|
2208
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id,
|
|
2726
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, bgCwd);
|
|
2209
2727
|
}
|
|
2210
2728
|
};
|
|
2211
2729
|
// A throw here means the agent never started. Let it out: pi marks a
|
|
@@ -2239,18 +2757,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2239
2757
|
// wiring above, so a strict-isolation failure still fails THIS tool
|
|
2240
2758
|
// call instead of being reported as a subagent that ran (#179).
|
|
2241
2759
|
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
|
-
}
|
|
2760
|
+
joinTurnGroup(id, joinMode);
|
|
2254
2761
|
agentActivity.set(id, bgState);
|
|
2255
2762
|
widget.ensureTimer();
|
|
2256
2763
|
widget.update();
|
|
@@ -2263,26 +2770,26 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2263
2770
|
description: params.description,
|
|
2264
2771
|
isBackground: true,
|
|
2265
2772
|
});
|
|
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 });
|
|
2773
|
+
return backgroundResult(id, record);
|
|
2276
2774
|
}
|
|
2277
2775
|
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
2278
2776
|
let spinnerFrame = 0;
|
|
2279
2777
|
const startedAt = Date.now();
|
|
2280
2778
|
let fgId;
|
|
2779
|
+
// Set once the run is live and can be handed to the background — see
|
|
2780
|
+
// `foregroundHandoffs`. Unset while queued: there is no run to hand over.
|
|
2781
|
+
let handoffId;
|
|
2782
|
+
// Set by the handoff. From then on `onUpdate` is a dead handle (pi drops
|
|
2783
|
+
// updates once `execute` has returned), so `streamUpdate` stops building
|
|
2784
|
+
// them; the tracker keeps `fgState` live for the widget and FleetView.
|
|
2785
|
+
let backgrounded = false;
|
|
2281
2786
|
// Set only while the spawn is parked on a foreground concurrency slot
|
|
2282
2787
|
// (maxConcurrentForeground); undefined the rest of the time, including
|
|
2283
2788
|
// always when the limit is unset.
|
|
2284
2789
|
let queuedAhead;
|
|
2285
2790
|
const streamUpdate = () => {
|
|
2791
|
+
if (backgrounded)
|
|
2792
|
+
return;
|
|
2286
2793
|
// Spend from the record, everything else from the live tracker. `fgId`
|
|
2287
2794
|
// is set in onSessionCreated below, which fires before the first
|
|
2288
2795
|
// assistant message — so nothing is spent while this reads zero.
|
|
@@ -2305,6 +2812,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2305
2812
|
? describeActivity(fgState.activeTools, fgState.responseText)
|
|
2306
2813
|
: `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
|
|
2307
2814
|
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
2815
|
+
// One hint per batch, as in Claude Code: on the earliest call still
|
|
2816
|
+
// pending (a Map iterates in insertion order). The key takes them all.
|
|
2817
|
+
backgroundHint: handoffId !== undefined && foregroundHandoffs.keys().next().value === handoffId,
|
|
2308
2818
|
};
|
|
2309
2819
|
onUpdate?.({
|
|
2310
2820
|
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
@@ -2353,44 +2863,76 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2353
2863
|
streamUpdate();
|
|
2354
2864
|
});
|
|
2355
2865
|
streamUpdate();
|
|
2356
|
-
|
|
2866
|
+
// The background key or the viewer's `b` wakes this with the run already
|
|
2867
|
+
// handed over (`sendForegroundToBackground`), raced against the run
|
|
2868
|
+
// below. The manager only accepts a handoff of a run that has not
|
|
2869
|
+
// settled, and the wake-up resolves synchronously with it, so whichever
|
|
2870
|
+
// promise wins is the truth: the result is never also delivered as a
|
|
2871
|
+
// notification.
|
|
2872
|
+
let wakeBackgrounded;
|
|
2873
|
+
const handedOff = new Promise(resolve => { wakeBackgrounded = () => resolve(undefined); });
|
|
2874
|
+
let fgResult;
|
|
2357
2875
|
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
|
-
|
|
2876
|
+
fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
2877
|
+
description: params.description,
|
|
2878
|
+
name: params.name,
|
|
2879
|
+
model,
|
|
2880
|
+
maxTurns: effectiveMaxTurns,
|
|
2881
|
+
isolated,
|
|
2882
|
+
inheritContext,
|
|
2883
|
+
thinkingLevel: thinking,
|
|
2884
|
+
isolation,
|
|
2885
|
+
invocation: agentInvocation,
|
|
2886
|
+
signal,
|
|
2887
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2888
|
+
// Deliberately does NOT set fgId: that drives agentActivity, the
|
|
2889
|
+
// widget and the `finally` cleanup below, none of which should see an
|
|
2890
|
+
// agent that has no session and may never get one.
|
|
2891
|
+
onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
|
|
2892
|
+
...fgCallbacks,
|
|
2893
|
+
}, (fgAgentId) => {
|
|
2894
|
+
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
2895
|
+
// Set up the output file so streamToOutputFile can pick it up.
|
|
2896
|
+
const fgRec = manager.getRecord(fgAgentId);
|
|
2897
|
+
attachTranscript(fgRec, fgAgentId);
|
|
2898
|
+
// Fires once the run promise exists, i.e. the agent is running. From
|
|
2899
|
+
// here until this call returns, the run can be handed off.
|
|
2900
|
+
handoffId = fgAgentId;
|
|
2901
|
+
foregroundHandoffs.set(fgAgentId, () => {
|
|
2902
|
+
// What a background spawn sets up front: the notification names
|
|
2903
|
+
// this tool call, and it reports alone — its turn's batch is long
|
|
2904
|
+
// closed.
|
|
2905
|
+
if (fgRec) {
|
|
2906
|
+
fgRec.toolCallId = toolCallId;
|
|
2907
|
+
fgRec.joinMode = "async";
|
|
2908
|
+
}
|
|
2909
|
+
backgrounded = true;
|
|
2910
|
+
wakeBackgrounded();
|
|
2911
|
+
});
|
|
2912
|
+
}), handedOff]);
|
|
2382
2913
|
}
|
|
2383
2914
|
finally {
|
|
2384
2915
|
// Runs on both paths, so a startup throw — which now propagates, see
|
|
2385
2916
|
// the background spawn above (#179) — no longer leaves the spinner
|
|
2386
2917
|
// ticking or a finished agent on the widget.
|
|
2387
2918
|
refreshTicker.delete(spinnerOwner);
|
|
2388
|
-
if (
|
|
2919
|
+
if (handoffId !== undefined)
|
|
2920
|
+
foregroundHandoffs.delete(handoffId);
|
|
2921
|
+
// A handed-off run is still working: its row stays, and the
|
|
2922
|
+
// background settle in `onComplete` tears it down when it really ends.
|
|
2923
|
+
if (fgId && !backgrounded) {
|
|
2389
2924
|
agentActivity.delete(fgId);
|
|
2390
2925
|
widget.markFinished(fgId);
|
|
2391
2926
|
fleet.onAgentFinished(fgId);
|
|
2392
2927
|
}
|
|
2393
2928
|
}
|
|
2929
|
+
if (fgResult === undefined) {
|
|
2930
|
+
// Shown as a background agent from here on (the widget's default mode
|
|
2931
|
+
// hides foreground rows).
|
|
2932
|
+
widget.update();
|
|
2933
|
+
return backgroundResult(handoffId, manager.getRecord(handoffId));
|
|
2934
|
+
}
|
|
2935
|
+
const record = fgResult.record;
|
|
2394
2936
|
// Get final token count — from the record, like the cost below it, so the
|
|
2395
2937
|
// two describe the same work when the agent delegated to nested children.
|
|
2396
2938
|
const tokenText = formatLifetimeTokens(record);
|
|
@@ -2516,33 +3058,30 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2516
3058
|
}
|
|
2517
3059
|
/**
|
|
2518
3060
|
* Hand a finished run back to the model through the SAME channel a background
|
|
2519
|
-
* agent uses —
|
|
2520
|
-
*
|
|
3061
|
+
* agent uses — the completion outbox, which delivers at the parent's next
|
|
3062
|
+
* boundary — rendered by the existing `subagent-notification` renderer.
|
|
2521
3063
|
*/
|
|
2522
3064
|
function notifyWorkflowFinished(task) {
|
|
2523
3065
|
widget.update();
|
|
2524
3066
|
fleet.update();
|
|
2525
3067
|
flushPendingUsageIfIdle();
|
|
2526
3068
|
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
|
-
});
|
|
3069
|
+
const message = {
|
|
3070
|
+
content: formatWorkflowNotification(task),
|
|
3071
|
+
details: {
|
|
3072
|
+
id: task.id,
|
|
3073
|
+
description: `Workflow ${task.workflowName ?? task.id}`,
|
|
3074
|
+
status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
|
|
3075
|
+
toolUses: task.totalToolCalls,
|
|
3076
|
+
// A workflow has agents, not turns; rendering "↻0" would be noise.
|
|
3077
|
+
turnCount: 0,
|
|
3078
|
+
totalTokens: task.totalTokens,
|
|
3079
|
+
durationMs: elapsedMs(task, Date.now()),
|
|
3080
|
+
error: task.error,
|
|
3081
|
+
resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
|
|
3082
|
+
},
|
|
3083
|
+
};
|
|
3084
|
+
outbox.add({ ids: [task.id], build: () => message });
|
|
2546
3085
|
}
|
|
2547
3086
|
// Defined unconditionally, registered only when the feature is on — the same
|
|
2548
3087
|
// shape the Agent tool uses. Keeping the definition out of the `if` means the
|
|
@@ -2550,7 +3089,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2550
3089
|
const workflowTool = defineTool({
|
|
2551
3090
|
name: SUBAGENT_TOOL_NAMES.WORKFLOW,
|
|
2552
3091
|
label: "SubagentWorkflow",
|
|
2553
|
-
description: renderToolDescriptionTemplate(
|
|
3092
|
+
description: renderToolDescriptionTemplate(workflowToolDescription(isModelOverrideAllowed())),
|
|
2554
3093
|
promptSnippet: "Run a deterministic script that orchestrates many subagents",
|
|
2555
3094
|
promptGuidelines: [
|
|
2556
3095
|
"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 +3150,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2611
3150
|
}, theme);
|
|
2612
3151
|
},
|
|
2613
3152
|
execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
2614
|
-
|
|
3153
|
+
// A run from before a restart, `/resume` or `/reload` is found by its
|
|
3154
|
+
// files: its journal and saved script sit in this session's task dir.
|
|
3155
|
+
const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks, (id) => {
|
|
3156
|
+
try {
|
|
3157
|
+
const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
3158
|
+
const journal = join(dir, `${id}.workflow.jsonl`);
|
|
3159
|
+
if (!existsSync(journal))
|
|
3160
|
+
return undefined;
|
|
3161
|
+
const script = join(dir, `${id}.workflow.js`);
|
|
3162
|
+
return { journalPath: journal, ...(existsSync(script) ? { scriptPath: script } : {}) };
|
|
3163
|
+
}
|
|
3164
|
+
catch {
|
|
3165
|
+
return undefined;
|
|
3166
|
+
}
|
|
3167
|
+
});
|
|
2615
3168
|
if (resumeFrom !== undefined && !resumeFrom.ok)
|
|
2616
3169
|
return textResult(resumeFrom.message);
|
|
2617
3170
|
// A resume with no source of its own re-runs what that run ran. The
|
|
@@ -2921,7 +3474,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2921
3474
|
// Mark result as consumed — suppresses the completion notification
|
|
2922
3475
|
if (!active) {
|
|
2923
3476
|
record.resultConsumed = true;
|
|
2924
|
-
|
|
3477
|
+
outbox.cancel(record.id);
|
|
2925
3478
|
}
|
|
2926
3479
|
// Verbose: include full conversation
|
|
2927
3480
|
if (params.verbose && record.session) {
|
|
@@ -3167,18 +3720,19 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3167
3720
|
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
|
|
3168
3721
|
return;
|
|
3169
3722
|
}
|
|
3170
|
-
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
3171
3723
|
const session = record.session;
|
|
3172
3724
|
const activity = agentActivity.get(record.id);
|
|
3725
|
+
let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
|
|
3173
3726
|
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
3727
|
+
overlayOptions = getConversationOverlayOptions(tui);
|
|
3174
3728
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
3175
3729
|
if (manager.abort(record.id)) {
|
|
3176
3730
|
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
3177
3731
|
}
|
|
3178
|
-
}, keybindings, (message) => manager.steer(record.id, message), showCost,
|
|
3732
|
+
}, keybindings, (message) => manager.steer(record.id, message), showCost, backgroundActionFor(record, message => ctx.ui.notify(message, "info")), getFullscreenScrollbar(), getViewerRenderOptions());
|
|
3179
3733
|
}, {
|
|
3180
3734
|
overlay: true,
|
|
3181
|
-
overlayOptions:
|
|
3735
|
+
overlayOptions: () => overlayOptions,
|
|
3182
3736
|
});
|
|
3183
3737
|
}
|
|
3184
3738
|
async function showAgentDetail(ctx, name) {
|
|
@@ -3540,6 +4094,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3540
4094
|
backgroundByDefault: getBackgroundByDefault(),
|
|
3541
4095
|
schedulingEnabled: isSchedulingEnabled(),
|
|
3542
4096
|
scopeModels: isScopeModelsEnabled(),
|
|
4097
|
+
allowModelOverride: isModelOverrideAllowed(),
|
|
3543
4098
|
strictAgentFiles,
|
|
3544
4099
|
disableDefaultAgents: isDefaultsDisabled(),
|
|
3545
4100
|
toolDescriptionMode: getToolDescriptionMode(),
|
|
@@ -3566,7 +4121,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3566
4121
|
reportUsage: isReportUsageEnabled(),
|
|
3567
4122
|
showCost: isShowCostEnabled(),
|
|
3568
4123
|
showModel: isShowModelEnabled(),
|
|
3569
|
-
|
|
4124
|
+
// As written: undefined (the default) stays out of the file.
|
|
4125
|
+
backgroundKey: backgroundKeySetting,
|
|
3570
4126
|
};
|
|
3571
4127
|
}
|
|
3572
4128
|
const _settingsSnapshotIsComplete = true;
|
|
@@ -3635,9 +4191,10 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3635
4191
|
{
|
|
3636
4192
|
id: "joinMode",
|
|
3637
4193
|
label: "Join mode",
|
|
3638
|
-
description: "
|
|
3639
|
-
|
|
3640
|
-
|
|
4194
|
+
description: "smart = each completion at the parent's next boundary, as in Claude Code; group = one notification per turn's batch",
|
|
4195
|
+
// `async` is an older name for `smart`; settings files may still hold it.
|
|
4196
|
+
currentValue: getDefaultJoinMode() === "group" ? "group" : "smart",
|
|
4197
|
+
values: ["smart", "group"],
|
|
3641
4198
|
},
|
|
3642
4199
|
{
|
|
3643
4200
|
id: "backgroundByDefault",
|
|
@@ -3668,6 +4225,15 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3668
4225
|
currentValue: isScopeModelsEnabled() ? "on" : "off",
|
|
3669
4226
|
values: ["on", "off"],
|
|
3670
4227
|
},
|
|
4228
|
+
{
|
|
4229
|
+
id: "allowModelOverride",
|
|
4230
|
+
label: "Model override",
|
|
4231
|
+
description: "Let Agent calls, workflows and scheduled jobs pick a subagent's model and thinking level; "
|
|
4232
|
+
+ "off runs every subagent on your current model and thinking level unless its agent file sets one "
|
|
4233
|
+
+ "(the tool parameters change on next pi session)",
|
|
4234
|
+
currentValue: isModelOverrideAllowed() ? "on" : "off",
|
|
4235
|
+
values: ["on", "off"],
|
|
4236
|
+
},
|
|
3671
4237
|
{
|
|
3672
4238
|
id: "strictAgentFiles",
|
|
3673
4239
|
label: "Strict agent files",
|
|
@@ -3724,13 +4290,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3724
4290
|
currentValue: isShowModelEnabled() ? "on" : "off",
|
|
3725
4291
|
values: ["on", "off"],
|
|
3726
4292
|
},
|
|
3727
|
-
{
|
|
3728
|
-
id: "viewerMarkdown",
|
|
3729
|
-
label: "Viewer markdown",
|
|
3730
|
-
description: "How much of the conversation viewer renders as Markdown. assistant = assistant text only (default); all = tool results too, for tools that emit Markdown — accepting that a Markdown pass over a diff or a log eats `#` comments, swallows a `---` line and re-fences indented output; off = everything verbatim. `m` in the viewer cycles the same setting (footer: raw / md / md+).",
|
|
3731
|
-
currentValue: getViewerMarkdown(),
|
|
3732
|
-
values: ["off", "assistant", "all"],
|
|
3733
|
-
},
|
|
3734
4293
|
{
|
|
3735
4294
|
id: "fleetView",
|
|
3736
4295
|
label: "Fleet view",
|
|
@@ -3861,6 +4420,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3861
4420
|
setScopeModelsEnabled(enabled);
|
|
3862
4421
|
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
3863
4422
|
}
|
|
4423
|
+
else if (id === "allowModelOverride") {
|
|
4424
|
+
const enabled = value === "on";
|
|
4425
|
+
setModelOverrideAllowed(enabled);
|
|
4426
|
+
notifyApplied(ctx, enabled
|
|
4427
|
+
? "Subagent calls may pick a model and thinking level. Tool spec change takes effect on next pi session."
|
|
4428
|
+
: "Subagents run on your current model and thinking level. Tool spec change takes effect on next pi session.");
|
|
4429
|
+
}
|
|
3864
4430
|
else if (id === "strictAgentFiles") {
|
|
3865
4431
|
const enabled = value === "on";
|
|
3866
4432
|
strictAgentFiles = enabled;
|
|
@@ -3910,10 +4476,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3910
4476
|
setShowModel(enabled);
|
|
3911
4477
|
notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
|
|
3912
4478
|
}
|
|
3913
|
-
else if (id === "viewerMarkdown") {
|
|
3914
|
-
setViewerMarkdown(value);
|
|
3915
|
-
notifyApplied(ctx, `Viewer markdown set to ${value}`);
|
|
3916
|
-
}
|
|
3917
4479
|
else if (id === "fleetView") {
|
|
3918
4480
|
const enabled = value === "on";
|
|
3919
4481
|
setFleetViewEnabled(enabled);
|
|
@@ -4015,23 +4577,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4015
4577
|
// the right toast. Successful saves show info; persistence failures downgrade
|
|
4016
4578
|
// to warning so users aren't silently reverted on restart. Event fires regardless
|
|
4017
4579
|
// of outcome so listeners see the in-memory change.
|
|
4018
|
-
/**
|
|
4019
|
-
* Persist + broadcast the settings, silent on success — for a change whose
|
|
4020
|
-
* feedback is the UI it just changed: the viewer's `m` key, where a
|
|
4021
|
-
* notification per press would talk over the overlay it is describing.
|
|
4022
|
-
*
|
|
4023
|
-
* A *failed* write still speaks. Every other settings path warns when the
|
|
4024
|
-
* value is session-only, and swallowing it here would leave a preference
|
|
4025
|
-
* looking persisted when the next session will not have it.
|
|
4026
|
-
*/
|
|
4027
|
-
function persistSettings(ctx, changeMsg) {
|
|
4028
|
-
const { message, level } = saveAndEmitChanged(snapshotSettings(), changeMsg, (event, payload) => pi.events.emit(event, payload));
|
|
4029
|
-
// `ctx` is absent only on the fleet path between sessions, where
|
|
4030
|
-
// `currentCtx` has been cleared and there is no UI to carry the warning to.
|
|
4031
|
-
// The write still happens.
|
|
4032
|
-
if (level === "warning")
|
|
4033
|
-
ctx?.ui.notify(message, level);
|
|
4034
|
-
}
|
|
4035
4580
|
function notifyApplied(ctx, successMsg) {
|
|
4036
4581
|
const { message, level } = saveAndEmitChanged(snapshotSettings(), successMsg, (event, payload) => pi.events.emit(event, payload));
|
|
4037
4582
|
ctx.ui.notify(message, level);
|