pi-sessions 0.8.0 → 0.10.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/README.md +74 -37
- package/dist/session-messaging/broker/process.js +234 -0
- package/dist/shared/session-broker/framing.js +58 -0
- package/dist/shared/session-broker/protocol.js +163 -0
- package/dist/shared/session-broker/socket-path.js +19 -0
- package/dist/shared/typebox.js +36 -0
- package/extensions/pi-sessions.ts +125 -0
- package/extensions/session-ask/agent.ts +34 -14
- package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
- package/extensions/session-ask/navigate.ts +3 -1
- package/extensions/session-ask/presenter.ts +23 -0
- package/extensions/session-ask/renderer.ts +63 -0
- package/extensions/session-ask/tool-contract.ts +27 -0
- package/extensions/session-ask/view-model.ts +18 -0
- package/extensions/session-auto-title/generate.ts +18 -18
- package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +60 -53
- package/extensions/session-auto-title/model.ts +25 -17
- package/extensions/session-auto-title/retitle.ts +17 -18
- package/extensions/session-auto-title/wizard.ts +12 -11
- package/extensions/session-handoff/board-view-model.ts +179 -0
- package/extensions/session-handoff/board.ts +663 -0
- package/extensions/session-handoff/bootstrap.ts +172 -0
- package/extensions/session-handoff/extract.ts +129 -132
- package/extensions/session-handoff/install.ts +225 -0
- package/extensions/session-handoff/kickoff.ts +92 -0
- package/extensions/session-handoff/launch/backend.ts +16 -0
- package/extensions/session-handoff/launch/deferred.ts +20 -0
- package/extensions/session-handoff/launch/ghostty.ts +99 -0
- package/extensions/session-handoff/launch/resolution.ts +57 -0
- package/extensions/session-handoff/launch/shell.ts +7 -0
- package/extensions/session-handoff/launch/tmux.ts +31 -0
- package/extensions/session-handoff/launch-options.ts +61 -0
- package/extensions/session-handoff/launch-target.ts +91 -0
- package/extensions/session-handoff/metadata.ts +194 -56
- package/extensions/session-handoff/model.ts +58 -0
- package/extensions/session-handoff/query.ts +0 -1
- package/extensions/session-handoff/receipt.ts +96 -0
- package/extensions/session-handoff/review.ts +2 -28
- package/extensions/session-handoff/spawn.ts +76 -181
- package/extensions/session-handoff/strong-modal.ts +13 -0
- package/extensions/session-handoff/tool-contract.ts +12 -0
- package/extensions/session-handoff/tool-presenter.ts +35 -0
- package/extensions/session-handoff/tool-renderer.ts +66 -0
- package/extensions/session-handoff/tool-schema.ts +54 -0
- package/extensions/session-handoff/tool-view-model.ts +49 -0
- package/extensions/session-handoff/tool.ts +206 -0
- package/extensions/session-handoff/ui.ts +66 -0
- package/extensions/session-hooks/install.ts +75 -0
- package/extensions/{session-index.ts → session-index/install.ts} +9 -7
- package/extensions/session-messaging/broker/process.ts +21 -28
- package/extensions/session-messaging/broker/spawn.ts +12 -7
- package/extensions/session-messaging/install.ts +78 -0
- package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
- package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
- package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
- package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
- package/extensions/session-messaging/pi/message-contracts.ts +32 -0
- package/extensions/session-messaging/pi/renderer.ts +19 -12
- package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
- package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
- package/extensions/session-messaging/pi/service.ts +262 -81
- package/extensions/session-messaging/pi/tools.ts +184 -40
- package/extensions/session-search/extract.ts +60 -14
- package/extensions/session-search/hooks.ts +10 -13
- package/extensions/session-search/install.ts +373 -0
- package/extensions/session-search/renderer.ts +195 -0
- package/extensions/session-search/tool-contract.ts +51 -0
- package/extensions/shared/composition.ts +20 -0
- package/extensions/shared/errors.ts +3 -0
- package/extensions/shared/model-resolution.ts +73 -0
- package/extensions/shared/model-runtime.ts +33 -0
- package/extensions/shared/model.ts +7 -10
- package/extensions/shared/rendering/collapsible-text.ts +89 -0
- package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
- package/extensions/shared/rendering/theme.ts +5 -0
- package/extensions/shared/session-broker/client.ts +30 -29
- package/extensions/shared/session-broker/protocol.ts +128 -10
- package/extensions/shared/session-index/common.ts +5 -5
- package/extensions/shared/session-index/lineage.ts +0 -3
- package/extensions/shared/session-index/schema.ts +0 -1
- package/extensions/shared/session-index/scoring.ts +1 -2
- package/extensions/shared/session-index/search.ts +11 -4
- package/extensions/shared/session-index/store.ts +4 -9
- package/extensions/shared/settings.ts +83 -40
- package/extensions/shared/text.ts +5 -0
- package/extensions/shared/thinking-levels.ts +22 -0
- package/extensions/shared/tmux.ts +205 -0
- package/extensions/subagents/cancel.ts +111 -0
- package/extensions/subagents/classify.ts +58 -0
- package/extensions/subagents/install.ts +229 -0
- package/extensions/subagents/launch-target.ts +75 -0
- package/extensions/subagents/ledger.ts +217 -0
- package/extensions/subagents/reconcile.ts +386 -0
- package/extensions/subagents/report-message-presenter.ts +36 -0
- package/extensions/subagents/report-message-renderer.ts +21 -0
- package/extensions/subagents/report-message-view-model.ts +29 -0
- package/extensions/subagents/report.ts +159 -0
- package/extensions/subagents/roster.ts +262 -0
- package/extensions/subagents/settle.ts +182 -0
- package/extensions/subagents/wake.ts +173 -0
- package/images/handoff-board-subagents.png +0 -0
- package/images/handoff-board-user-sessions.png +0 -0
- package/images/handoff-subagent-report.png +0 -0
- package/images/session-handoff-tool.png +0 -0
- package/package.json +16 -18
- package/extensions/session-handoff.ts +0 -741
- package/extensions/session-hooks.ts +0 -75
- package/extensions/session-messaging/pi/message-view.ts +0 -131
- package/extensions/session-messaging.ts +0 -30
- package/extensions/session-search.ts +0 -419
- package/extensions/shared/session-broker/active.ts +0 -26
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { CancelSessionResult } from "../session-messaging/install.ts";
|
|
3
|
+
import type { SubagentState } from "./classify.ts";
|
|
4
|
+
import {
|
|
5
|
+
findOwnedSubagentLaunch,
|
|
6
|
+
SUBAGENT_CANCELLED_CUSTOM_TYPE,
|
|
7
|
+
type SubagentLaunched,
|
|
8
|
+
} from "./ledger.ts";
|
|
9
|
+
import type { ReconcileResult } from "./reconcile.ts";
|
|
10
|
+
|
|
11
|
+
export interface ManagedSubagentCancelResult {
|
|
12
|
+
kind: "managed";
|
|
13
|
+
accepted: true;
|
|
14
|
+
target: {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
sessionName?: string;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
};
|
|
19
|
+
state: SubagentState;
|
|
20
|
+
cancelId?: string;
|
|
21
|
+
relation?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SubagentCancelResult = CancelSessionResult | ManagedSubagentCancelResult;
|
|
25
|
+
|
|
26
|
+
export interface CancelParentSession {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
epoch: number;
|
|
29
|
+
getBranch(): readonly SessionEntry[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CancelMessaging {
|
|
33
|
+
cancelSession(sessionId: string): Promise<CancelSessionResult>;
|
|
34
|
+
listSessions(): Promise<string[]>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CancelActions {
|
|
38
|
+
appendEntry(customType: string, data: unknown): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CancelReconciler {
|
|
42
|
+
reconcile(): Promise<ReconcileResult>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class SubagentCancellationRouter {
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly executor: CancelActions,
|
|
48
|
+
private readonly messaging: CancelMessaging,
|
|
49
|
+
private readonly reconciler: CancelReconciler,
|
|
50
|
+
private readonly getParent: () => CancelParentSession | undefined,
|
|
51
|
+
private readonly isCurrent: (epoch: number) => boolean,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
async cancelSession(sessionId: string): Promise<SubagentCancelResult> {
|
|
55
|
+
const parent = this.getParent();
|
|
56
|
+
const launch = parent
|
|
57
|
+
? findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, sessionId)
|
|
58
|
+
: undefined;
|
|
59
|
+
if (!parent || !launch) {
|
|
60
|
+
return this.messaging.cancelSession(sessionId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return this.cancelOwned(parent, launch);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async cancelOwned(
|
|
67
|
+
parent: CancelParentSession,
|
|
68
|
+
launch: SubagentLaunched,
|
|
69
|
+
): Promise<ManagedSubagentCancelResult> {
|
|
70
|
+
this.requireCurrent(parent);
|
|
71
|
+
this.executor.appendEntry(SUBAGENT_CANCELLED_CUSTOM_TYPE, {
|
|
72
|
+
writerSessionId: parent.sessionId,
|
|
73
|
+
childSessionId: launch.childSessionId,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let brokerResult: CancelSessionResult | undefined;
|
|
77
|
+
if ((await this.messaging.listSessions()).includes(launch.childSessionId)) {
|
|
78
|
+
this.requireCurrent(parent);
|
|
79
|
+
brokerResult = await this.messaging.cancelSession(launch.childSessionId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const reconciliation = await this.reconciler.reconcile();
|
|
83
|
+
return buildManagedResult(launch, brokerResult, reconciliation);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private requireCurrent(parent: CancelParentSession): void {
|
|
87
|
+
if (!this.isCurrent(parent.epoch)) {
|
|
88
|
+
throw new Error("The parent session changed while cancelling its subagent.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildManagedResult(
|
|
94
|
+
launch: SubagentLaunched,
|
|
95
|
+
brokerResult: CancelSessionResult | undefined,
|
|
96
|
+
reconciliation: ReconcileResult,
|
|
97
|
+
): ManagedSubagentCancelResult {
|
|
98
|
+
const delivered = brokerResult?.delivered ? brokerResult : undefined;
|
|
99
|
+
return {
|
|
100
|
+
kind: "managed",
|
|
101
|
+
accepted: true,
|
|
102
|
+
target: {
|
|
103
|
+
sessionId: launch.childSessionId,
|
|
104
|
+
sessionName: delivered?.target.sessionName ?? launch.title,
|
|
105
|
+
...(delivered?.target.cwd ? { cwd: delivered.target.cwd } : {}),
|
|
106
|
+
},
|
|
107
|
+
state: reconciliation.states.get(launch.childSessionId) ?? "unknown",
|
|
108
|
+
...(brokerResult ? { cancelId: brokerResult.cancelId } : {}),
|
|
109
|
+
...(delivered?.relation ? { relation: delivered.relation } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type SubagentState =
|
|
2
|
+
| "starting"
|
|
3
|
+
| "busy"
|
|
4
|
+
| "active"
|
|
5
|
+
| "completed"
|
|
6
|
+
| "stopping"
|
|
7
|
+
| "stopped"
|
|
8
|
+
| "suspended"
|
|
9
|
+
| "interrupted"
|
|
10
|
+
| "unknown";
|
|
11
|
+
|
|
12
|
+
const RUNNING_SUBAGENT_STATES: ReadonlySet<SubagentState> = new Set(["starting", "busy", "active"]);
|
|
13
|
+
|
|
14
|
+
export interface SubagentEvidence {
|
|
15
|
+
hasWindow: boolean;
|
|
16
|
+
brokerLive: boolean;
|
|
17
|
+
hasRegistered: boolean;
|
|
18
|
+
awaitingKickoff: boolean;
|
|
19
|
+
cancelled: boolean;
|
|
20
|
+
suspended: boolean;
|
|
21
|
+
hasReportOrClosure: boolean;
|
|
22
|
+
childReadable: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isRunningSubagentState(state: SubagentState): boolean {
|
|
26
|
+
return RUNNING_SUBAGENT_STATES.has(state);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function classifySubagent(evidence: SubagentEvidence): SubagentState {
|
|
30
|
+
if (!evidence.childReadable) {
|
|
31
|
+
return "unknown";
|
|
32
|
+
}
|
|
33
|
+
if (evidence.hasWindow && evidence.cancelled) {
|
|
34
|
+
return "stopping";
|
|
35
|
+
}
|
|
36
|
+
if (evidence.awaitingKickoff) {
|
|
37
|
+
return "starting";
|
|
38
|
+
}
|
|
39
|
+
if (evidence.hasWindow && !evidence.hasRegistered) {
|
|
40
|
+
return "starting";
|
|
41
|
+
}
|
|
42
|
+
if (evidence.hasWindow) {
|
|
43
|
+
return "busy";
|
|
44
|
+
}
|
|
45
|
+
if (evidence.brokerLive) {
|
|
46
|
+
return "active";
|
|
47
|
+
}
|
|
48
|
+
if (evidence.hasReportOrClosure) {
|
|
49
|
+
return "completed";
|
|
50
|
+
}
|
|
51
|
+
if (evidence.cancelled) {
|
|
52
|
+
return "stopped";
|
|
53
|
+
}
|
|
54
|
+
if (evidence.suspended) {
|
|
55
|
+
return "suspended";
|
|
56
|
+
}
|
|
57
|
+
return "interrupted";
|
|
58
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { ExtensionAPI, SessionTreeNode } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { HandoffLaunchTarget } from "../session-handoff/launch-target.ts";
|
|
3
|
+
import type {
|
|
4
|
+
MessagingHandle,
|
|
5
|
+
SendMessageRequest,
|
|
6
|
+
SendMessageResult,
|
|
7
|
+
} from "../session-messaging/install.ts";
|
|
8
|
+
import type { SessionLifecycle } from "../shared/composition.ts";
|
|
9
|
+
import type { SessionSettings } from "../shared/settings.ts";
|
|
10
|
+
import { isTmuxInstalled } from "../shared/tmux.ts";
|
|
11
|
+
import { SubagentCancellationRouter, type SubagentCancelResult } from "./cancel.ts";
|
|
12
|
+
import { createSubagentLaunchTarget, type SubagentLaunchState } from "./launch-target.ts";
|
|
13
|
+
import {
|
|
14
|
+
hasSubagentLaunchEntries,
|
|
15
|
+
SUBAGENT_LAUNCHED_CUSTOM_TYPE,
|
|
16
|
+
SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
|
|
17
|
+
SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE,
|
|
18
|
+
} from "./ledger.ts";
|
|
19
|
+
import { openReconcileSession, SubagentReconciler } from "./reconcile.ts";
|
|
20
|
+
import {
|
|
21
|
+
buildIncomingSubagentReport,
|
|
22
|
+
createSubmitTaskReportTool,
|
|
23
|
+
type SubagentParentSession,
|
|
24
|
+
} from "./report.ts";
|
|
25
|
+
import { renderSubagentReportMessage } from "./report-message-renderer.ts";
|
|
26
|
+
import { openRosterSession, type SubagentRoster, TranscriptSubagentRoster } from "./roster.ts";
|
|
27
|
+
import {
|
|
28
|
+
countSubagentReports,
|
|
29
|
+
createSettledChildLifecycle,
|
|
30
|
+
findSelfSubagentIdentity,
|
|
31
|
+
type SubagentChildSessionState,
|
|
32
|
+
} from "./settle.ts";
|
|
33
|
+
import { SubagentMessageRouter } from "./wake.ts";
|
|
34
|
+
|
|
35
|
+
interface ParentSessionState extends SubagentParentSession {
|
|
36
|
+
getSessionName(): string | undefined;
|
|
37
|
+
launchState: SubagentLaunchState;
|
|
38
|
+
tmuxInstalled: boolean;
|
|
39
|
+
getTree(): SessionTreeNode[];
|
|
40
|
+
hasPendingMessages(): boolean;
|
|
41
|
+
shutdown(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CurrentSubagentSession {
|
|
45
|
+
parent: ParentSessionState;
|
|
46
|
+
child?: SubagentChildSessionState | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SubagentsHandle extends SessionLifecycle {
|
|
50
|
+
roster: SubagentRoster;
|
|
51
|
+
getLaunchTargets(): readonly HandoffLaunchTarget[];
|
|
52
|
+
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
53
|
+
cancelSession(sessionId: string): Promise<SubagentCancelResult>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function installSubagents(
|
|
57
|
+
pi: ExtensionAPI,
|
|
58
|
+
deps: { settings: SessionSettings; messaging: MessagingHandle },
|
|
59
|
+
): SubagentsHandle {
|
|
60
|
+
pi.registerMessageRenderer(SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE, renderSubagentReportMessage);
|
|
61
|
+
|
|
62
|
+
let epoch = 0;
|
|
63
|
+
let current: CurrentSubagentSession | undefined;
|
|
64
|
+
|
|
65
|
+
const isCurrentSession = (candidateEpoch: number): boolean =>
|
|
66
|
+
current?.parent.epoch === candidateEpoch;
|
|
67
|
+
const reconciler = new SubagentReconciler({
|
|
68
|
+
executor: pi,
|
|
69
|
+
messaging: deps.messaging,
|
|
70
|
+
getParent: () => current?.parent,
|
|
71
|
+
isCurrent: isCurrentSession,
|
|
72
|
+
openSession: openReconcileSession,
|
|
73
|
+
});
|
|
74
|
+
const settledChildLifecycle = createSettledChildLifecycle(pi, reconciler, isCurrentSession);
|
|
75
|
+
const roster = new TranscriptSubagentRoster({
|
|
76
|
+
executor: pi,
|
|
77
|
+
messaging: deps.messaging,
|
|
78
|
+
getParent: () => current?.parent,
|
|
79
|
+
reconcile: () => reconciler.reconcile(),
|
|
80
|
+
openSession: openRosterSession,
|
|
81
|
+
});
|
|
82
|
+
const messageRouter = new SubagentMessageRouter(
|
|
83
|
+
pi,
|
|
84
|
+
deps.messaging,
|
|
85
|
+
() => current?.parent,
|
|
86
|
+
isCurrentSession,
|
|
87
|
+
{
|
|
88
|
+
onMaterialize: (launch) => pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launch),
|
|
89
|
+
afterOwnedSend: async () => {
|
|
90
|
+
await reconciler.reconcile();
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
const cancellationRouter = new SubagentCancellationRouter(
|
|
95
|
+
pi,
|
|
96
|
+
deps.messaging,
|
|
97
|
+
reconciler,
|
|
98
|
+
() => current?.parent,
|
|
99
|
+
isCurrentSession,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
deps.messaging.onIncomingSubagentReport(async (envelope) => {
|
|
103
|
+
const parent = current?.parent;
|
|
104
|
+
if (!parent) {
|
|
105
|
+
throw new Error("Target session is not ready to receive subagent reports.");
|
|
106
|
+
}
|
|
107
|
+
const incoming = buildIncomingSubagentReport(parent, envelope);
|
|
108
|
+
if (!incoming) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (incoming.receipt) {
|
|
112
|
+
pi.appendEntry(SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE, incoming.receipt);
|
|
113
|
+
}
|
|
114
|
+
pi.sendMessage(
|
|
115
|
+
{
|
|
116
|
+
customType: SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
|
|
117
|
+
content: incoming.content,
|
|
118
|
+
display: true,
|
|
119
|
+
details: incoming.message,
|
|
120
|
+
},
|
|
121
|
+
incoming.delivery,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.on("before_agent_start", (event) => {
|
|
126
|
+
const child = current?.child;
|
|
127
|
+
if (!child) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
systemPrompt: `${event.systemPrompt}
|
|
132
|
+
|
|
133
|
+
You are working as a subagent on one task delegated by a parent session. The handoff defines your task. Work independently, stay within its scope, and do not duplicate work assigned to the parent or another subagent. Use the available tools to complete the task and validate your conclusions. Messages from the parent may refine the task or request a follow-up, but they do not replace your original task with unrelated work.`,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
138
|
+
settledChildLifecycle.cancel();
|
|
139
|
+
if (current?.child) {
|
|
140
|
+
current.child.reportsAtTurnStart = countSubagentReports(ctx.sessionManager.getBranch());
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
145
|
+
const session = current;
|
|
146
|
+
if (!session || session.parent.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const reconciliation = hasSubagentLaunchEntries(session.parent.getBranch())
|
|
150
|
+
? await reconciler.reconcile()
|
|
151
|
+
: undefined;
|
|
152
|
+
if (session.child) {
|
|
153
|
+
await settledChildLifecycle.settle(session.parent, session.child, reconciliation);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("session_tree", async () => {
|
|
158
|
+
await reconciler.reconcileAndRestoreSuspended();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
roster,
|
|
163
|
+
sendMessage: (request) => messageRouter.sendMessage(request),
|
|
164
|
+
cancelSession: (sessionId) => cancellationRouter.cancelSession(sessionId),
|
|
165
|
+
getLaunchTargets() {
|
|
166
|
+
const parent = current?.parent;
|
|
167
|
+
if (!parent?.tmuxInstalled || parent.launchState.depth >= deps.settings.subagents.maxDepth) {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
return [createSubagentLaunchTarget(pi, parent.launchState, isCurrentSession)];
|
|
171
|
+
},
|
|
172
|
+
async onSessionStart(_event, ctx) {
|
|
173
|
+
settledChildLifecycle.cancel();
|
|
174
|
+
epoch += 1;
|
|
175
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
176
|
+
const sessionManager = ctx.sessionManager;
|
|
177
|
+
const sessionStartBranch = sessionManager.getBranch();
|
|
178
|
+
const identity = findSelfSubagentIdentity(sessionId, sessionStartBranch);
|
|
179
|
+
const parent: ParentSessionState = {
|
|
180
|
+
sessionId,
|
|
181
|
+
getSessionName: () => sessionManager.getSessionName(),
|
|
182
|
+
getBranch: () => sessionManager.getBranch(),
|
|
183
|
+
getTree: () => sessionManager.getTree(),
|
|
184
|
+
isIdle: ctx.isIdle,
|
|
185
|
+
hasPendingMessages: ctx.hasPendingMessages,
|
|
186
|
+
shutdown: ctx.shutdown,
|
|
187
|
+
epoch,
|
|
188
|
+
launchState: { sessionId, depth: identity?.depth ?? 0, epoch },
|
|
189
|
+
tmuxInstalled: await isTmuxInstalled(pi, ctx.cwd),
|
|
190
|
+
};
|
|
191
|
+
reconciler.beginSession();
|
|
192
|
+
current = {
|
|
193
|
+
parent,
|
|
194
|
+
...(identity
|
|
195
|
+
? {
|
|
196
|
+
child: {
|
|
197
|
+
identity,
|
|
198
|
+
requestResponse: identity.requestResponse,
|
|
199
|
+
reportsAtTurnStart: countSubagentReports(sessionStartBranch),
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
203
|
+
};
|
|
204
|
+
if (identity) {
|
|
205
|
+
pi.registerTool(
|
|
206
|
+
createSubmitTaskReportTool(pi, deps.messaging, () => {
|
|
207
|
+
const session = current;
|
|
208
|
+
return session?.child
|
|
209
|
+
? { ...session.parent, identity: session.child.identity }
|
|
210
|
+
: undefined;
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (_event.reason === "reload") {
|
|
215
|
+
await reconciler.reconcile();
|
|
216
|
+
} else {
|
|
217
|
+
await reconciler.reconcileAndRestoreSuspended();
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
async onSessionShutdown(event) {
|
|
221
|
+
settledChildLifecycle.cancel();
|
|
222
|
+
if (current && event.reason !== "reload") {
|
|
223
|
+
await reconciler.suspendForShutdown();
|
|
224
|
+
}
|
|
225
|
+
epoch += 1;
|
|
226
|
+
current = undefined;
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type HandoffLaunchTarget, SUBAGENT_LAUNCH } from "../session-handoff/launch-target.ts";
|
|
3
|
+
import { formatError } from "../shared/errors.ts";
|
|
4
|
+
import { createTmuxWindow, tmuxSessionName } from "../shared/tmux.ts";
|
|
5
|
+
import { SUBAGENT_LAUNCHED_CUSTOM_TYPE, type SubagentLaunched } from "./ledger.ts";
|
|
6
|
+
|
|
7
|
+
export interface SubagentLaunchState {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
depth: number;
|
|
10
|
+
epoch: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createSubagentLaunchTarget(
|
|
14
|
+
pi: ExtensionAPI,
|
|
15
|
+
state: SubagentLaunchState,
|
|
16
|
+
isCurrent: (epoch: number) => boolean,
|
|
17
|
+
): HandoffLaunchTarget {
|
|
18
|
+
return {
|
|
19
|
+
value: SUBAGENT_LAUNCH,
|
|
20
|
+
description:
|
|
21
|
+
"'subagent' delegates one task to a detached tmux worker and requests a report by default.",
|
|
22
|
+
requestResponseDefault: true,
|
|
23
|
+
bootstrapMode: "automatic",
|
|
24
|
+
describeSubagentChild(input) {
|
|
25
|
+
return {
|
|
26
|
+
childSessionId: input.childSessionId,
|
|
27
|
+
ownerSessionId: input.ownerSessionId,
|
|
28
|
+
depth: state.depth + 1,
|
|
29
|
+
requestResponse: input.requestResponse,
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
prepareChild(input) {
|
|
33
|
+
requireCurrentParent(state, input.parentSessionId, isCurrent);
|
|
34
|
+
},
|
|
35
|
+
async launch(input) {
|
|
36
|
+
requireCurrentParent(state, input.parentSessionId, isCurrent);
|
|
37
|
+
const launched: SubagentLaunched = {
|
|
38
|
+
writerSessionId: state.sessionId,
|
|
39
|
+
childSessionId: input.prepared.sessionId,
|
|
40
|
+
childSessionFile: input.prepared.sessionFile,
|
|
41
|
+
title: input.title,
|
|
42
|
+
goal: input.goal,
|
|
43
|
+
requestResponse: input.requestResponse,
|
|
44
|
+
model: input.model,
|
|
45
|
+
cwd: input.cwd,
|
|
46
|
+
resumeCommand: input.prepared.resumeCommand,
|
|
47
|
+
depth: state.depth + 1,
|
|
48
|
+
};
|
|
49
|
+
pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launched);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await createTmuxWindow(pi, {
|
|
53
|
+
tmuxSession: tmuxSessionName(state.sessionId),
|
|
54
|
+
name: input.title,
|
|
55
|
+
cwd: input.cwd,
|
|
56
|
+
command: input.prepared.resumeCommand,
|
|
57
|
+
piSessionId: input.prepared.sessionId,
|
|
58
|
+
});
|
|
59
|
+
return { success: true, backend: "tmux" };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { success: false, error: formatError(error) };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireCurrentParent(
|
|
68
|
+
state: SubagentLaunchState,
|
|
69
|
+
parentSessionId: string,
|
|
70
|
+
isCurrent: (epoch: number) => boolean,
|
|
71
|
+
): void {
|
|
72
|
+
if (!isCurrent(state.epoch) || state.sessionId !== parentSessionId) {
|
|
73
|
+
throw new Error("The parent session changed before subagent launch completed.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import { TASK_REPORT_SCHEMA } from "../shared/session-broker/protocol.ts";
|
|
4
|
+
import { safeParseTypeBoxValue } from "../shared/typebox.ts";
|
|
5
|
+
|
|
6
|
+
export const SUBAGENT_LAUNCHED_CUSTOM_TYPE = "pi-sessions.subagent_launched";
|
|
7
|
+
export const SUBAGENT_REPORT_CUSTOM_TYPE = "pi-sessions.subagent_report";
|
|
8
|
+
export const SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE = "pi-sessions.subagent_report_received";
|
|
9
|
+
export const SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE = "pi-sessions.subagent_report_message";
|
|
10
|
+
export const SUBAGENT_CLOSED_CUSTOM_TYPE = "pi-sessions.subagent_closed";
|
|
11
|
+
export const SUBAGENT_REPORT_REMINDER_MESSAGE_CUSTOM_TYPE = "pi-sessions.report_reminder_message";
|
|
12
|
+
export const SUBAGENT_DISOWNED_MESSAGE_CUSTOM_TYPE = "pi-sessions.subagent_disowned_message";
|
|
13
|
+
export const SUBAGENT_CANCELLED_CUSTOM_TYPE = "pi-sessions.subagent_cancelled";
|
|
14
|
+
export const SUBAGENT_SUSPENDED_CUSTOM_TYPE = "pi-sessions.subagent_suspended";
|
|
15
|
+
|
|
16
|
+
export const SUBAGENT_LAUNCHED_SCHEMA = Type.Object({
|
|
17
|
+
writerSessionId: Type.String(),
|
|
18
|
+
childSessionId: Type.String(),
|
|
19
|
+
childSessionFile: Type.String(),
|
|
20
|
+
title: Type.String(),
|
|
21
|
+
goal: Type.String(),
|
|
22
|
+
requestResponse: Type.Boolean(),
|
|
23
|
+
model: Type.Optional(Type.String()),
|
|
24
|
+
cwd: Type.String(),
|
|
25
|
+
resumeCommand: Type.String(),
|
|
26
|
+
depth: Type.Integer({ minimum: 1 }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const SUBAGENT_REPORT_SCHEMA = Type.Intersect([
|
|
30
|
+
Type.Object({ reportId: Type.String() }),
|
|
31
|
+
TASK_REPORT_SCHEMA,
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const SUBAGENT_REPORT_RECEIVED_SCHEMA = Type.Object({
|
|
35
|
+
writerSessionId: Type.String(),
|
|
36
|
+
childSessionId: Type.String(),
|
|
37
|
+
reportId: Type.String(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const SUBAGENT_REPORT_MESSAGE_SCHEMA = Type.Intersect([
|
|
41
|
+
Type.Object({
|
|
42
|
+
writerSessionId: Type.String(),
|
|
43
|
+
childSessionId: Type.String(),
|
|
44
|
+
reportId: Type.String(),
|
|
45
|
+
title: Type.Optional(Type.String()),
|
|
46
|
+
provenance: Type.Union([Type.Literal("live"), Type.Literal("recovered")]),
|
|
47
|
+
}),
|
|
48
|
+
TASK_REPORT_SCHEMA,
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export const SUBAGENT_CLOSED_SCHEMA = Type.Object({
|
|
52
|
+
reason: Type.Union([
|
|
53
|
+
Type.Literal("no_response_expected"),
|
|
54
|
+
Type.Literal("no_report_after_reminder"),
|
|
55
|
+
]),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const SUBAGENT_CANCELLED_SCHEMA = Type.Object({
|
|
59
|
+
writerSessionId: Type.String(),
|
|
60
|
+
childSessionId: Type.String(),
|
|
61
|
+
});
|
|
62
|
+
export const SUBAGENT_SUSPENDED_SCHEMA = Type.Object({
|
|
63
|
+
writerSessionId: Type.String(),
|
|
64
|
+
childSessionIds: Type.Array(Type.String()),
|
|
65
|
+
});
|
|
66
|
+
export const SUBAGENT_DISOWNED_MESSAGE_SCHEMA = Type.Object({
|
|
67
|
+
writerSessionId: Type.String(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export type SubagentLaunched = Static<typeof SUBAGENT_LAUNCHED_SCHEMA>;
|
|
71
|
+
export type SubagentReport = Static<typeof SUBAGENT_REPORT_SCHEMA>;
|
|
72
|
+
export type SubagentReportReceived = Static<typeof SUBAGENT_REPORT_RECEIVED_SCHEMA>;
|
|
73
|
+
export type SubagentReportMessage = Static<typeof SUBAGENT_REPORT_MESSAGE_SCHEMA>;
|
|
74
|
+
export type SubagentClosed = Static<typeof SUBAGENT_CLOSED_SCHEMA>;
|
|
75
|
+
|
|
76
|
+
export interface ChildSubagentLifecycle {
|
|
77
|
+
reports: readonly SubagentReport[];
|
|
78
|
+
closed: SubagentClosed | undefined;
|
|
79
|
+
hasReminder: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ParentSubagentLaunch extends SubagentLaunched {
|
|
83
|
+
launchedAt: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ParentSubagentLedger {
|
|
87
|
+
launches: readonly ParentSubagentLaunch[];
|
|
88
|
+
cancelledChildIds: ReadonlySet<string>;
|
|
89
|
+
suspendedChildIds: ReadonlySet<string>;
|
|
90
|
+
receivedReportIds: ReadonlySet<string>;
|
|
91
|
+
deliveredReportIds: ReadonlySet<string>;
|
|
92
|
+
hasForeignLaunch: boolean;
|
|
93
|
+
hasDisownedNotice: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function hasSubagentLaunchEntries(branch: readonly SessionEntry[]): boolean {
|
|
97
|
+
return branch.some(
|
|
98
|
+
(entry) => entry.type === "custom" && entry.customType === SUBAGENT_LAUNCHED_CUSTOM_TYPE,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function findOwnedSubagentLaunch(
|
|
103
|
+
branch: readonly SessionEntry[],
|
|
104
|
+
ownerSessionId: string,
|
|
105
|
+
childSessionId: string,
|
|
106
|
+
): ParentSubagentLaunch | undefined {
|
|
107
|
+
return collectParentLedger(branch, ownerSessionId).launches.find(
|
|
108
|
+
(launch) => launch.childSessionId === childSessionId,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function collectParentLedger(
|
|
113
|
+
branch: readonly SessionEntry[],
|
|
114
|
+
ownerSessionId: string,
|
|
115
|
+
): ParentSubagentLedger {
|
|
116
|
+
const launches = new Map<string, ParentSubagentLaunch>();
|
|
117
|
+
const cancelledChildIds = new Set<string>();
|
|
118
|
+
const suspendedChildIds = new Set<string>();
|
|
119
|
+
const receivedReportIds = new Set<string>();
|
|
120
|
+
const deliveredReportIds = new Set<string>();
|
|
121
|
+
let hasForeignLaunch = false;
|
|
122
|
+
let hasDisownedNotice = false;
|
|
123
|
+
|
|
124
|
+
for (const entry of branch) {
|
|
125
|
+
if (entry.type === "custom_message") {
|
|
126
|
+
if (entry.customType === SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE) {
|
|
127
|
+
const report = safeParseTypeBoxValue(SUBAGENT_REPORT_MESSAGE_SCHEMA, entry.details);
|
|
128
|
+
if (report?.writerSessionId === ownerSessionId) {
|
|
129
|
+
deliveredReportIds.add(report.reportId);
|
|
130
|
+
}
|
|
131
|
+
} else if (entry.customType === SUBAGENT_DISOWNED_MESSAGE_CUSTOM_TYPE) {
|
|
132
|
+
const notice = safeParseTypeBoxValue(SUBAGENT_DISOWNED_MESSAGE_SCHEMA, entry.details);
|
|
133
|
+
hasDisownedNotice ||= notice?.writerSessionId === ownerSessionId;
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (entry.type !== "custom") {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (entry.customType === SUBAGENT_LAUNCHED_CUSTOM_TYPE) {
|
|
142
|
+
const launch = safeParseTypeBoxValue(SUBAGENT_LAUNCHED_SCHEMA, entry.data);
|
|
143
|
+
if (!launch) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (launch.writerSessionId !== ownerSessionId) {
|
|
147
|
+
hasForeignLaunch = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
launches.set(launch.childSessionId, { ...launch, launchedAt: entry.timestamp });
|
|
151
|
+
cancelledChildIds.delete(launch.childSessionId);
|
|
152
|
+
suspendedChildIds.delete(launch.childSessionId);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (entry.customType === SUBAGENT_CANCELLED_CUSTOM_TYPE) {
|
|
157
|
+
const cancellation = safeParseTypeBoxValue(SUBAGENT_CANCELLED_SCHEMA, entry.data);
|
|
158
|
+
if (cancellation?.writerSessionId === ownerSessionId) {
|
|
159
|
+
cancelledChildIds.add(cancellation.childSessionId);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (entry.customType === SUBAGENT_SUSPENDED_CUSTOM_TYPE) {
|
|
165
|
+
const suspension = safeParseTypeBoxValue(SUBAGENT_SUSPENDED_SCHEMA, entry.data);
|
|
166
|
+
if (suspension?.writerSessionId === ownerSessionId) {
|
|
167
|
+
for (const childSessionId of suspension.childSessionIds) {
|
|
168
|
+
suspendedChildIds.add(childSessionId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (entry.customType === SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE) {
|
|
175
|
+
const receipt = safeParseTypeBoxValue(SUBAGENT_REPORT_RECEIVED_SCHEMA, entry.data);
|
|
176
|
+
if (receipt?.writerSessionId === ownerSessionId) {
|
|
177
|
+
receivedReportIds.add(receipt.reportId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
launches: [...launches.values()],
|
|
184
|
+
cancelledChildIds,
|
|
185
|
+
suspendedChildIds,
|
|
186
|
+
receivedReportIds,
|
|
187
|
+
deliveredReportIds,
|
|
188
|
+
hasForeignLaunch,
|
|
189
|
+
hasDisownedNotice,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function getChildSubagentLifecycle(branch: readonly SessionEntry[]): ChildSubagentLifecycle {
|
|
194
|
+
const reports = new Map<string, SubagentReport>();
|
|
195
|
+
let closed: SubagentClosed | undefined;
|
|
196
|
+
let hasReminder = false;
|
|
197
|
+
|
|
198
|
+
for (const entry of branch) {
|
|
199
|
+
if (entry.type === "custom_message") {
|
|
200
|
+
hasReminder ||= entry.customType === SUBAGENT_REPORT_REMINDER_MESSAGE_CUSTOM_TYPE;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (entry.type !== "custom") {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (entry.customType === SUBAGENT_REPORT_CUSTOM_TYPE) {
|
|
207
|
+
const report = safeParseTypeBoxValue(SUBAGENT_REPORT_SCHEMA, entry.data);
|
|
208
|
+
if (report) {
|
|
209
|
+
reports.set(report.reportId, report);
|
|
210
|
+
}
|
|
211
|
+
} else if (entry.customType === SUBAGENT_CLOSED_CUSTOM_TYPE) {
|
|
212
|
+
closed = safeParseTypeBoxValue(SUBAGENT_CLOSED_SCHEMA, entry.data) ?? closed;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { reports: [...reports.values()], closed, hasReminder };
|
|
217
|
+
}
|