@signalridge/pi-subagents 1.12.0 → 1.14.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 +26 -0
- package/README.md +24 -5
- package/package.json +3 -2
- package/src/agent-manager.ts +368 -64
- package/src/agent-runner.ts +150 -35
- package/src/index.ts +177 -67
- package/src/operation-approvals.ts +49 -0
- package/src/output-file.ts +28 -9
- package/src/types.ts +4 -3
- package/src/ui/conversation-viewer.ts +607 -287
- package/src/ui/fleet-list.ts +154 -29
- package/src/ui/owner-group.ts +12 -4
- 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,
|
|
@@ -213,11 +215,8 @@ import {
|
|
|
213
215
|
type Theme,
|
|
214
216
|
} from "./ui/agent-display.js";
|
|
215
217
|
import { createMentionProvider, mentionRoster } from "./ui/agent-mention.js";
|
|
216
|
-
import {
|
|
217
|
-
|
|
218
|
-
VIEWPORT_HEIGHT_PCT,
|
|
219
|
-
} from "./ui/conversation-viewer.js";
|
|
220
|
-
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";
|
|
221
220
|
import {
|
|
222
221
|
safeTerminalText,
|
|
223
222
|
sanitizeDisplayText,
|
|
@@ -230,6 +229,7 @@ import {
|
|
|
230
229
|
getAgentStatusLabel,
|
|
231
230
|
getAgentStatusMark,
|
|
232
231
|
} from "./ui/status-label.js";
|
|
232
|
+
import { WorkflowUiClient } from "./ui/workflow-ui-client.js";
|
|
233
233
|
import {
|
|
234
234
|
addUsage,
|
|
235
235
|
getLifetimeTotal,
|
|
@@ -811,6 +811,12 @@ function activateRootRuntime(
|
|
|
811
811
|
// tokens is omitted when nothing was ever produced (e.g. agent errored before
|
|
812
812
|
// any message_end fired), preserving prior payload shape.
|
|
813
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;
|
|
814
820
|
const total = getLifetimeTotal(u);
|
|
815
821
|
const tokens =
|
|
816
822
|
total > 0 ? { input: u.input, output: u.output, total } : undefined;
|
|
@@ -824,6 +830,7 @@ function activateRootRuntime(
|
|
|
824
830
|
toolUses: record.toolUses,
|
|
825
831
|
durationMs,
|
|
826
832
|
tokens,
|
|
833
|
+
...(managedTokens ? { managedTokens: { ...managedTokens } } : {}),
|
|
827
834
|
...(record.outputFile ? { outputFile: record.outputFile } : {}),
|
|
828
835
|
...(record.invocation?.agentTier === undefined
|
|
829
836
|
? {}
|
|
@@ -919,13 +926,25 @@ function activateRootRuntime(
|
|
|
919
926
|
string,
|
|
920
927
|
{ runs: number; usage: LifetimeUsage }
|
|
921
928
|
>();
|
|
929
|
+
const accountedUsage = new WeakMap<AgentRecord, { usage: LifetimeUsage; completion: string; promise: AgentRecord["promise"] }>();
|
|
922
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 });
|
|
923
942
|
const entry = sessionUsageByType.get(record.type) ?? {
|
|
924
943
|
runs: 0,
|
|
925
944
|
usage: { input: 0, output: 0, cacheWrite: 0 },
|
|
926
945
|
};
|
|
927
946
|
entry.runs++;
|
|
928
|
-
addUsage(entry.usage,
|
|
947
|
+
addUsage(entry.usage, usage);
|
|
929
948
|
sessionUsageByType.set(record.type, entry);
|
|
930
949
|
}
|
|
931
950
|
|
|
@@ -935,6 +954,14 @@ function activateRootRuntime(
|
|
|
935
954
|
// Nested children report only through their owning parent's scoped tools.
|
|
936
955
|
// Keep them out of top-level lifecycle, transcript, notification, and UI channels.
|
|
937
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
|
+
}
|
|
938
965
|
|
|
939
966
|
// Emit lifecycle event based on terminal status
|
|
940
967
|
const isError =
|
|
@@ -1058,6 +1085,10 @@ function activateRootRuntime(
|
|
|
1058
1085
|
append: (tombstone) =>
|
|
1059
1086
|
pi.appendEntry(MANAGED_SPAWN_ENTRY_TYPE, tombstone),
|
|
1060
1087
|
},
|
|
1088
|
+
(record) => {
|
|
1089
|
+
record.joinMode = "async";
|
|
1090
|
+
refreshAgentSurfaces();
|
|
1091
|
+
},
|
|
1061
1092
|
);
|
|
1062
1093
|
|
|
1063
1094
|
// Expose the validated root manager via Symbol.for() for cross-package access.
|
|
@@ -1283,6 +1314,7 @@ function activateRootRuntime(
|
|
|
1283
1314
|
): Promise<void> {
|
|
1284
1315
|
// A successful session_start is the commit point for a reused runtime. A
|
|
1285
1316
|
// cancelled session_before_switch leaves the old context and scheduler live.
|
|
1317
|
+
retireFleetUi();
|
|
1286
1318
|
if (currentCtx) {
|
|
1287
1319
|
scheduler.stop();
|
|
1288
1320
|
manager.detachForBranchChange();
|
|
@@ -1402,6 +1434,7 @@ function activateRootRuntime(
|
|
|
1402
1434
|
}
|
|
1403
1435
|
if (ownsRpcRegistry && isSchedulingEnabled() && !scheduler.isActive())
|
|
1404
1436
|
startScheduler(ctx);
|
|
1437
|
+
bindFleetUi();
|
|
1405
1438
|
}
|
|
1406
1439
|
|
|
1407
1440
|
// Both before-events are cancellable. A later handler may veto, and tree
|
|
@@ -1413,6 +1446,7 @@ function activateRootRuntime(
|
|
|
1413
1446
|
const detachCommittedTree = (event: unknown): void => {
|
|
1414
1447
|
if (!currentCtx || detachedTreeEvent === event) return;
|
|
1415
1448
|
detachedTreeEvent = event;
|
|
1449
|
+
retireFleetUi();
|
|
1416
1450
|
parentQuestions.retire();
|
|
1417
1451
|
manager.detachForBranchChange();
|
|
1418
1452
|
};
|
|
@@ -1446,6 +1480,7 @@ function activateRootRuntime(
|
|
|
1446
1480
|
manager.clearCompleted(true);
|
|
1447
1481
|
for (const tombstone of recovered) persistRecoveredManaged(tombstone);
|
|
1448
1482
|
if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
|
|
1483
|
+
bindFleetUi();
|
|
1449
1484
|
});
|
|
1450
1485
|
|
|
1451
1486
|
// Shutdown is idempotent and also serves same-session hot-reload handoff. The
|
|
@@ -1473,6 +1508,7 @@ function activateRootRuntime(
|
|
|
1473
1508
|
});
|
|
1474
1509
|
scheduler.stop();
|
|
1475
1510
|
parentQuestions.retire();
|
|
1511
|
+
retireFleetUi();
|
|
1476
1512
|
currentCtx = undefined; // reject new spawns; owned cleanup RPC remains bound
|
|
1477
1513
|
if (pending.length > 0) {
|
|
1478
1514
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -1549,6 +1585,60 @@ function activateRootRuntime(
|
|
|
1549
1585
|
// used to carry the same rows, with the same summary repeated again in the
|
|
1550
1586
|
// footer status; one surface, below the prompt, replaces all three.
|
|
1551
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
|
+
|
|
1552
1642
|
let fleetViewEnabled = true;
|
|
1553
1643
|
function isFleetViewEnabled(): boolean {
|
|
1554
1644
|
return fleetViewEnabled;
|
|
@@ -1570,6 +1660,7 @@ function activateRootRuntime(
|
|
|
1570
1660
|
function seatFleet(ctx: ExtensionContext): boolean {
|
|
1571
1661
|
if (ctx.mode !== "tui" || !ctx.hasUI) return false;
|
|
1572
1662
|
fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
|
|
1663
|
+
fleet.setWorkflowClient(workflowUiClient);
|
|
1573
1664
|
return true;
|
|
1574
1665
|
}
|
|
1575
1666
|
|
|
@@ -1586,6 +1677,7 @@ function activateRootRuntime(
|
|
|
1586
1677
|
// session that has not run a tool yet. Re-seat from the live context so the
|
|
1587
1678
|
// guard and the draw target agree; the setter is idempotent.
|
|
1588
1679
|
fleet.setUICtx(currentCtx.ui as unknown as FleetUICtx);
|
|
1680
|
+
fleet.setWorkflowClient(workflowUiClient);
|
|
1589
1681
|
// Arm the tickers after the draw, not before: both tear their own timer down
|
|
1590
1682
|
// when they find nothing to show, and the fleet finds nothing until the child
|
|
1591
1683
|
// session lands a moment later. A spawn that entered through the manager has
|
|
@@ -1676,16 +1768,17 @@ function activateRootRuntime(
|
|
|
1676
1768
|
},
|
|
1677
1769
|
};
|
|
1678
1770
|
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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());
|
|
1682
1775
|
const outputTranscript =
|
|
1683
1776
|
getAgentConfig(dispatch.type)?.outputTranscript ??
|
|
1684
1777
|
getOutputTranscriptDefault();
|
|
1685
1778
|
let id = "";
|
|
1686
1779
|
const initializeRecord = (spawnedId: string): void => {
|
|
1687
1780
|
id = spawnedId;
|
|
1688
|
-
agentActivity.set(id, state);
|
|
1781
|
+
agentActivity.set(id, tracking().state);
|
|
1689
1782
|
const record = manager.getRecordMutable(id);
|
|
1690
1783
|
if (record && outputTranscript) {
|
|
1691
1784
|
record.outputFile = createOutputFilePath(
|
|
@@ -1703,12 +1796,31 @@ function activateRootRuntime(
|
|
|
1703
1796
|
fleet.ensureTimer();
|
|
1704
1797
|
fleet.update();
|
|
1705
1798
|
};
|
|
1706
|
-
const
|
|
1707
|
-
|
|
1708
|
-
|
|
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),
|
|
1709
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
|
+
},
|
|
1710
1822
|
onSessionCreated: (session: AgentSession) => {
|
|
1711
|
-
onSessionCreated(session);
|
|
1823
|
+
tracking().callbacks.onSessionCreated(session);
|
|
1712
1824
|
const record = manager.getRecordMutable(id);
|
|
1713
1825
|
if (record?.outputFile) {
|
|
1714
1826
|
record.outputCleanup = streamToOutputFile(
|
|
@@ -1734,11 +1846,9 @@ function activateRootRuntime(
|
|
|
1734
1846
|
// transcript, timer, or callback chain. The original Agent record owns all
|
|
1735
1847
|
// first-class UI and streaming state.
|
|
1736
1848
|
if (!managedResult.created) return managedResult;
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
initializeRecord(managedResult.id);
|
|
1741
|
-
}
|
|
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.
|
|
1742
1852
|
return managedResult;
|
|
1743
1853
|
}
|
|
1744
1854
|
|
|
@@ -3161,8 +3271,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3161
3271
|
let spinnerFrame = 0;
|
|
3162
3272
|
const startedAt = Date.now();
|
|
3163
3273
|
let fgId: string | undefined;
|
|
3274
|
+
let foregroundUpdates = true;
|
|
3275
|
+
let handedOff = false;
|
|
3164
3276
|
|
|
3165
3277
|
const streamUpdate = () => {
|
|
3278
|
+
fleet.update();
|
|
3279
|
+
if (!foregroundUpdates) return;
|
|
3166
3280
|
const details: AgentDetails = {
|
|
3167
3281
|
...detailBase,
|
|
3168
3282
|
toolUses: fgState.toolUses,
|
|
@@ -3248,21 +3362,30 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3248
3362
|
(fgAgentId) => {
|
|
3249
3363
|
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
3250
3364
|
// Set up the output file so streamToOutputFile can pick it up.
|
|
3365
|
+
fgId = fgAgentId;
|
|
3251
3366
|
const fgRec = manager.getRecordMutable(fgAgentId);
|
|
3367
|
+
if (fgRec) fgRec.toolCallId = toolCallId;
|
|
3252
3368
|
attachTranscript(fgRec, fgAgentId);
|
|
3253
3369
|
},
|
|
3254
3370
|
);
|
|
3255
3371
|
record = fgResult.record;
|
|
3372
|
+
handedOff = fgResult.handedOff === true;
|
|
3256
3373
|
} finally {
|
|
3257
|
-
//
|
|
3258
|
-
|
|
3374
|
+
// A transfer ends this tool's spinner, not the child's activity or stream.
|
|
3375
|
+
foregroundUpdates = false;
|
|
3259
3376
|
clearInterval(spinnerInterval);
|
|
3260
|
-
if (fgId) {
|
|
3377
|
+
if (fgId && !handedOff) {
|
|
3261
3378
|
agentActivity.delete(fgId);
|
|
3262
3379
|
fleet.onAgentFinished(fgId);
|
|
3263
3380
|
}
|
|
3264
3381
|
}
|
|
3265
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
|
+
|
|
3266
3389
|
// Get final token count
|
|
3267
3390
|
const tokenText = formatLifetimeTokens(fgState);
|
|
3268
3391
|
|
|
@@ -3559,6 +3682,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3559
3682
|
}
|
|
3560
3683
|
|
|
3561
3684
|
async function showAgentsMenu(ctx: ExtensionCommandContext) {
|
|
3685
|
+
const isCurrent = rootUiFence();
|
|
3686
|
+
if (!isCurrent()) return;
|
|
3562
3687
|
reloadCustomAgents();
|
|
3563
3688
|
const allNames = getAllTypes();
|
|
3564
3689
|
|
|
@@ -3606,31 +3731,31 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3606
3731
|
}
|
|
3607
3732
|
|
|
3608
3733
|
const choice = await ctx.ui.select("Agents", options);
|
|
3609
|
-
if (!choice) return;
|
|
3734
|
+
if (!choice || !isCurrent()) return;
|
|
3610
3735
|
|
|
3611
3736
|
if (choice.startsWith("Agent runs (")) {
|
|
3612
3737
|
await showAgentRuns(ctx);
|
|
3613
|
-
await showAgentsMenu(ctx);
|
|
3738
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3614
3739
|
} else if (choice.startsWith("Agent types (")) {
|
|
3615
3740
|
await showAllAgentsList(ctx);
|
|
3616
|
-
await showAgentsMenu(ctx);
|
|
3741
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3617
3742
|
} else if (choice.startsWith("Scheduled jobs (")) {
|
|
3618
3743
|
await showSchedulesMenu(ctx, scheduler);
|
|
3619
|
-
await showAgentsMenu(ctx);
|
|
3744
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3620
3745
|
} else if (choice.startsWith("Model tiers (")) {
|
|
3621
3746
|
await showModelTiersMenu(ctx);
|
|
3622
|
-
await showAgentsMenu(ctx);
|
|
3747
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3623
3748
|
} else if (choice === "Create new agent") {
|
|
3624
3749
|
await showCreateWizard(ctx);
|
|
3625
3750
|
} else if (choice === "Settings") {
|
|
3626
3751
|
await showSettings(ctx);
|
|
3627
|
-
await showAgentsMenu(ctx);
|
|
3752
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3628
3753
|
} else if (choice.startsWith("Usage (")) {
|
|
3629
3754
|
showSessionUsage(ctx);
|
|
3630
|
-
await showAgentsMenu(ctx);
|
|
3755
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3631
3756
|
} else if (choice === "Diagnostics") {
|
|
3632
3757
|
await showDiagnostics(ctx);
|
|
3633
|
-
await showAgentsMenu(ctx);
|
|
3758
|
+
if (isCurrent()) await showAgentsMenu(ctx);
|
|
3634
3759
|
}
|
|
3635
3760
|
}
|
|
3636
3761
|
|
|
@@ -3754,29 +3879,33 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3754
3879
|
}
|
|
3755
3880
|
|
|
3756
3881
|
async function showAgentRuns(ctx: ExtensionCommandContext) {
|
|
3757
|
-
const
|
|
3882
|
+
const isCurrent = rootUiFence();
|
|
3883
|
+
const agents = manager.listAgentsMutable().filter((a) => !a.parentAgentId && !a.detached)
|
|
3884
|
+
.map(record => captureAgentSelection(manager, record, isCurrent));
|
|
3758
3885
|
if (agents.length === 0) {
|
|
3759
3886
|
ctx.ui.notify("No agents.", "info");
|
|
3760
3887
|
return;
|
|
3761
3888
|
}
|
|
3762
3889
|
|
|
3763
|
-
const
|
|
3890
|
+
const selection = await selectItem(ctx.ui, "Agent runs", agents, ({ snapshot: agent }) => {
|
|
3764
3891
|
const dn = getDisplayName(agent.type);
|
|
3765
3892
|
const dur = formatDuration(agent.startedAt, agent.completedAt);
|
|
3766
3893
|
return `${dn} (${sanitizeDisplayText(agent.description)}) · ${agent.toolUses} tools · ${getAgentStatusLabel(agent.status)} · ${dur}`;
|
|
3767
3894
|
});
|
|
3768
|
-
if (!
|
|
3895
|
+
if (!selection || !isCurrent() || !selection.current()) return;
|
|
3769
3896
|
|
|
3770
|
-
await viewAgentConversation(ctx,
|
|
3771
|
-
// Back-navigation
|
|
3772
|
-
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);
|
|
3773
3900
|
}
|
|
3774
3901
|
|
|
3775
3902
|
async function viewAgentConversation(
|
|
3776
3903
|
ctx: ExtensionCommandContext,
|
|
3777
|
-
|
|
3904
|
+
selection: AgentSelection,
|
|
3778
3905
|
) {
|
|
3779
|
-
|
|
3906
|
+
const record = selection.snapshot;
|
|
3907
|
+
if (!selection.current()) return;
|
|
3908
|
+
if (!selection.session) {
|
|
3780
3909
|
if (record.status === "queued") {
|
|
3781
3910
|
const handleHint = record.handle
|
|
3782
3911
|
? ` Use @${record.handle} <message> to chat while it waits.`
|
|
@@ -3785,7 +3914,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3785
3914
|
"Queued agent",
|
|
3786
3915
|
`"${sanitizeDisplayText(record.description)}" has not started yet.${handleHint}\n\nStop this queued run?`,
|
|
3787
3916
|
);
|
|
3788
|
-
if (stop && manager.abort(record.id)) {
|
|
3917
|
+
if (stop && selection.current() && manager.abort(record.id)) {
|
|
3789
3918
|
ctx.ui.notify(
|
|
3790
3919
|
`Stopped queued agent "${sanitizeDisplayText(record.description)}".`,
|
|
3791
3920
|
"info",
|
|
@@ -3797,38 +3926,19 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3797
3926
|
return;
|
|
3798
3927
|
}
|
|
3799
3928
|
|
|
3800
|
-
|
|
3801
|
-
const
|
|
3802
|
-
|
|
3803
|
-
|
|
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>(
|
|
3804
3935
|
(tui, theme, keybindings, done) => {
|
|
3805
|
-
return
|
|
3806
|
-
|
|
3807
|
-
session,
|
|
3808
|
-
record,
|
|
3809
|
-
activity,
|
|
3810
|
-
theme,
|
|
3811
|
-
done,
|
|
3812
|
-
() => {
|
|
3813
|
-
if (manager.abort(record.id)) {
|
|
3814
|
-
ctx.ui.notify(
|
|
3815
|
-
`Stopped "${sanitizeDisplayText(record.description)}".`,
|
|
3816
|
-
"info",
|
|
3817
|
-
);
|
|
3818
|
-
}
|
|
3819
|
-
},
|
|
3820
|
-
keybindings,
|
|
3821
|
-
(message: string) => manager.steer(record.id, message),
|
|
3822
|
-
() => manager.getRecord(record.id),
|
|
3823
|
-
);
|
|
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);
|
|
3824
3938
|
},
|
|
3825
3939
|
{
|
|
3826
3940
|
overlay: true,
|
|
3827
|
-
overlayOptions: {
|
|
3828
|
-
anchor: "center",
|
|
3829
|
-
width: "90%",
|
|
3830
|
-
maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
|
|
3831
|
-
},
|
|
3941
|
+
overlayOptions: getConversationOverlayOptions({ mode: mode === "fullscreen" ? "fullscreen" : undefined }),
|
|
3832
3942
|
},
|
|
3833
3943
|
);
|
|
3834
3944
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { defineTool, type ExtensionContext, type ExtensionToolContext, type SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type ApprovalService, createApprovalToolDefinitions } from "@signalridge/pi-approval-core";
|
|
4
|
+
import { captureQuestionOrigin } from "./question-origin.js";
|
|
5
|
+
|
|
6
|
+
/** Human service capture and native context leases live for the entire child session. */
|
|
7
|
+
export function createChildApprovalTools(options: {
|
|
8
|
+
service: ApprovalService;
|
|
9
|
+
assertServiceCurrent: () => void;
|
|
10
|
+
root: ExtensionContext;
|
|
11
|
+
rootOrigin: (current: ExtensionContext) => boolean;
|
|
12
|
+
agentId?: string;
|
|
13
|
+
settings: SettingsManager;
|
|
14
|
+
waiting: (toolCallId: string) => () => void;
|
|
15
|
+
isBound: () => boolean;
|
|
16
|
+
isToolCurrent: (name: string) => boolean;
|
|
17
|
+
}): ToolDefinition[] {
|
|
18
|
+
const leases = new WeakMap<ExtensionToolContext, () => boolean>();
|
|
19
|
+
const [edit, write, bash] = createApprovalToolDefinitions({
|
|
20
|
+
source: `child:${options.agentId ?? randomUUID()}`,
|
|
21
|
+
shellPath: options.settings.getShellPath(),
|
|
22
|
+
commandPrefix: options.settings.getShellCommandPrefix(),
|
|
23
|
+
isToolCurrent: (_ctx, name) => options.isToolCurrent(name),
|
|
24
|
+
isCurrent: (ctx) => {
|
|
25
|
+
let lease = leases.get(ctx);
|
|
26
|
+
if (!lease) {
|
|
27
|
+
const childOrigin = captureQuestionOrigin(ctx);
|
|
28
|
+
const invocationRoot = captureQuestionOrigin(options.root);
|
|
29
|
+
lease = () => {
|
|
30
|
+
if (ctx.signal?.aborted || !options.isBound() || !options.rootOrigin(options.root) ||
|
|
31
|
+
!invocationRoot(options.root) || !childOrigin(ctx)) return false;
|
|
32
|
+
options.assertServiceCurrent();
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
leases.set(ctx, lease);
|
|
36
|
+
}
|
|
37
|
+
return lease();
|
|
38
|
+
},
|
|
39
|
+
request: async (operation, owner) => {
|
|
40
|
+
const resume = options.waiting(operation.toolCallId);
|
|
41
|
+
try {
|
|
42
|
+
await options.service.request(operation, owner);
|
|
43
|
+
} finally {
|
|
44
|
+
resume();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return [defineTool(edit), defineTool(write), defineTool(bash)];
|
|
49
|
+
}
|
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
|
}
|
package/src/types.ts
CHANGED
|
@@ -180,8 +180,9 @@ export interface AgentRecord {
|
|
|
180
180
|
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
|
|
181
181
|
compactionCount: number;
|
|
182
182
|
/**
|
|
183
|
-
* Whether this agent
|
|
184
|
-
*
|
|
183
|
+
* Whether this agent currently runs in the background. Tri-state, initially
|
|
184
|
+
* set from `SpawnOptions.isBackground`; a foreground handoff sets it to true.
|
|
185
|
+
* `true` = background, `false` =
|
|
185
186
|
* foreground (has an inline Agent tool-result surface), `undefined` = the
|
|
186
187
|
* caller never declared it (e.g. a cross-extension RPC spawn, which is detached
|
|
187
188
|
* and has no inline surface). The widget's background-only filter keys off this
|
|
@@ -190,7 +191,7 @@ export interface AgentRecord {
|
|
|
190
191
|
* which only the Agent-tool path populates.
|
|
191
192
|
*/
|
|
192
193
|
isBackground?: boolean;
|
|
193
|
-
/** Resolved spawn params
|
|
194
|
+
/** Resolved spawn params for UI display; handoff updates runInBackground. */
|
|
194
195
|
invocation?: AgentInvocation;
|
|
195
196
|
/** Nesting depth: top-level subagent = 1. */
|
|
196
197
|
depth?: number;
|