@signalridge/pi-subagents 1.11.1 → 1.13.0
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 +27 -0
- package/README.md +77 -10
- package/package.json +3 -3
- package/src/agent-color.ts +1 -1
- package/src/agent-manager.ts +395 -66
- package/src/agent-runner.ts +83 -35
- package/src/extension-resource-names.ts +105 -0
- package/src/index.ts +221 -69
- package/src/output-file.ts +28 -9
- package/src/parent-questions.ts +453 -0
- package/src/question-origin.ts +24 -0
- package/src/supervisor.ts +28 -8
- package/src/types.ts +4 -3
- package/src/ui/agent-display.ts +2 -1
- package/src/ui/conversation-viewer.ts +641 -290
- package/src/ui/fleet-list.ts +258 -66
- package/src/ui/owner-group.ts +110 -0
- package/src/ui/schedule-menu.ts +14 -7
- package/src/ui/workflow-ui-client.ts +189 -0
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
Spacer,
|
|
38
38
|
Text,
|
|
39
39
|
} from "@earendil-works/pi-tui";
|
|
40
|
+
import { parseOwnedConversationOpenRequest, replyChannel, WORKFLOW_UI_CHANNELS } from "@signalridge/pi-subagents-protocol";
|
|
40
41
|
import { wrapCustomUi } from "@signalridge/pi-ui";
|
|
41
42
|
import { Type } from "@sinclair/typebox";
|
|
42
43
|
import { abortable } from "./abortable.js";
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
import {
|
|
58
59
|
AgentManager,
|
|
59
60
|
MANAGED_SPAWN_ENTRY_TYPE,
|
|
61
|
+
type ManagedSpawnCallbacks,
|
|
60
62
|
type ManagedSpawnEntryLike,
|
|
61
63
|
type ManagedSpawnPolicy,
|
|
62
64
|
type ManagedSpawnRequest,
|
|
@@ -165,7 +167,9 @@ import {
|
|
|
165
167
|
streamToOutputFile,
|
|
166
168
|
writeInitialEntry,
|
|
167
169
|
} from "./output-file.js";
|
|
170
|
+
import { registerParentQuestions } from "./parent-questions.js";
|
|
168
171
|
import { captureProjectTrust, isProjectResource } from "./project-trust.js";
|
|
172
|
+
import { captureQuestionOrigin } from "./question-origin.js";
|
|
169
173
|
import { SubagentScheduler } from "./schedule.js";
|
|
170
174
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
171
175
|
import {
|
|
@@ -211,11 +215,8 @@ import {
|
|
|
211
215
|
type Theme,
|
|
212
216
|
} from "./ui/agent-display.js";
|
|
213
217
|
import { createMentionProvider, mentionRoster } from "./ui/agent-mention.js";
|
|
214
|
-
import {
|
|
215
|
-
|
|
216
|
-
VIEWPORT_HEIGHT_PCT,
|
|
217
|
-
} from "./ui/conversation-viewer.js";
|
|
218
|
-
import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
|
|
218
|
+
import { getConversationOverlayOptions } from "./ui/conversation-viewer.js";
|
|
219
|
+
import { type AgentSelection, captureAgentSelection, FleetList, type FleetUICtx } from "./ui/fleet-list.js";
|
|
219
220
|
import {
|
|
220
221
|
safeTerminalText,
|
|
221
222
|
sanitizeDisplayText,
|
|
@@ -228,6 +229,7 @@ import {
|
|
|
228
229
|
getAgentStatusLabel,
|
|
229
230
|
getAgentStatusMark,
|
|
230
231
|
} from "./ui/status-label.js";
|
|
232
|
+
import { WorkflowUiClient } from "./ui/workflow-ui-client.js";
|
|
231
233
|
import {
|
|
232
234
|
addUsage,
|
|
233
235
|
getLifetimeTotal,
|
|
@@ -809,6 +811,12 @@ function activateRootRuntime(
|
|
|
809
811
|
// tokens is omitted when nothing was ever produced (e.g. agent errored before
|
|
810
812
|
// any message_end fired), preserving prior payload shape.
|
|
811
813
|
const u = record.lifetimeUsage;
|
|
814
|
+
const managedUsage = record.owner ? manager.getManagedUsage(record.id, record.owner) : undefined;
|
|
815
|
+
const managedTokens = managedUsage ? {
|
|
816
|
+
input: managedUsage.input,
|
|
817
|
+
output: managedUsage.output,
|
|
818
|
+
total: getLifetimeTotal(managedUsage),
|
|
819
|
+
} : undefined;
|
|
812
820
|
const total = getLifetimeTotal(u);
|
|
813
821
|
const tokens =
|
|
814
822
|
total > 0 ? { input: u.input, output: u.output, total } : undefined;
|
|
@@ -822,6 +830,7 @@ function activateRootRuntime(
|
|
|
822
830
|
toolUses: record.toolUses,
|
|
823
831
|
durationMs,
|
|
824
832
|
tokens,
|
|
833
|
+
...(managedTokens ? { managedTokens: { ...managedTokens } } : {}),
|
|
825
834
|
...(record.outputFile ? { outputFile: record.outputFile } : {}),
|
|
826
835
|
...(record.invocation?.agentTier === undefined
|
|
827
836
|
? {}
|
|
@@ -917,13 +926,25 @@ function activateRootRuntime(
|
|
|
917
926
|
string,
|
|
918
927
|
{ runs: number; usage: LifetimeUsage }
|
|
919
928
|
>();
|
|
929
|
+
const accountedUsage = new WeakMap<AgentRecord, { usage: LifetimeUsage; completion: string; promise: AgentRecord["promise"] }>();
|
|
920
930
|
function recordSessionUsage(record: AgentRecord): void {
|
|
931
|
+
const previous = accountedUsage.get(record);
|
|
932
|
+
const completion = JSON.stringify([record.owner, record.startedAt, record.completedAt]);
|
|
933
|
+
if (previous?.completion === completion && previous.promise === record.promise) return;
|
|
934
|
+
const usage = {
|
|
935
|
+
input: Math.max(0, record.lifetimeUsage.input - (previous?.usage.input ?? 0)),
|
|
936
|
+
output: Math.max(0, record.lifetimeUsage.output - (previous?.usage.output ?? 0)),
|
|
937
|
+
cacheWrite: Math.max(0, record.lifetimeUsage.cacheWrite - (previous?.usage.cacheWrite ?? 0)),
|
|
938
|
+
};
|
|
939
|
+
// Records survive native resumes; lifetime totals must be credited only once.
|
|
940
|
+
// Weak ownership releases bookkeeping when the manager evicts the record.
|
|
941
|
+
accountedUsage.set(record, { usage: { ...record.lifetimeUsage }, completion, promise: record.promise });
|
|
921
942
|
const entry = sessionUsageByType.get(record.type) ?? {
|
|
922
943
|
runs: 0,
|
|
923
944
|
usage: { input: 0, output: 0, cacheWrite: 0 },
|
|
924
945
|
};
|
|
925
946
|
entry.runs++;
|
|
926
|
-
addUsage(entry.usage,
|
|
947
|
+
addUsage(entry.usage, usage);
|
|
927
948
|
sessionUsageByType.set(record.type, entry);
|
|
928
949
|
}
|
|
929
950
|
|
|
@@ -933,6 +954,14 @@ function activateRootRuntime(
|
|
|
933
954
|
// Nested children report only through their owning parent's scoped tools.
|
|
934
955
|
// Keep them out of top-level lifecycle, transcript, notification, and UI channels.
|
|
935
956
|
if (record.parentAgentId || record.detached) return;
|
|
957
|
+
// Native thread resumes do not pass the fresh-spawn stream finalizer.
|
|
958
|
+
// Flush and retire this owned attempt's UI subscription before publishing
|
|
959
|
+
// its terminal payload; the next admission installs a new anchored stream.
|
|
960
|
+
if (record.owner) {
|
|
961
|
+
const cleanup = record.outputCleanup;
|
|
962
|
+
record.outputCleanup = undefined;
|
|
963
|
+
cleanup?.();
|
|
964
|
+
}
|
|
936
965
|
|
|
937
966
|
// Emit lifecycle event based on terminal status
|
|
938
967
|
const isError =
|
|
@@ -1056,6 +1085,10 @@ function activateRootRuntime(
|
|
|
1056
1085
|
append: (tombstone) =>
|
|
1057
1086
|
pi.appendEntry(MANAGED_SPAWN_ENTRY_TYPE, tombstone),
|
|
1058
1087
|
},
|
|
1088
|
+
(record) => {
|
|
1089
|
+
record.joinMode = "async";
|
|
1090
|
+
refreshAgentSurfaces();
|
|
1091
|
+
},
|
|
1059
1092
|
);
|
|
1060
1093
|
|
|
1061
1094
|
// Expose the validated root manager via Symbol.for() for cross-package access.
|
|
@@ -1194,6 +1227,28 @@ function activateRootRuntime(
|
|
|
1194
1227
|
// same session. Invalidate pending off-screen mention turns only when the
|
|
1195
1228
|
// replacement commits: before-switch/tree handlers can still cancel it.
|
|
1196
1229
|
let sessionGeneration = 0;
|
|
1230
|
+
const parentQuestions = registerParentQuestions(pi, () => currentCtx);
|
|
1231
|
+
manager.setParentQuestionFactory((record, ctx) => {
|
|
1232
|
+
const generation = sessionGeneration;
|
|
1233
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1234
|
+
const originCurrent = captureQuestionOrigin(ctx);
|
|
1235
|
+
return (question, choices, signal) => {
|
|
1236
|
+
// A retained channel can resume after ordinary native appends. Each
|
|
1237
|
+
// question also owns the LIVE complete prefix at its own invocation.
|
|
1238
|
+
const questionOriginCurrent = currentCtx ? captureQuestionOrigin(currentCtx) : () => false;
|
|
1239
|
+
const controller = record.abortController;
|
|
1240
|
+
const owner = JSON.stringify(record.owner);
|
|
1241
|
+
const isCurrent = () => currentCtx !== undefined &&
|
|
1242
|
+
sessionGeneration === generation && boundSessionId === sessionId &&
|
|
1243
|
+
originCurrent(currentCtx) && questionOriginCurrent(currentCtx) &&
|
|
1244
|
+
manager.getRecordMutable(record.id) === record && !record.detached &&
|
|
1245
|
+
record.isBackground === true && record.status === "running" &&
|
|
1246
|
+
record.abortController === controller && !controller?.signal.aborted &&
|
|
1247
|
+
JSON.stringify(record.owner) === owner;
|
|
1248
|
+
return parentQuestions.broker.request({ agentId: record.id, isCurrent }, question, choices,
|
|
1249
|
+
controller ? AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]) : signal);
|
|
1250
|
+
};
|
|
1251
|
+
});
|
|
1197
1252
|
// Public synchronous bus preflight works in either extension load order. A
|
|
1198
1253
|
// filtered-out activation has no bound context and must not veto navigation.
|
|
1199
1254
|
pi.events.on("pi:navigation-preflight", (raw: unknown) => {
|
|
@@ -1259,11 +1314,13 @@ function activateRootRuntime(
|
|
|
1259
1314
|
): Promise<void> {
|
|
1260
1315
|
// A successful session_start is the commit point for a reused runtime. A
|
|
1261
1316
|
// cancelled session_before_switch leaves the old context and scheduler live.
|
|
1317
|
+
retireFleetUi();
|
|
1262
1318
|
if (currentCtx) {
|
|
1263
1319
|
scheduler.stop();
|
|
1264
1320
|
manager.detachForBranchChange();
|
|
1265
1321
|
manager.resetManagedSpawns();
|
|
1266
1322
|
}
|
|
1323
|
+
parentQuestions.retire();
|
|
1267
1324
|
sessionGeneration++;
|
|
1268
1325
|
const replacingConfiguration = currentCtx !== undefined;
|
|
1269
1326
|
configurationOwner = ctx;
|
|
@@ -1377,6 +1434,7 @@ function activateRootRuntime(
|
|
|
1377
1434
|
}
|
|
1378
1435
|
if (ownsRpcRegistry && isSchedulingEnabled() && !scheduler.isActive())
|
|
1379
1436
|
startScheduler(ctx);
|
|
1437
|
+
bindFleetUi();
|
|
1380
1438
|
}
|
|
1381
1439
|
|
|
1382
1440
|
// Both before-events are cancellable. A later handler may veto, and tree
|
|
@@ -1388,6 +1446,8 @@ function activateRootRuntime(
|
|
|
1388
1446
|
const detachCommittedTree = (event: unknown): void => {
|
|
1389
1447
|
if (!currentCtx || detachedTreeEvent === event) return;
|
|
1390
1448
|
detachedTreeEvent = event;
|
|
1449
|
+
retireFleetUi();
|
|
1450
|
+
parentQuestions.retire();
|
|
1391
1451
|
manager.detachForBranchChange();
|
|
1392
1452
|
};
|
|
1393
1453
|
// If workflows' committed-tree handler runs first, it must detach our old
|
|
@@ -1420,6 +1480,7 @@ function activateRootRuntime(
|
|
|
1420
1480
|
manager.clearCompleted(true);
|
|
1421
1481
|
for (const tombstone of recovered) persistRecoveredManaged(tombstone);
|
|
1422
1482
|
if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
|
|
1483
|
+
bindFleetUi();
|
|
1423
1484
|
});
|
|
1424
1485
|
|
|
1425
1486
|
// Shutdown is idempotent and also serves same-session hot-reload handoff. The
|
|
@@ -1446,6 +1507,8 @@ function activateRootRuntime(
|
|
|
1446
1507
|
},
|
|
1447
1508
|
});
|
|
1448
1509
|
scheduler.stop();
|
|
1510
|
+
parentQuestions.retire();
|
|
1511
|
+
retireFleetUi();
|
|
1449
1512
|
currentCtx = undefined; // reject new spawns; owned cleanup RPC remains bound
|
|
1450
1513
|
if (pending.length > 0) {
|
|
1451
1514
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -1522,6 +1585,60 @@ function activateRootRuntime(
|
|
|
1522
1585
|
// used to carry the same rows, with the same summary repeated again in the
|
|
1523
1586
|
// footer status; one surface, below the prompt, replaces all three.
|
|
1524
1587
|
const fleet = new FleetList(manager, agentActivity);
|
|
1588
|
+
let workflowUiClient: WorkflowUiClient | undefined;
|
|
1589
|
+
let ownedConversationUnsubscribe: (() => void) | undefined;
|
|
1590
|
+
const retiredWorkflowSurfaces = new Set<string>();
|
|
1591
|
+
|
|
1592
|
+
function rootUiFence(): () => boolean {
|
|
1593
|
+
const root = currentCtx;
|
|
1594
|
+
const generation = sessionGeneration;
|
|
1595
|
+
const managerIdentity = root?.sessionManager;
|
|
1596
|
+
const sessionId = boundSessionId;
|
|
1597
|
+
return () => !!root && currentCtx === root && generation === sessionGeneration &&
|
|
1598
|
+
currentCtx.sessionManager === managerIdentity && boundSessionId === sessionId &&
|
|
1599
|
+
currentCtx.sessionManager.getSessionId() === sessionId;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function retireFleetUi(): void {
|
|
1603
|
+
ownedConversationUnsubscribe?.();
|
|
1604
|
+
ownedConversationUnsubscribe = undefined;
|
|
1605
|
+
workflowUiClient?.dispose();
|
|
1606
|
+
workflowUiClient = undefined;
|
|
1607
|
+
fleet.dispose();
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function bindFleetUi(): void {
|
|
1611
|
+
if (!currentCtx || !ownsRpcRegistry) return;
|
|
1612
|
+
const ctx = currentCtx;
|
|
1613
|
+
const isCurrent = rootUiFence();
|
|
1614
|
+
ownedConversationUnsubscribe = pi.events.on(WORKFLOW_UI_CHANNELS.openConversation, raw => {
|
|
1615
|
+
let request: ReturnType<typeof parseOwnedConversationOpenRequest>;
|
|
1616
|
+
try { request = parseOwnedConversationOpenRequest(raw); } catch { return; }
|
|
1617
|
+
const channel = replyChannel(WORKFLOW_UI_CHANNELS.openConversation, request.requestId);
|
|
1618
|
+
const retained = manager.getRecordMutable(request.agentId);
|
|
1619
|
+
const selection = retained ? captureAgentSelection(manager, retained, isCurrent) : undefined;
|
|
1620
|
+
const owner = selection?.owner;
|
|
1621
|
+
if (!isCurrent() || ctx.mode !== "tui" || !ctx.hasUI || !selection?.session || !selection.current() ||
|
|
1622
|
+
retained?.parentAgentId || owner?.extension !== request.owner.extension || owner.runId !== request.owner.runId ||
|
|
1623
|
+
owner.nodeId !== request.owner.nodeId || owner.attemptId !== request.owner.attemptId || !fleet.canOpenConversation(selection)) {
|
|
1624
|
+
pi.events.emit(channel, { success: false, error: "Exact retained agent conversation is not available in the active TUI." });
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
// Reserve the exact selection before replying: reply listeners can issue
|
|
1628
|
+
// another request or retire this root synchronously. Only construction yields.
|
|
1629
|
+
if (!fleet.openConversation(selection, true)) {
|
|
1630
|
+
pi.events.emit(channel, { success: false, error: "Exact retained agent conversation is already reserved or unavailable." });
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
pi.events.emit(channel, { success: true, data: { accepted: true } });
|
|
1634
|
+
});
|
|
1635
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
1636
|
+
fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
|
|
1637
|
+
workflowUiClient = new WorkflowUiClient(pi.events, () => isCurrent() && ctx.mode === "tui" && ctx.hasUI,
|
|
1638
|
+
() => fleet.update(), retiredWorkflowSurfaces);
|
|
1639
|
+
fleet.setWorkflowClient(workflowUiClient);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1525
1642
|
let fleetViewEnabled = true;
|
|
1526
1643
|
function isFleetViewEnabled(): boolean {
|
|
1527
1644
|
return fleetViewEnabled;
|
|
@@ -1543,6 +1660,7 @@ function activateRootRuntime(
|
|
|
1543
1660
|
function seatFleet(ctx: ExtensionContext): boolean {
|
|
1544
1661
|
if (ctx.mode !== "tui" || !ctx.hasUI) return false;
|
|
1545
1662
|
fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
|
|
1663
|
+
fleet.setWorkflowClient(workflowUiClient);
|
|
1546
1664
|
return true;
|
|
1547
1665
|
}
|
|
1548
1666
|
|
|
@@ -1559,6 +1677,7 @@ function activateRootRuntime(
|
|
|
1559
1677
|
// session that has not run a tool yet. Re-seat from the live context so the
|
|
1560
1678
|
// guard and the draw target agree; the setter is idempotent.
|
|
1561
1679
|
fleet.setUICtx(currentCtx.ui as unknown as FleetUICtx);
|
|
1680
|
+
fleet.setWorkflowClient(workflowUiClient);
|
|
1562
1681
|
// Arm the tickers after the draw, not before: both tear their own timer down
|
|
1563
1682
|
// when they find nothing to show, and the fleet finds nothing until the child
|
|
1564
1683
|
// session lands a moment later. A spawn that entered through the manager has
|
|
@@ -1649,16 +1768,17 @@ function activateRootRuntime(
|
|
|
1649
1768
|
},
|
|
1650
1769
|
};
|
|
1651
1770
|
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1771
|
+
// Allocate tracking only at a real fresh/reused-thread admission. An
|
|
1772
|
+
// idempotent request replay must not create another tracker at all.
|
|
1773
|
+
let tracker: ReturnType<typeof createActivityTracker> | undefined;
|
|
1774
|
+
const tracking = () => tracker ??= createActivityTracker(undefined, () => fleet.update());
|
|
1655
1775
|
const outputTranscript =
|
|
1656
1776
|
getAgentConfig(dispatch.type)?.outputTranscript ??
|
|
1657
1777
|
getOutputTranscriptDefault();
|
|
1658
1778
|
let id = "";
|
|
1659
1779
|
const initializeRecord = (spawnedId: string): void => {
|
|
1660
1780
|
id = spawnedId;
|
|
1661
|
-
agentActivity.set(id, state);
|
|
1781
|
+
agentActivity.set(id, tracking().state);
|
|
1662
1782
|
const record = manager.getRecordMutable(id);
|
|
1663
1783
|
if (record && outputTranscript) {
|
|
1664
1784
|
record.outputFile = createOutputFilePath(
|
|
@@ -1676,12 +1796,31 @@ function activateRootRuntime(
|
|
|
1676
1796
|
fleet.ensureTimer();
|
|
1677
1797
|
fleet.update();
|
|
1678
1798
|
};
|
|
1679
|
-
const
|
|
1680
|
-
|
|
1681
|
-
|
|
1799
|
+
const managedCallbacks: ManagedSpawnCallbacks = {
|
|
1800
|
+
onToolActivity: activity => tracker?.callbacks.onToolActivity(activity),
|
|
1801
|
+
onTextDelta: (delta, text) => tracker?.callbacks.onTextDelta(delta, text),
|
|
1802
|
+
onTurnEnd: count => tracker?.callbacks.onTurnEnd(count),
|
|
1803
|
+
onAssistantUsage: usage => tracker?.callbacks.onAssistantUsage(usage),
|
|
1682
1804
|
onSpawned: initializeRecord,
|
|
1805
|
+
onResumeStarting: (record: AgentRecord, startMessageIndex: number) => {
|
|
1806
|
+
id = record.id;
|
|
1807
|
+
const { state } = tracking();
|
|
1808
|
+
state.session = record.session;
|
|
1809
|
+
state.toolUses = record.toolUses;
|
|
1810
|
+
state.lifetimeUsage = { ...record.lifetimeUsage };
|
|
1811
|
+
agentActivity.set(id, state);
|
|
1812
|
+
record.outputCleanup?.();
|
|
1813
|
+
record.outputCleanup = undefined;
|
|
1814
|
+
if (outputTranscript && record.session) {
|
|
1815
|
+
record.outputFile ??= createOutputFilePath(ctxRef.cwd, id, ctxRef.sessionManager.getSessionId());
|
|
1816
|
+
ensureOutputFile(record.outputFile);
|
|
1817
|
+
record.outputCleanup = streamToOutputFile(record.session, record.outputFile, id, ctxRef.cwd, startMessageIndex);
|
|
1818
|
+
}
|
|
1819
|
+
fleet.ensureTimer();
|
|
1820
|
+
fleet.update();
|
|
1821
|
+
},
|
|
1683
1822
|
onSessionCreated: (session: AgentSession) => {
|
|
1684
|
-
onSessionCreated(session);
|
|
1823
|
+
tracking().callbacks.onSessionCreated(session);
|
|
1685
1824
|
const record = manager.getRecordMutable(id);
|
|
1686
1825
|
if (record?.outputFile) {
|
|
1687
1826
|
record.outputCleanup = streamToOutputFile(
|
|
@@ -1707,11 +1846,9 @@ function activateRootRuntime(
|
|
|
1707
1846
|
// transcript, timer, or callback chain. The original Agent record owns all
|
|
1708
1847
|
// first-class UI and streaming state.
|
|
1709
1848
|
if (!managedResult.created) return managedResult;
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
initializeRecord(managedResult.id);
|
|
1714
|
-
}
|
|
1849
|
+
// New records invoke onSpawned synchronously. Reused threads invoke only
|
|
1850
|
+
// onResumeStarting at actual admission (possibly after waiting for capacity).
|
|
1851
|
+
// Never fall through to the truncating new-record initializer on reentry.
|
|
1715
1852
|
return managedResult;
|
|
1716
1853
|
}
|
|
1717
1854
|
|
|
@@ -2050,7 +2187,7 @@ function activateRootRuntime(
|
|
|
2050
2187
|
* prose working. A delivered mention costs no main-model turn; the answer
|
|
2051
2188
|
* arrives through the ordinary completion notification either way.
|
|
2052
2189
|
*/
|
|
2053
|
-
pi.on("input", async (event, ctx) => {
|
|
2190
|
+
pi.on("input", parentQuestions.observeHandledInput(async (event, ctx) => {
|
|
2054
2191
|
// Never hijack text the extension layer itself submitted (pi.sendMessage,
|
|
2055
2192
|
// scheduled prompts) — only something a person typed can be a mention.
|
|
2056
2193
|
if (event.source === "extension" || !isAgentMentionsEnabled())
|
|
@@ -2289,7 +2426,7 @@ function activateRootRuntime(
|
|
|
2289
2426
|
);
|
|
2290
2427
|
}
|
|
2291
2428
|
return { action: "handled" };
|
|
2292
|
-
});
|
|
2429
|
+
}));
|
|
2293
2430
|
|
|
2294
2431
|
// ---- Agent tool ----
|
|
2295
2432
|
|
|
@@ -2489,6 +2626,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2489
2626
|
"Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
|
|
2490
2627
|
}),
|
|
2491
2628
|
),
|
|
2629
|
+
supervisor_questions: Type.Optional(Type.Union([Type.Literal("human"), Type.Literal("parent"), Type.Literal("off")], {
|
|
2630
|
+
description: 'Explicit question routing, default human. "parent" is ordinary clarification only (never permission/approval), supported only for fresh non-isolated top-level background calls. No schedules or mode-changing resumes.',
|
|
2631
|
+
})),
|
|
2492
2632
|
resume: Type.Optional(
|
|
2493
2633
|
Type.String({
|
|
2494
2634
|
description:
|
|
@@ -2723,6 +2863,16 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2723
2863
|
...params,
|
|
2724
2864
|
agentTiers: getAgentTiersSettings(),
|
|
2725
2865
|
});
|
|
2866
|
+
if (params.supervisor_questions === "parent" &&
|
|
2867
|
+
(!resolvedConfig.runInBackground || resolvedConfig.isolated || params.schedule || params.resume || !isSupervisorQuestionsEnabled())) {
|
|
2868
|
+
throw new Error('supervisor_questions: "parent" requires a fresh non-isolated background Agent. Set run_in_background: true, omit schedule/resume, and enable Supervisor questions. Foreground calls cannot answer while blocked.');
|
|
2869
|
+
}
|
|
2870
|
+
if (params.schedule && params.supervisor_questions !== undefined) {
|
|
2871
|
+
throw new Error('Scheduled calls do not support per-call question routing; omit supervisor_questions.');
|
|
2872
|
+
}
|
|
2873
|
+
if (params.resume && params.supervisor_questions !== undefined) {
|
|
2874
|
+
throw new Error('Resume preserves the child question mode; omit supervisor_questions and use run_in_background: true for a parent-mode child.');
|
|
2875
|
+
}
|
|
2726
2876
|
if (!params.resume && !params.schedule && resolvedConfig.inheritContext) {
|
|
2727
2877
|
return textResult(INHERIT_CONTEXT_UNAVAILABLE);
|
|
2728
2878
|
}
|
|
@@ -3041,6 +3191,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3041
3191
|
: thinking,
|
|
3042
3192
|
agentTier: resolvedConfig.requestedAgentTier,
|
|
3043
3193
|
isBackground: true,
|
|
3194
|
+
supervisorQuestions: params.supervisor_questions === "parent" ? "parent" : params.supervisor_questions === "off" ? false : undefined,
|
|
3044
3195
|
isolation,
|
|
3045
3196
|
invocation: agentInvocation,
|
|
3046
3197
|
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
@@ -3120,8 +3271,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3120
3271
|
let spinnerFrame = 0;
|
|
3121
3272
|
const startedAt = Date.now();
|
|
3122
3273
|
let fgId: string | undefined;
|
|
3274
|
+
let foregroundUpdates = true;
|
|
3275
|
+
let handedOff = false;
|
|
3123
3276
|
|
|
3124
3277
|
const streamUpdate = () => {
|
|
3278
|
+
fleet.update();
|
|
3279
|
+
if (!foregroundUpdates) return;
|
|
3125
3280
|
const details: AgentDetails = {
|
|
3126
3281
|
...detailBase,
|
|
3127
3282
|
toolUses: fgState.toolUses,
|
|
@@ -3198,6 +3353,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3198
3353
|
: thinking,
|
|
3199
3354
|
agentTier: resolvedConfig.requestedAgentTier,
|
|
3200
3355
|
isolation,
|
|
3356
|
+
supervisorQuestions: params.supervisor_questions === "off" ? false : undefined,
|
|
3201
3357
|
invocation: agentInvocation,
|
|
3202
3358
|
signal,
|
|
3203
3359
|
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
@@ -3206,21 +3362,30 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3206
3362
|
(fgAgentId) => {
|
|
3207
3363
|
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
3208
3364
|
// Set up the output file so streamToOutputFile can pick it up.
|
|
3365
|
+
fgId = fgAgentId;
|
|
3209
3366
|
const fgRec = manager.getRecordMutable(fgAgentId);
|
|
3367
|
+
if (fgRec) fgRec.toolCallId = toolCallId;
|
|
3210
3368
|
attachTranscript(fgRec, fgAgentId);
|
|
3211
3369
|
},
|
|
3212
3370
|
);
|
|
3213
3371
|
record = fgResult.record;
|
|
3372
|
+
handedOff = fgResult.handedOff === true;
|
|
3214
3373
|
} finally {
|
|
3215
|
-
//
|
|
3216
|
-
|
|
3374
|
+
// A transfer ends this tool's spinner, not the child's activity or stream.
|
|
3375
|
+
foregroundUpdates = false;
|
|
3217
3376
|
clearInterval(spinnerInterval);
|
|
3218
|
-
if (fgId) {
|
|
3377
|
+
if (fgId && !handedOff) {
|
|
3219
3378
|
agentActivity.delete(fgId);
|
|
3220
3379
|
fleet.onAgentFinished(fgId);
|
|
3221
3380
|
}
|
|
3222
3381
|
}
|
|
3223
3382
|
|
|
3383
|
+
if (handedOff) {
|
|
3384
|
+
return textResult(`${fallbackNote}Agent continues in background.\nAgent ID: ${record.id}\n` +
|
|
3385
|
+
"You will be notified when it completes. Use get_subagent_result for results or steer_subagent to send messages.",
|
|
3386
|
+
buildDetails(detailBase, record, fgState, { status: "background" }));
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3224
3389
|
// Get final token count
|
|
3225
3390
|
const tokenText = formatLifetimeTokens(fgState);
|
|
3226
3391
|
|
|
@@ -3517,6 +3682,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3517
3682
|
}
|
|
3518
3683
|
|
|
3519
3684
|
async function showAgentsMenu(ctx: ExtensionCommandContext) {
|
|
3685
|
+
const isCurrent = rootUiFence();
|
|
3686
|
+
if (!isCurrent()) return;
|
|
3520
3687
|
reloadCustomAgents();
|
|
3521
3688
|
const allNames = getAllTypes();
|
|
3522
3689
|
|
|
@@ -3564,31 +3731,31 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3564
3731
|
}
|
|
3565
3732
|
|
|
3566
3733
|
const choice = await ctx.ui.select("Agents", options);
|
|
3567
|
-
if (!choice) return;
|
|
3734
|
+
if (!choice || !isCurrent()) return;
|
|
3568
3735
|
|
|
3569
3736
|
if (choice.startsWith("Agent runs (")) {
|
|
3570
3737
|
await showAgentRuns(ctx);
|
|
3571
|
-
await showAgentsMenu(ctx);
|
|
3738
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3572
3739
|
} else if (choice.startsWith("Agent types (")) {
|
|
3573
3740
|
await showAllAgentsList(ctx);
|
|
3574
|
-
await showAgentsMenu(ctx);
|
|
3741
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3575
3742
|
} else if (choice.startsWith("Scheduled jobs (")) {
|
|
3576
3743
|
await showSchedulesMenu(ctx, scheduler);
|
|
3577
|
-
await showAgentsMenu(ctx);
|
|
3744
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3578
3745
|
} else if (choice.startsWith("Model tiers (")) {
|
|
3579
3746
|
await showModelTiersMenu(ctx);
|
|
3580
|
-
await showAgentsMenu(ctx);
|
|
3747
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3581
3748
|
} else if (choice === "Create new agent") {
|
|
3582
3749
|
await showCreateWizard(ctx);
|
|
3583
3750
|
} else if (choice === "Settings") {
|
|
3584
3751
|
await showSettings(ctx);
|
|
3585
|
-
await showAgentsMenu(ctx);
|
|
3752
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3586
3753
|
} else if (choice.startsWith("Usage (")) {
|
|
3587
3754
|
showSessionUsage(ctx);
|
|
3588
|
-
await showAgentsMenu(ctx);
|
|
3755
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3589
3756
|
} else if (choice === "Diagnostics") {
|
|
3590
3757
|
await showDiagnostics(ctx);
|
|
3591
|
-
await showAgentsMenu(ctx);
|
|
3758
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3592
3759
|
}
|
|
3593
3760
|
}
|
|
3594
3761
|
|
|
@@ -3712,29 +3879,33 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3712
3879
|
}
|
|
3713
3880
|
|
|
3714
3881
|
async function showAgentRuns(ctx: ExtensionCommandContext) {
|
|
3715
|
-
const
|
|
3882
|
+
const isCurrent = rootUiFence();
|
|
3883
|
+
const agents = manager.listAgentsMutable().filter((a) => !a.parentAgentId && !a.detached)
|
|
3884
|
+
.map(record => captureAgentSelection(manager, record, isCurrent));
|
|
3716
3885
|
if (agents.length === 0) {
|
|
3717
3886
|
ctx.ui.notify("No agents.", "info");
|
|
3718
3887
|
return;
|
|
3719
3888
|
}
|
|
3720
3889
|
|
|
3721
|
-
const
|
|
3890
|
+
const selection = await selectItem(ctx.ui, "Agent runs", agents, ({ snapshot: agent }) => {
|
|
3722
3891
|
const dn = getDisplayName(agent.type);
|
|
3723
3892
|
const dur = formatDuration(agent.startedAt, agent.completedAt);
|
|
3724
3893
|
return `${dn} (${sanitizeDisplayText(agent.description)}) · ${agent.toolUses} tools · ${getAgentStatusLabel(agent.status)} · ${dur}`;
|
|
3725
3894
|
});
|
|
3726
|
-
if (!
|
|
3895
|
+
if (!selection || !isCurrent() || !selection.current()) return;
|
|
3727
3896
|
|
|
3728
|
-
await viewAgentConversation(ctx,
|
|
3729
|
-
// Back-navigation
|
|
3730
|
-
await showAgentRuns(ctx);
|
|
3897
|
+
await viewAgentConversation(ctx, selection);
|
|
3898
|
+
// Back-navigation stays on the same root/tree, never a replacement.
|
|
3899
|
+
if (isCurrent()) await showAgentRuns(ctx);
|
|
3731
3900
|
}
|
|
3732
3901
|
|
|
3733
3902
|
async function viewAgentConversation(
|
|
3734
3903
|
ctx: ExtensionCommandContext,
|
|
3735
|
-
|
|
3904
|
+
selection: AgentSelection,
|
|
3736
3905
|
) {
|
|
3737
|
-
|
|
3906
|
+
const record = selection.snapshot;
|
|
3907
|
+
if (!selection.current()) return;
|
|
3908
|
+
if (!selection.session) {
|
|
3738
3909
|
if (record.status === "queued") {
|
|
3739
3910
|
const handleHint = record.handle
|
|
3740
3911
|
? ` Use @${record.handle} <message> to chat while it waits.`
|
|
@@ -3743,7 +3914,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3743
3914
|
"Queued agent",
|
|
3744
3915
|
`"${sanitizeDisplayText(record.description)}" has not started yet.${handleHint}\n\nStop this queued run?`,
|
|
3745
3916
|
);
|
|
3746
|
-
if (stop && manager.abort(record.id)) {
|
|
3917
|
+
if (stop && selection.current() && manager.abort(record.id)) {
|
|
3747
3918
|
ctx.ui.notify(
|
|
3748
3919
|
`Stopped queued agent "${sanitizeDisplayText(record.description)}".`,
|
|
3749
3920
|
"info",
|
|
@@ -3755,38 +3926,19 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3755
3926
|
return;
|
|
3756
3927
|
}
|
|
3757
3928
|
|
|
3758
|
-
|
|
3759
|
-
const
|
|
3760
|
-
|
|
3761
|
-
|
|
3929
|
+
seatFleet(ctx);
|
|
3930
|
+
const mode = fleet.conversationMode();
|
|
3931
|
+
// Fullscreen owns its entire native viewport; an extra shared border would
|
|
3932
|
+
// clip its controls. Floating conversations retain the normal treatment.
|
|
3933
|
+
const viewerUi = mode === "fullscreen" ? ctx.ui : wrapCustomUi(ctx.ui);
|
|
3934
|
+
await viewerUi.custom<undefined>(
|
|
3762
3935
|
(tui, theme, keybindings, done) => {
|
|
3763
|
-
return
|
|
3764
|
-
|
|
3765
|
-
session,
|
|
3766
|
-
record,
|
|
3767
|
-
activity,
|
|
3768
|
-
theme,
|
|
3769
|
-
done,
|
|
3770
|
-
() => {
|
|
3771
|
-
if (manager.abort(record.id)) {
|
|
3772
|
-
ctx.ui.notify(
|
|
3773
|
-
`Stopped "${sanitizeDisplayText(record.description)}".`,
|
|
3774
|
-
"info",
|
|
3775
|
-
);
|
|
3776
|
-
}
|
|
3777
|
-
},
|
|
3778
|
-
keybindings,
|
|
3779
|
-
(message: string) => manager.steer(record.id, message),
|
|
3780
|
-
() => manager.getRecord(record.id),
|
|
3781
|
-
);
|
|
3936
|
+
if (!selection.current()) { done(undefined); return { render: () => [], invalidate: () => {} }; }
|
|
3937
|
+
return fleet.conversation(selection, tui, theme, keybindings, done, message => ctx.ui.notify(message, "info"), mode);
|
|
3782
3938
|
},
|
|
3783
3939
|
{
|
|
3784
3940
|
overlay: true,
|
|
3785
|
-
overlayOptions: {
|
|
3786
|
-
anchor: "center",
|
|
3787
|
-
width: "90%",
|
|
3788
|
-
maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
|
|
3789
|
-
},
|
|
3941
|
+
overlayOptions: getConversationOverlayOptions({ mode: mode === "fullscreen" ? "fullscreen" : undefined }),
|
|
3790
3942
|
},
|
|
3791
3943
|
);
|
|
3792
3944
|
}
|
package/src/output-file.ts
CHANGED
|
@@ -95,17 +95,32 @@ export function streamToOutputFile(
|
|
|
95
95
|
cwd: string,
|
|
96
96
|
startIndex?: number,
|
|
97
97
|
): () => void {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
98
|
+
if (startIndex !== undefined && (!Number.isSafeInteger(startIndex) || startIndex < 0)) {
|
|
99
|
+
throw new RangeError("startIndex must be a nonnegative safe integer");
|
|
100
|
+
}
|
|
101
|
+
// A resume supplies the index past history already on disk. A fresh spawn
|
|
102
|
+
// has seeded its prompt separately, but Pi may add the actual user later or
|
|
103
|
+
// handle the input via a custom-message turn with no user at all. Dedup is
|
|
104
|
+
// independent of the cursor so legitimate pre-user records still stream.
|
|
105
|
+
let writtenCount = startIndex ?? 0;
|
|
106
|
+
let initialUserPending = startIndex === undefined;
|
|
107
|
+
let disposed = false;
|
|
104
108
|
|
|
105
109
|
const flush = () => {
|
|
110
|
+
if (disposed) return;
|
|
106
111
|
const messages = session.messages;
|
|
107
112
|
while (writtenCount < messages.length) {
|
|
108
|
-
const msg = messages[writtenCount];
|
|
113
|
+
const msg = messages[writtenCount++];
|
|
114
|
+
// Native prompt/tool-loadout records have no Claude Code transcript entry.
|
|
115
|
+
if (msg.role === "system") continue;
|
|
116
|
+
if (initialUserPending && msg.role === "user") {
|
|
117
|
+
initialUserPending = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// An actual assistant response proves a turn ran without the seeded user.
|
|
121
|
+
// Its later follow-up must be kept, even if both reach the same flush.
|
|
122
|
+
// Custom guidance alone is not that evidence: the user can still arrive.
|
|
123
|
+
if (msg.role === "assistant") initialUserPending = false;
|
|
109
124
|
const entry = {
|
|
110
125
|
isSidechain: true,
|
|
111
126
|
agentId,
|
|
@@ -117,11 +132,11 @@ export function streamToOutputFile(
|
|
|
117
132
|
try {
|
|
118
133
|
appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
|
|
119
134
|
} catch { /* ignore write errors */ }
|
|
120
|
-
writtenCount++;
|
|
121
135
|
}
|
|
122
136
|
};
|
|
123
137
|
|
|
124
138
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
139
|
+
if (disposed) return;
|
|
125
140
|
if (event.type === "turn_end") flush();
|
|
126
141
|
// Compaction replaces session.messages with a shorter, summarized array,
|
|
127
142
|
// leaving writtenCount past the new end — without re-anchoring, the flush
|
|
@@ -135,12 +150,16 @@ export function streamToOutputFile(
|
|
|
135
150
|
// session.messages untouched, so only successful ones re-anchor.
|
|
136
151
|
if (event.type === "compaction_start") flush();
|
|
137
152
|
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
138
|
-
queueMicrotask(() => {
|
|
153
|
+
queueMicrotask(() => {
|
|
154
|
+
if (!disposed) writtenCount = session.messages.length;
|
|
155
|
+
});
|
|
139
156
|
}
|
|
140
157
|
});
|
|
141
158
|
|
|
142
159
|
return () => {
|
|
160
|
+
if (disposed) return;
|
|
143
161
|
flush();
|
|
162
|
+
disposed = true;
|
|
144
163
|
unsubscribe();
|
|
145
164
|
};
|
|
146
165
|
}
|