pi-sessions 0.7.2 → 0.9.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 +71 -37
- 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} +67 -46
- package/extensions/session-auto-title/model.ts +25 -17
- package/extensions/session-auto-title/retitle.ts +26 -12
- package/extensions/session-auto-title/wizard.ts +12 -6
- package/extensions/session-handoff/board-view-model.ts +181 -0
- package/extensions/session-handoff/board.ts +663 -0
- package/extensions/session-handoff/bootstrap.ts +174 -0
- package/extensions/session-handoff/extract.ts +101 -122
- 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 +152 -63
- 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/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 +80 -38
- 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 +52 -0
- package/extensions/subagents/install.ts +344 -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/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 +10 -16
- 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,52 @@
|
|
|
1
|
+
export type SubagentState =
|
|
2
|
+
| "starting"
|
|
3
|
+
| "busy"
|
|
4
|
+
| "active"
|
|
5
|
+
| "completed"
|
|
6
|
+
| "stopping"
|
|
7
|
+
| "stopped"
|
|
8
|
+
| "suspended"
|
|
9
|
+
| "interrupted"
|
|
10
|
+
| "unknown";
|
|
11
|
+
|
|
12
|
+
export interface SubagentEvidence {
|
|
13
|
+
hasWindow: boolean;
|
|
14
|
+
brokerLive: boolean;
|
|
15
|
+
hasRegistered: boolean;
|
|
16
|
+
awaitingKickoff: boolean;
|
|
17
|
+
cancelled: boolean;
|
|
18
|
+
suspended: boolean;
|
|
19
|
+
hasReportOrClosure: boolean;
|
|
20
|
+
childReadable: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function classifySubagent(evidence: SubagentEvidence): SubagentState {
|
|
24
|
+
if (!evidence.childReadable) {
|
|
25
|
+
return "unknown";
|
|
26
|
+
}
|
|
27
|
+
if (evidence.hasWindow && evidence.cancelled) {
|
|
28
|
+
return "stopping";
|
|
29
|
+
}
|
|
30
|
+
if (evidence.awaitingKickoff) {
|
|
31
|
+
return "starting";
|
|
32
|
+
}
|
|
33
|
+
if (evidence.hasWindow && !evidence.hasRegistered) {
|
|
34
|
+
return "starting";
|
|
35
|
+
}
|
|
36
|
+
if (evidence.hasWindow) {
|
|
37
|
+
return "busy";
|
|
38
|
+
}
|
|
39
|
+
if (evidence.brokerLive) {
|
|
40
|
+
return "active";
|
|
41
|
+
}
|
|
42
|
+
if (evidence.hasReportOrClosure) {
|
|
43
|
+
return "completed";
|
|
44
|
+
}
|
|
45
|
+
if (evidence.cancelled) {
|
|
46
|
+
return "stopped";
|
|
47
|
+
}
|
|
48
|
+
if (evidence.suspended) {
|
|
49
|
+
return "suspended";
|
|
50
|
+
}
|
|
51
|
+
return "interrupted";
|
|
52
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { ExtensionAPI, SessionEntry, SessionTreeNode } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type HandoffLaunchTarget, SUBAGENT_LAUNCH } from "../session-handoff/launch-target.ts";
|
|
3
|
+
import {
|
|
4
|
+
getHandoffMetadataFromEntries,
|
|
5
|
+
type HandoffSubagent,
|
|
6
|
+
} from "../session-handoff/metadata.ts";
|
|
7
|
+
import type {
|
|
8
|
+
MessagingHandle,
|
|
9
|
+
SendMessageRequest,
|
|
10
|
+
SendMessageResult,
|
|
11
|
+
} from "../session-messaging/install.ts";
|
|
12
|
+
import type { SessionLifecycle } from "../shared/composition.ts";
|
|
13
|
+
import type { SessionSettings } from "../shared/settings.ts";
|
|
14
|
+
import { hasAttachedTmuxClients, isTmuxInstalled, tmuxSessionName } from "../shared/tmux.ts";
|
|
15
|
+
import { SubagentCancellationRouter, type SubagentCancelResult } from "./cancel.ts";
|
|
16
|
+
import { createSubagentLaunchTarget, type SubagentLaunchState } from "./launch-target.ts";
|
|
17
|
+
import {
|
|
18
|
+
getChildSubagentLifecycle,
|
|
19
|
+
hasSubagentLaunchEntries,
|
|
20
|
+
SUBAGENT_CLOSED_CUSTOM_TYPE,
|
|
21
|
+
SUBAGENT_LAUNCHED_CUSTOM_TYPE,
|
|
22
|
+
SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
|
|
23
|
+
SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE,
|
|
24
|
+
SUBAGENT_REPORT_REMINDER_MESSAGE_CUSTOM_TYPE,
|
|
25
|
+
} from "./ledger.ts";
|
|
26
|
+
import { openReconcileSession, SubagentReconciler } from "./reconcile.ts";
|
|
27
|
+
import {
|
|
28
|
+
buildIncomingSubagentReport,
|
|
29
|
+
createSubmitTaskReportTool,
|
|
30
|
+
type SubagentParentSession,
|
|
31
|
+
} from "./report.ts";
|
|
32
|
+
import { renderSubagentReportMessage } from "./report-message-renderer.ts";
|
|
33
|
+
import { openRosterSession, type SubagentRoster, TranscriptSubagentRoster } from "./roster.ts";
|
|
34
|
+
import { SubagentMessageRouter } from "./wake.ts";
|
|
35
|
+
|
|
36
|
+
const LINGER_POLL_MS = 1_000;
|
|
37
|
+
|
|
38
|
+
interface ParentSessionState extends SubagentParentSession {
|
|
39
|
+
getSessionName(): string | undefined;
|
|
40
|
+
launchState: SubagentLaunchState;
|
|
41
|
+
tmuxInstalled: boolean;
|
|
42
|
+
getTree(): SessionTreeNode[];
|
|
43
|
+
hasPendingMessages(): boolean;
|
|
44
|
+
shutdown(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ChildSessionState {
|
|
48
|
+
identity: HandoffSubagent;
|
|
49
|
+
requestResponse: boolean;
|
|
50
|
+
reportsAtTurnStart: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface CurrentSubagentSession {
|
|
54
|
+
parent: ParentSessionState;
|
|
55
|
+
child?: ChildSessionState | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SubagentsHandle extends SessionLifecycle {
|
|
59
|
+
roster: SubagentRoster;
|
|
60
|
+
getLaunchTargets(): readonly HandoffLaunchTarget[];
|
|
61
|
+
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
62
|
+
cancelSession(sessionId: string): Promise<SubagentCancelResult>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function installSubagents(
|
|
66
|
+
pi: ExtensionAPI,
|
|
67
|
+
deps: { settings: SessionSettings; messaging: MessagingHandle },
|
|
68
|
+
): SubagentsHandle {
|
|
69
|
+
pi.registerMessageRenderer(SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE, renderSubagentReportMessage);
|
|
70
|
+
|
|
71
|
+
let epoch = 0;
|
|
72
|
+
let current: CurrentSubagentSession | undefined;
|
|
73
|
+
let lingerTimer: NodeJS.Timeout | undefined;
|
|
74
|
+
|
|
75
|
+
const isCurrentSession = (candidateEpoch: number): boolean =>
|
|
76
|
+
current?.parent.epoch === candidateEpoch;
|
|
77
|
+
const clearLinger = (): void => {
|
|
78
|
+
if (lingerTimer) {
|
|
79
|
+
clearTimeout(lingerTimer);
|
|
80
|
+
lingerTimer = undefined;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const reconciler = new SubagentReconciler({
|
|
84
|
+
executor: pi,
|
|
85
|
+
messaging: deps.messaging,
|
|
86
|
+
getParent: () => current?.parent,
|
|
87
|
+
isCurrent: isCurrentSession,
|
|
88
|
+
openSession: openReconcileSession,
|
|
89
|
+
});
|
|
90
|
+
const roster = new TranscriptSubagentRoster({
|
|
91
|
+
executor: pi,
|
|
92
|
+
messaging: deps.messaging,
|
|
93
|
+
getParent: () => current?.parent,
|
|
94
|
+
reconcile: () => reconciler.reconcile(),
|
|
95
|
+
openSession: openRosterSession,
|
|
96
|
+
});
|
|
97
|
+
const messageRouter = new SubagentMessageRouter(
|
|
98
|
+
pi,
|
|
99
|
+
deps.messaging,
|
|
100
|
+
() => current?.parent,
|
|
101
|
+
isCurrentSession,
|
|
102
|
+
{
|
|
103
|
+
onMaterialize: (launch) => pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launch),
|
|
104
|
+
afterOwnedSend: async () => {
|
|
105
|
+
await reconciler.reconcile();
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
const cancellationRouter = new SubagentCancellationRouter(
|
|
110
|
+
pi,
|
|
111
|
+
deps.messaging,
|
|
112
|
+
reconciler,
|
|
113
|
+
() => current?.parent,
|
|
114
|
+
isCurrentSession,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
deps.messaging.onIncomingSubagentReport(async (envelope) => {
|
|
118
|
+
const parent = current?.parent;
|
|
119
|
+
if (!parent) {
|
|
120
|
+
throw new Error("Target session is not ready to receive subagent reports.");
|
|
121
|
+
}
|
|
122
|
+
const incoming = buildIncomingSubagentReport(parent, envelope);
|
|
123
|
+
if (!incoming) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (incoming.receipt) {
|
|
127
|
+
pi.appendEntry(SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE, incoming.receipt);
|
|
128
|
+
}
|
|
129
|
+
pi.sendMessage(
|
|
130
|
+
{
|
|
131
|
+
customType: SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
|
|
132
|
+
content: incoming.content,
|
|
133
|
+
display: true,
|
|
134
|
+
details: incoming.message,
|
|
135
|
+
},
|
|
136
|
+
incoming.delivery,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
pi.on("before_agent_start", (event) => {
|
|
141
|
+
const child = current?.child;
|
|
142
|
+
if (!child) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
systemPrompt: `${event.systemPrompt}
|
|
147
|
+
|
|
148
|
+
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.`,
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
153
|
+
clearLinger();
|
|
154
|
+
if (current?.child) {
|
|
155
|
+
current.child.reportsAtTurnStart = countReports(ctx.sessionManager.getBranch());
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
160
|
+
const session = current;
|
|
161
|
+
if (!session || session.parent.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (hasSubagentLaunchEntries(session.parent.getBranch())) {
|
|
165
|
+
await reconciler.reconcile();
|
|
166
|
+
}
|
|
167
|
+
if (session.child) {
|
|
168
|
+
if (ctx.hasPendingMessages()) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const shouldExit = settleChild(pi, session.child, ctx.sessionManager.getBranch());
|
|
172
|
+
if (!shouldExit) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
await exitWhenUnobserved(
|
|
176
|
+
pi,
|
|
177
|
+
session.parent,
|
|
178
|
+
session.child.identity,
|
|
179
|
+
isCurrentSession,
|
|
180
|
+
(timer) => {
|
|
181
|
+
clearLinger();
|
|
182
|
+
lingerTimer = timer;
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
pi.on("session_tree", async () => {
|
|
190
|
+
await reconciler.reconcileAndRestoreSuspended();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
roster,
|
|
195
|
+
sendMessage: (request) => messageRouter.sendMessage(request),
|
|
196
|
+
cancelSession: (sessionId) => cancellationRouter.cancelSession(sessionId),
|
|
197
|
+
getLaunchTargets() {
|
|
198
|
+
const parent = current?.parent;
|
|
199
|
+
if (!parent?.tmuxInstalled || parent.launchState.depth >= deps.settings.subagents.maxDepth) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
return [createSubagentLaunchTarget(pi, parent.launchState, isCurrentSession)];
|
|
203
|
+
},
|
|
204
|
+
async onSessionStart(_event, ctx) {
|
|
205
|
+
clearLinger();
|
|
206
|
+
epoch += 1;
|
|
207
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
208
|
+
const identity = findSelfSubagentIdentity(ctx.sessionManager);
|
|
209
|
+
const sessionManager = ctx.sessionManager;
|
|
210
|
+
const parent: ParentSessionState = {
|
|
211
|
+
sessionId,
|
|
212
|
+
getSessionName: () => sessionManager.getSessionName(),
|
|
213
|
+
getBranch: () => sessionManager.getBranch(),
|
|
214
|
+
getTree: () => sessionManager.getTree(),
|
|
215
|
+
isIdle: ctx.isIdle,
|
|
216
|
+
hasPendingMessages: ctx.hasPendingMessages,
|
|
217
|
+
shutdown: ctx.shutdown,
|
|
218
|
+
epoch,
|
|
219
|
+
launchState: { sessionId, depth: identity?.depth ?? 0, epoch },
|
|
220
|
+
tmuxInstalled: await isTmuxInstalled(pi, ctx.cwd),
|
|
221
|
+
};
|
|
222
|
+
reconciler.beginSession();
|
|
223
|
+
current = {
|
|
224
|
+
parent,
|
|
225
|
+
...(identity
|
|
226
|
+
? {
|
|
227
|
+
child: {
|
|
228
|
+
identity,
|
|
229
|
+
requestResponse: identity.requestResponse,
|
|
230
|
+
reportsAtTurnStart: countReports(sessionManager.getBranch()),
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
: {}),
|
|
234
|
+
};
|
|
235
|
+
if (identity) {
|
|
236
|
+
pi.registerTool(
|
|
237
|
+
createSubmitTaskReportTool(pi, deps.messaging, () => {
|
|
238
|
+
const session = current;
|
|
239
|
+
return session?.child
|
|
240
|
+
? { ...session.parent, identity: session.child.identity }
|
|
241
|
+
: undefined;
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (_event.reason === "reload") {
|
|
246
|
+
await reconciler.reconcile();
|
|
247
|
+
} else {
|
|
248
|
+
await reconciler.reconcileAndRestoreSuspended();
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
async onSessionShutdown(event) {
|
|
252
|
+
clearLinger();
|
|
253
|
+
if (current && event.reason !== "reload") {
|
|
254
|
+
await reconciler.suspendForShutdown();
|
|
255
|
+
}
|
|
256
|
+
epoch += 1;
|
|
257
|
+
current = undefined;
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function settleChild(
|
|
263
|
+
pi: ExtensionAPI,
|
|
264
|
+
child: ChildSessionState,
|
|
265
|
+
branch: readonly SessionEntry[],
|
|
266
|
+
): boolean {
|
|
267
|
+
const lifecycle = getChildSubagentLifecycle(branch);
|
|
268
|
+
const reports = lifecycle.reports.length;
|
|
269
|
+
if (reports > child.reportsAtTurnStart) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (!child.requestResponse) {
|
|
273
|
+
pi.appendEntry(SUBAGENT_CLOSED_CUSTOM_TYPE, {
|
|
274
|
+
reason: "no_response_expected",
|
|
275
|
+
});
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (lifecycle.hasReminder) {
|
|
280
|
+
pi.appendEntry(SUBAGENT_CLOSED_CUSTOM_TYPE, {
|
|
281
|
+
reason: "no_report_after_reminder",
|
|
282
|
+
});
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
pi.sendMessage(
|
|
287
|
+
{
|
|
288
|
+
customType: SUBAGENT_REPORT_REMINDER_MESSAGE_CUSTOM_TYPE,
|
|
289
|
+
content:
|
|
290
|
+
"[system] Your delegated turn settled without a task report. Call submit_task_report now with done, blocked, or incomplete status.",
|
|
291
|
+
display: true,
|
|
292
|
+
},
|
|
293
|
+
{ triggerTurn: true },
|
|
294
|
+
);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function countReports(branch: readonly SessionEntry[]): number {
|
|
299
|
+
return getChildSubagentLifecycle(branch).reports.length;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function findSelfSubagentIdentity(session: {
|
|
303
|
+
getSessionId(): string;
|
|
304
|
+
getBranch(): readonly SessionEntry[];
|
|
305
|
+
}): HandoffSubagent | undefined {
|
|
306
|
+
const metadata = getHandoffMetadataFromEntries(session.getBranch());
|
|
307
|
+
if (metadata?.launch !== SUBAGENT_LAUNCH) {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
return metadata.subagent.childSessionId === session.getSessionId()
|
|
311
|
+
? metadata.subagent
|
|
312
|
+
: undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function exitWhenUnobserved(
|
|
316
|
+
executor: ExtensionAPI,
|
|
317
|
+
parent: ParentSessionState,
|
|
318
|
+
identity: HandoffSubagent,
|
|
319
|
+
isCurrentSession: (epoch: number) => boolean,
|
|
320
|
+
setTimer: (timer: NodeJS.Timeout) => void,
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
const check = async (): Promise<void> => {
|
|
323
|
+
if (!isCurrentSession(parent.epoch) || parent.hasPendingMessages() || !parent.isIdle()) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let attached = false;
|
|
328
|
+
try {
|
|
329
|
+
attached = await hasAttachedTmuxClients(executor, tmuxSessionName(identity.ownerSessionId));
|
|
330
|
+
} catch {
|
|
331
|
+
attached = false;
|
|
332
|
+
}
|
|
333
|
+
if (!isCurrentSession(parent.epoch)) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (!attached) {
|
|
337
|
+
parent.shutdown();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
setTimer(setTimeout(() => void check(), LINGER_POLL_MS));
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
await check();
|
|
344
|
+
}
|
|
@@ -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
|
+
}
|