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,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SessionEntry,
|
|
3
|
+
SessionManager,
|
|
4
|
+
type SessionTreeNode,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { isSessionStarting } from "../session-handoff/metadata.ts";
|
|
7
|
+
import { listTmuxWindows, type TmuxExecutor, tmuxSessionName } from "../shared/tmux.ts";
|
|
8
|
+
import { classifySubagent, type SubagentState } from "./classify.ts";
|
|
9
|
+
import {
|
|
10
|
+
type ChildSubagentLifecycle,
|
|
11
|
+
collectParentLedger,
|
|
12
|
+
getChildSubagentLifecycle,
|
|
13
|
+
type ParentSubagentLaunch,
|
|
14
|
+
type ParentSubagentLedger,
|
|
15
|
+
} from "./ledger.ts";
|
|
16
|
+
import type { ReconcileResult } from "./reconcile.ts";
|
|
17
|
+
|
|
18
|
+
export type SubagentRelationScope = "branch" | "tree";
|
|
19
|
+
|
|
20
|
+
export interface SubagentRosterEntry {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
sessionFile: string;
|
|
23
|
+
ownerSessionId: string;
|
|
24
|
+
ownerTitle: string;
|
|
25
|
+
ownerIsCurrentSession: boolean;
|
|
26
|
+
title: string;
|
|
27
|
+
goal: string;
|
|
28
|
+
model?: string | undefined;
|
|
29
|
+
cwd: string;
|
|
30
|
+
resumeCommand: string;
|
|
31
|
+
launchedAt: string;
|
|
32
|
+
depth: number;
|
|
33
|
+
state: SubagentState;
|
|
34
|
+
onActiveBranch: boolean;
|
|
35
|
+
managedLive: boolean;
|
|
36
|
+
tmuxWindowId?: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SubagentRosterResult {
|
|
40
|
+
entries: readonly SubagentRosterEntry[];
|
|
41
|
+
total: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SubagentRoster {
|
|
45
|
+
resolve(scope: SubagentRelationScope): Promise<SubagentRosterResult>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface RosterSession {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
getSessionName(): string | undefined;
|
|
51
|
+
getBranch(fromId?: string): readonly SessionEntry[];
|
|
52
|
+
getTree(): readonly SessionTreeNode[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RosterParentSession extends RosterSession {
|
|
56
|
+
epoch: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RosterDependencies {
|
|
60
|
+
executor: TmuxExecutor;
|
|
61
|
+
messaging: { listSessions(): Promise<string[]> };
|
|
62
|
+
getParent(): RosterParentSession | undefined;
|
|
63
|
+
reconcile(): Promise<ReconcileResult>;
|
|
64
|
+
openSession(path: string): RosterSession;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TraversedSubagent {
|
|
68
|
+
launch: ParentSubagentLaunch;
|
|
69
|
+
ownerSessionId: string;
|
|
70
|
+
ownerTitle: string;
|
|
71
|
+
ownerIsCurrentSession: boolean;
|
|
72
|
+
ownerActiveLedger: ParentSubagentLedger;
|
|
73
|
+
onActiveBranch: boolean;
|
|
74
|
+
lifecycle: ChildSubagentLifecycle | undefined;
|
|
75
|
+
awaitingKickoff: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class TranscriptSubagentRoster implements SubagentRoster {
|
|
79
|
+
constructor(private readonly deps: RosterDependencies) {}
|
|
80
|
+
|
|
81
|
+
async resolve(scope: SubagentRelationScope): Promise<SubagentRosterResult> {
|
|
82
|
+
const parent = this.deps.getParent();
|
|
83
|
+
if (!parent) {
|
|
84
|
+
throw new Error("Subagent roster is unavailable before session start.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const reconciliation = await this.deps.reconcile();
|
|
88
|
+
const traversed = this.traverse(parent, scope);
|
|
89
|
+
const brokerLive = new Set(await this.deps.messaging.listSessions());
|
|
90
|
+
|
|
91
|
+
const ownerSessionIds = [...new Set(traversed.map((child) => child.ownerSessionId))];
|
|
92
|
+
const windowsByOwner = new Map(
|
|
93
|
+
await Promise.all(
|
|
94
|
+
ownerSessionIds.map(async (ownerSessionId) => {
|
|
95
|
+
const windows = await listTmuxWindows(
|
|
96
|
+
this.deps.executor,
|
|
97
|
+
tmuxSessionName(ownerSessionId),
|
|
98
|
+
);
|
|
99
|
+
return [
|
|
100
|
+
ownerSessionId,
|
|
101
|
+
new Map(windows.map((window) => [window.piSessionId, window])),
|
|
102
|
+
] as const;
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const entries = traversed.map((child): SubagentRosterEntry => {
|
|
108
|
+
const childSessionId = child.launch.childSessionId;
|
|
109
|
+
const window = windowsByOwner.get(child.ownerSessionId)?.get(childSessionId);
|
|
110
|
+
const hasWindow = window !== undefined;
|
|
111
|
+
const isBrokerLive = brokerLive.has(childSessionId);
|
|
112
|
+
const activeLedger = child.ownerActiveLedger;
|
|
113
|
+
const lifecycle = child.lifecycle;
|
|
114
|
+
const state =
|
|
115
|
+
reconciliation.states.get(childSessionId) ??
|
|
116
|
+
classifySubagent({
|
|
117
|
+
hasWindow,
|
|
118
|
+
brokerLive: isBrokerLive,
|
|
119
|
+
hasRegistered: reconciliation.registered.has(childSessionId),
|
|
120
|
+
awaitingKickoff: child.awaitingKickoff,
|
|
121
|
+
cancelled: activeLedger.cancelledChildIds.has(childSessionId),
|
|
122
|
+
suspended: activeLedger.suspendedChildIds.has(childSessionId),
|
|
123
|
+
hasReportOrClosure: Boolean(lifecycle?.reports.length || lifecycle?.closed),
|
|
124
|
+
childReadable: lifecycle !== undefined,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
sessionId: childSessionId,
|
|
129
|
+
sessionFile: child.launch.childSessionFile,
|
|
130
|
+
ownerSessionId: child.ownerSessionId,
|
|
131
|
+
ownerTitle: child.ownerTitle,
|
|
132
|
+
ownerIsCurrentSession: child.ownerIsCurrentSession,
|
|
133
|
+
title: child.launch.title,
|
|
134
|
+
goal: child.launch.goal,
|
|
135
|
+
...(child.launch.model ? { model: child.launch.model } : {}),
|
|
136
|
+
cwd: child.launch.cwd,
|
|
137
|
+
resumeCommand: child.launch.resumeCommand,
|
|
138
|
+
launchedAt: child.launch.launchedAt,
|
|
139
|
+
depth: child.launch.depth,
|
|
140
|
+
state,
|
|
141
|
+
onActiveBranch: child.onActiveBranch,
|
|
142
|
+
managedLive: hasWindow || isBrokerLive,
|
|
143
|
+
...(window ? { tmuxWindowId: window.windowId } : {}),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return { entries, total: entries.length };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private traverse(root: RosterSession, scope: SubagentRelationScope): TraversedSubagent[] {
|
|
151
|
+
const results = new Map<string, TraversedSubagent>();
|
|
152
|
+
const openedChildren = new Map<string, RosterSession | undefined>();
|
|
153
|
+
const visitedOwners = new Set<string>();
|
|
154
|
+
const queue: RosterSession[] = [root];
|
|
155
|
+
|
|
156
|
+
while (queue.length > 0) {
|
|
157
|
+
const owner = queue.shift();
|
|
158
|
+
if (!owner || visitedOwners.has(owner.sessionId)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
visitedOwners.add(owner.sessionId);
|
|
162
|
+
|
|
163
|
+
const activeBranch = owner.getBranch();
|
|
164
|
+
const activeLedger = collectParentLedger(activeBranch, owner.sessionId);
|
|
165
|
+
const activeChildIds = new Set(activeLedger.launches.map((launch) => launch.childSessionId));
|
|
166
|
+
const launches =
|
|
167
|
+
scope === "branch" ? activeLedger.launches : collectTreeLaunches(owner, owner.sessionId);
|
|
168
|
+
|
|
169
|
+
for (const launch of launches) {
|
|
170
|
+
let child: RosterSession | undefined;
|
|
171
|
+
let lifecycle: ChildSubagentLifecycle | undefined;
|
|
172
|
+
let awaitingKickoff = false;
|
|
173
|
+
try {
|
|
174
|
+
if (openedChildren.has(launch.childSessionId)) {
|
|
175
|
+
child = openedChildren.get(launch.childSessionId);
|
|
176
|
+
} else {
|
|
177
|
+
child = this.deps.openSession(launch.childSessionFile);
|
|
178
|
+
if (child.sessionId !== launch.childSessionId) {
|
|
179
|
+
child = undefined;
|
|
180
|
+
}
|
|
181
|
+
openedChildren.set(launch.childSessionId, child);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const childBranch = child?.getBranch();
|
|
185
|
+
if (!childBranch) {
|
|
186
|
+
child = undefined;
|
|
187
|
+
} else {
|
|
188
|
+
lifecycle = getChildSubagentLifecycle(childBranch);
|
|
189
|
+
awaitingKickoff = isSessionStarting(childBranch);
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
child = undefined;
|
|
193
|
+
openedChildren.set(launch.childSessionId, undefined);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const existing = results.get(launch.childSessionId);
|
|
197
|
+
const candidate: TraversedSubagent = {
|
|
198
|
+
launch,
|
|
199
|
+
ownerSessionId: owner.sessionId,
|
|
200
|
+
ownerTitle: owner.getSessionName()?.trim() || "Untitled session",
|
|
201
|
+
ownerIsCurrentSession: owner.sessionId === root.sessionId,
|
|
202
|
+
ownerActiveLedger: activeLedger,
|
|
203
|
+
onActiveBranch: activeChildIds.has(launch.childSessionId),
|
|
204
|
+
lifecycle,
|
|
205
|
+
awaitingKickoff,
|
|
206
|
+
};
|
|
207
|
+
if (!existing || candidate.launch.depth < existing.launch.depth) {
|
|
208
|
+
results.set(launch.childSessionId, candidate);
|
|
209
|
+
}
|
|
210
|
+
if (child && !visitedOwners.has(child.sessionId)) {
|
|
211
|
+
queue.push(child);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return [...results.values()];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function openRosterSession(path: string): RosterSession {
|
|
221
|
+
const manager = SessionManager.open(path);
|
|
222
|
+
return {
|
|
223
|
+
sessionId: manager.getSessionId(),
|
|
224
|
+
getSessionName: () => manager.getSessionName(),
|
|
225
|
+
getBranch: (fromId) => manager.getBranch(fromId),
|
|
226
|
+
getTree: () => manager.getTree(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function collectTreeLaunches(
|
|
231
|
+
session: RosterSession,
|
|
232
|
+
ownerSessionId: string,
|
|
233
|
+
): ParentSubagentLaunch[] {
|
|
234
|
+
const launches = new Map<string, ParentSubagentLaunch>();
|
|
235
|
+
for (const leafId of collectLeafIds(session.getTree())) {
|
|
236
|
+
const ledger = collectParentLedger(session.getBranch(leafId), ownerSessionId);
|
|
237
|
+
for (const launch of ledger.launches) {
|
|
238
|
+
const existing = launches.get(launch.childSessionId);
|
|
239
|
+
if (!existing || launch.launchedAt > existing.launchedAt) {
|
|
240
|
+
launches.set(launch.childSessionId, launch);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return [...launches.values()];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function collectLeafIds(roots: readonly SessionTreeNode[]): string[] {
|
|
248
|
+
const leafIds: string[] = [];
|
|
249
|
+
const stack = [...roots];
|
|
250
|
+
while (stack.length > 0) {
|
|
251
|
+
const node = stack.pop();
|
|
252
|
+
if (!node) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (node.children.length === 0) {
|
|
256
|
+
leafIds.push(node.entry.id);
|
|
257
|
+
} else {
|
|
258
|
+
stack.push(...node.children);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return leafIds;
|
|
262
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { SUBAGENT_LAUNCH } from "../session-handoff/launch-target.ts";
|
|
3
|
+
import { findCurrentHandoffBootstrap, type HandoffSubagent } from "../session-handoff/metadata.ts";
|
|
4
|
+
import { hasAttachedTmuxClients, tmuxSessionName } from "../shared/tmux.ts";
|
|
5
|
+
import { isRunningSubagentState } from "./classify.ts";
|
|
6
|
+
import {
|
|
7
|
+
getChildSubagentLifecycle,
|
|
8
|
+
SUBAGENT_CLOSED_CUSTOM_TYPE,
|
|
9
|
+
SUBAGENT_REPORT_REMINDER_MESSAGE_CUSTOM_TYPE,
|
|
10
|
+
} from "./ledger.ts";
|
|
11
|
+
import type { ReconcileResult, SubagentReconciler } from "./reconcile.ts";
|
|
12
|
+
|
|
13
|
+
const LINGER_POLL_MS = 1_000;
|
|
14
|
+
const OWNED_SUBAGENT_POLL_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
export interface SubagentChildSessionState {
|
|
17
|
+
identity: HandoffSubagent;
|
|
18
|
+
requestResponse: boolean;
|
|
19
|
+
reportsAtTurnStart: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SettledChildParentSession {
|
|
23
|
+
epoch: number;
|
|
24
|
+
getBranch(): readonly SessionEntry[];
|
|
25
|
+
hasPendingMessages(): boolean;
|
|
26
|
+
isIdle(): boolean;
|
|
27
|
+
shutdown(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SettledChildLifecycle {
|
|
31
|
+
cancel(): void;
|
|
32
|
+
settle(
|
|
33
|
+
parent: SettledChildParentSession,
|
|
34
|
+
child: SubagentChildSessionState,
|
|
35
|
+
reconciliation: ReconcileResult | undefined,
|
|
36
|
+
): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createSettledChildLifecycle(
|
|
40
|
+
pi: ExtensionAPI,
|
|
41
|
+
reconciler: SubagentReconciler,
|
|
42
|
+
isCurrentSession: (epoch: number) => boolean,
|
|
43
|
+
): SettledChildLifecycle {
|
|
44
|
+
let generation = 0;
|
|
45
|
+
let timer: NodeJS.Timeout | undefined;
|
|
46
|
+
|
|
47
|
+
const cancel = (): void => {
|
|
48
|
+
generation += 1;
|
|
49
|
+
if (timer) {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
timer = undefined;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
cancel,
|
|
57
|
+
async settle(parent, child, reconciliation) {
|
|
58
|
+
cancel();
|
|
59
|
+
const currentGeneration = generation;
|
|
60
|
+
let phase: "owned-subagents" | "settle" | "observer" =
|
|
61
|
+
reconciliation && hasRunningOwnedSubagents(reconciliation) ? "owned-subagents" : "settle";
|
|
62
|
+
const canAdvance = (): boolean =>
|
|
63
|
+
generation === currentGeneration &&
|
|
64
|
+
isCurrentSession(parent.epoch) &&
|
|
65
|
+
!parent.hasPendingMessages() &&
|
|
66
|
+
parent.isIdle();
|
|
67
|
+
const schedule = (delayMs: number): void => {
|
|
68
|
+
timer = setTimeout(() => {
|
|
69
|
+
timer = undefined;
|
|
70
|
+
void advance();
|
|
71
|
+
}, delayMs);
|
|
72
|
+
};
|
|
73
|
+
const advance = async (): Promise<void> => {
|
|
74
|
+
if (!canAdvance()) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (phase === "owned-subagents") {
|
|
79
|
+
const result = await reconciler.reconcile();
|
|
80
|
+
if (!canAdvance()) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (hasRunningOwnedSubagents(result)) {
|
|
84
|
+
schedule(OWNED_SUBAGENT_POLL_MS);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
phase = "settle";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (phase === "settle") {
|
|
91
|
+
if (!settleChild(pi, child, parent.getBranch())) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
phase = "observer";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let attached = false;
|
|
98
|
+
try {
|
|
99
|
+
attached = await hasAttachedTmuxClients(
|
|
100
|
+
pi,
|
|
101
|
+
tmuxSessionName(child.identity.ownerSessionId),
|
|
102
|
+
);
|
|
103
|
+
} catch {
|
|
104
|
+
attached = false;
|
|
105
|
+
}
|
|
106
|
+
if (!canAdvance()) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!attached) {
|
|
110
|
+
parent.shutdown();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
schedule(LINGER_POLL_MS);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (phase === "owned-subagents") {
|
|
117
|
+
schedule(OWNED_SUBAGENT_POLL_MS);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
await advance();
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function countSubagentReports(branch: readonly SessionEntry[]): number {
|
|
126
|
+
return getChildSubagentLifecycle(branch).reports.length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function findSelfSubagentIdentity(
|
|
130
|
+
sessionId: string,
|
|
131
|
+
branch: readonly SessionEntry[],
|
|
132
|
+
): HandoffSubagent | undefined {
|
|
133
|
+
const bootstrap = findCurrentHandoffBootstrap(branch);
|
|
134
|
+
if (
|
|
135
|
+
bootstrap?.launch !== SUBAGENT_LAUNCH ||
|
|
136
|
+
bootstrap.sessionId !== sessionId ||
|
|
137
|
+
bootstrap.subagent.childSessionId !== sessionId
|
|
138
|
+
) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
return bootstrap.subagent;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasRunningOwnedSubagents(result: ReconcileResult): boolean {
|
|
145
|
+
return [...result.states.values()].some(isRunningSubagentState);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function settleChild(
|
|
149
|
+
pi: ExtensionAPI,
|
|
150
|
+
child: SubagentChildSessionState,
|
|
151
|
+
branch: readonly SessionEntry[],
|
|
152
|
+
): boolean {
|
|
153
|
+
const lifecycle = getChildSubagentLifecycle(branch);
|
|
154
|
+
const reports = lifecycle.reports.length;
|
|
155
|
+
if (reports > child.reportsAtTurnStart) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (!child.requestResponse) {
|
|
159
|
+
pi.appendEntry(SUBAGENT_CLOSED_CUSTOM_TYPE, {
|
|
160
|
+
reason: "no_response_expected",
|
|
161
|
+
});
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (lifecycle.hasReminder) {
|
|
166
|
+
pi.appendEntry(SUBAGENT_CLOSED_CUSTOM_TYPE, {
|
|
167
|
+
reason: "no_report_after_reminder",
|
|
168
|
+
});
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pi.sendMessage(
|
|
173
|
+
{
|
|
174
|
+
customType: SUBAGENT_REPORT_REMINDER_MESSAGE_CUSTOM_TYPE,
|
|
175
|
+
content:
|
|
176
|
+
"[system] Your delegated turn settled without a task report. Call submit_task_report now with done, blocked, or incomplete status.",
|
|
177
|
+
display: true,
|
|
178
|
+
},
|
|
179
|
+
{ triggerTurn: true },
|
|
180
|
+
);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SendMessageRequest, SendMessageResult } from "../session-messaging/install.ts";
|
|
3
|
+
import {
|
|
4
|
+
createTmuxWindow,
|
|
5
|
+
killTmuxWindow,
|
|
6
|
+
listTmuxWindows,
|
|
7
|
+
type TmuxExecutor,
|
|
8
|
+
tmuxSessionName,
|
|
9
|
+
} from "../shared/tmux.ts";
|
|
10
|
+
import { findOwnedSubagentLaunch, type SubagentLaunched } from "./ledger.ts";
|
|
11
|
+
|
|
12
|
+
const SESSION_READY_TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
export interface WakeParentSession {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
epoch: number;
|
|
17
|
+
getBranch(): readonly SessionEntry[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface WakeMessaging {
|
|
21
|
+
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
22
|
+
listSessions(): Promise<string[]>;
|
|
23
|
+
waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SubagentMessageRouterOptions {
|
|
27
|
+
readyTimeoutMs?: number;
|
|
28
|
+
onMaterialize?(launch: SubagentLaunched): void;
|
|
29
|
+
afterOwnedSend?(): Promise<void> | void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SubagentMessageRouter {
|
|
33
|
+
private readonly wakeBySessionId = new Map<string, Promise<void>>();
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly executor: TmuxExecutor,
|
|
37
|
+
private readonly messaging: WakeMessaging,
|
|
38
|
+
private readonly getParent: () => WakeParentSession | undefined,
|
|
39
|
+
private readonly isCurrent: (epoch: number) => boolean,
|
|
40
|
+
private readonly options: SubagentMessageRouterOptions = {},
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
async sendMessage(request: SendMessageRequest): Promise<SendMessageResult> {
|
|
44
|
+
let failedLiveSend: SendMessageResult | undefined;
|
|
45
|
+
if (await this.isLive(request.target)) {
|
|
46
|
+
const result = await this.messaging.sendMessage(request);
|
|
47
|
+
if (result.delivered || !isTargetDeparture(result)) {
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
failedLiveSend = result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parent = this.getParent();
|
|
54
|
+
const launch = parent
|
|
55
|
+
? findOwnedSubagentLaunch(parent.getBranch(), parent.sessionId, request.target)
|
|
56
|
+
: undefined;
|
|
57
|
+
if (!parent || !launch) {
|
|
58
|
+
return failedLiveSend ?? this.messaging.sendMessage(request);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await this.wake(parent, launch);
|
|
63
|
+
const result = await this.sendOwned(parent, request);
|
|
64
|
+
if (result.delivered || !isTargetDeparture(result)) {
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await this.wake(parent, launch);
|
|
69
|
+
return this.sendOwned(parent, request);
|
|
70
|
+
} finally {
|
|
71
|
+
await this.options.afterOwnedSend?.();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async wake(parent: WakeParentSession, launch: SubagentLaunched): Promise<void> {
|
|
76
|
+
const existing = this.wakeBySessionId.get(launch.childSessionId);
|
|
77
|
+
if (existing) {
|
|
78
|
+
return existing;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const wake = this.ensureReady(parent, launch).finally(() => {
|
|
82
|
+
if (this.wakeBySessionId.get(launch.childSessionId) === wake) {
|
|
83
|
+
this.wakeBySessionId.delete(launch.childSessionId);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
this.wakeBySessionId.set(launch.childSessionId, wake);
|
|
87
|
+
return wake;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async ensureReady(parent: WakeParentSession, launch: SubagentLaunched): Promise<void> {
|
|
91
|
+
if (await this.isLive(launch.childSessionId)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tmuxSession = tmuxSessionName(parent.sessionId);
|
|
96
|
+
const hasWindow = (await listTmuxWindows(this.executor, tmuxSession)).some(
|
|
97
|
+
(window) => window.piSessionId === launch.childSessionId,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (!hasWindow) {
|
|
101
|
+
await this.createWindow(parent, launch, tmuxSession);
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
await this.messaging.waitForSession(
|
|
105
|
+
launch.childSessionId,
|
|
106
|
+
this.options.readyTimeoutMs ?? SESSION_READY_TIMEOUT_MS,
|
|
107
|
+
)
|
|
108
|
+
) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.requireCurrent(parent);
|
|
113
|
+
const killed = await killTmuxWindow(this.executor, tmuxSession, launch.childSessionId);
|
|
114
|
+
if (!killed) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Subagent ${launch.childSessionId} did not register and its stale tmux window could not be stopped.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await this.createWindow(parent, launch, tmuxSession);
|
|
121
|
+
if (
|
|
122
|
+
!(await this.messaging.waitForSession(
|
|
123
|
+
launch.childSessionId,
|
|
124
|
+
this.options.readyTimeoutMs ?? SESSION_READY_TIMEOUT_MS,
|
|
125
|
+
))
|
|
126
|
+
) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Subagent ${launch.childSessionId} was restarted but did not register for messaging.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async createWindow(
|
|
134
|
+
parent: WakeParentSession,
|
|
135
|
+
launch: SubagentLaunched,
|
|
136
|
+
tmuxSession: string,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
if (await this.isLive(launch.childSessionId)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.requireCurrent(parent);
|
|
142
|
+
this.options.onMaterialize?.(launch);
|
|
143
|
+
await createTmuxWindow(this.executor, {
|
|
144
|
+
tmuxSession,
|
|
145
|
+
name: launch.title,
|
|
146
|
+
cwd: launch.cwd,
|
|
147
|
+
command: launch.resumeCommand,
|
|
148
|
+
piSessionId: launch.childSessionId,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private sendOwned(
|
|
153
|
+
parent: WakeParentSession,
|
|
154
|
+
request: SendMessageRequest,
|
|
155
|
+
): Promise<SendMessageResult> {
|
|
156
|
+
this.requireCurrent(parent);
|
|
157
|
+
return this.messaging.sendMessage(request);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async isLive(sessionId: string): Promise<boolean> {
|
|
161
|
+
return (await this.messaging.listSessions()).includes(sessionId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private requireCurrent(parent: WakeParentSession): void {
|
|
165
|
+
if (!this.isCurrent(parent.epoch)) {
|
|
166
|
+
throw new Error("The parent session changed while waking its subagent.");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isTargetDeparture(result: SendMessageResult): boolean {
|
|
172
|
+
return !result.delivered && (result.reason === "no_session" || result.reason === "disconnected");
|
|
173
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,37 +19,35 @@
|
|
|
19
19
|
"node": ">=24 <26"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
|
+
"dist",
|
|
22
23
|
"extensions",
|
|
23
24
|
"images"
|
|
24
25
|
],
|
|
25
26
|
"pi": {
|
|
26
27
|
"extensions": [
|
|
27
|
-
"./extensions/
|
|
28
|
-
"./extensions/session-auto-title.ts",
|
|
29
|
-
"./extensions/session-handoff.ts",
|
|
30
|
-
"./extensions/session-hooks.ts",
|
|
31
|
-
"./extensions/session-index.ts",
|
|
32
|
-
"./extensions/session-messaging.ts",
|
|
33
|
-
"./extensions/session-search.ts"
|
|
28
|
+
"./extensions/pi-sessions.ts"
|
|
34
29
|
]
|
|
35
30
|
},
|
|
36
31
|
"scripts": {
|
|
32
|
+
"build:broker": "tsc --project tsconfig.broker.json",
|
|
37
33
|
"lint": "biome check .",
|
|
38
34
|
"format": "biome format --write .",
|
|
39
35
|
"typecheck": "tsc --noEmit",
|
|
36
|
+
"pretest": "npm run build:broker",
|
|
40
37
|
"test": "vitest run",
|
|
41
|
-
"check": "biome check . && tsc --noEmit &&
|
|
42
|
-
"prepare": "
|
|
38
|
+
"check": "biome check . && tsc --noEmit && npm test",
|
|
39
|
+
"prepare": "npm run build:broker && npm run prepare:husky",
|
|
40
|
+
"prepare:husky": "husky || true"
|
|
43
41
|
},
|
|
44
42
|
"dependencies": {
|
|
45
43
|
"typebox": "^1.1.38"
|
|
46
44
|
},
|
|
47
45
|
"devDependencies": {
|
|
48
46
|
"@biomejs/biome": "^2.4.14",
|
|
49
|
-
"@earendil-works/pi-agent-core": "^0.80.
|
|
50
|
-
"@earendil-works/pi-ai": "^0.80.
|
|
51
|
-
"@earendil-works/pi-coding-agent": "^0.80.
|
|
52
|
-
"@earendil-works/pi-tui": "^0.80.
|
|
47
|
+
"@earendil-works/pi-agent-core": "^0.80.10",
|
|
48
|
+
"@earendil-works/pi-ai": "^0.80.10",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "^0.80.10",
|
|
50
|
+
"@earendil-works/pi-tui": "^0.80.10",
|
|
53
51
|
"@types/node": "^25.6.2",
|
|
54
52
|
"husky": "^9.1.7",
|
|
55
53
|
"lint-staged": "^17.0.3",
|
|
@@ -57,10 +55,10 @@
|
|
|
57
55
|
"vitest": "^4.1.5"
|
|
58
56
|
},
|
|
59
57
|
"peerDependencies": {
|
|
60
|
-
"@earendil-works/pi-agent-core": ">=0.80.
|
|
61
|
-
"@earendil-works/pi-ai": ">=0.80.
|
|
62
|
-
"@earendil-works/pi-coding-agent": ">=0.80.
|
|
63
|
-
"@earendil-works/pi-tui": ">=0.80.
|
|
58
|
+
"@earendil-works/pi-agent-core": ">=0.80.10",
|
|
59
|
+
"@earendil-works/pi-ai": ">=0.80.10",
|
|
60
|
+
"@earendil-works/pi-coding-agent": ">=0.80.10",
|
|
61
|
+
"@earendil-works/pi-tui": ">=0.80.10"
|
|
64
62
|
},
|
|
65
63
|
"lint-staged": {
|
|
66
64
|
"*.{js,cjs,mjs,jsx,ts,tsx,json,jsonc}": "biome check --write --no-errors-on-unmatched"
|