pi-sessions 0.12.1 → 0.13.1
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 +6 -3
- package/extensions/pi-sessions.ts +20 -6
- package/extensions/session-ask/install.ts +24 -10
- package/extensions/session-auto-title/generate.ts +17 -14
- package/extensions/session-auto-title/install.ts +6 -29
- package/extensions/session-auto-title/retitle.ts +6 -19
- package/extensions/session-auto-title/wizard.ts +97 -31
- package/extensions/session-handoff/board-loading.ts +93 -0
- package/extensions/session-handoff/board.ts +239 -127
- package/extensions/session-handoff/install.ts +5 -5
- package/extensions/session-handoff/picker.ts +167 -19
- package/extensions/session-handoff/review.ts +56 -7
- package/extensions/session-handoff/ui.ts +32 -7
- package/extensions/session-hooks/install.ts +7 -20
- package/extensions/session-index/install.ts +8 -86
- package/extensions/session-index/loader.ts +80 -0
- package/extensions/session-index/panel.ts +103 -0
- package/extensions/session-messaging/broker/spawn.ts +35 -12
- package/extensions/session-messaging/pi/reachable-tool.ts +6 -1
- package/extensions/session-messaging/pi/tools.ts +79 -12
- package/extensions/session-search/extract.ts +120 -91
- package/extensions/session-search/hooks.ts +25 -203
- package/extensions/session-search/reindex.ts +13 -8
- package/extensions/shared/legend.ts +116 -0
- package/extensions/shared/session-index/scoring.ts +0 -1
- package/extensions/shared/session-index/search.ts +76 -49
- package/extensions/shared/settings.ts +22 -0
- package/extensions/subagents/context-limit.ts +58 -0
- package/extensions/subagents/install.ts +110 -30
- package/extensions/subagents/reconcile.ts +58 -19
- package/extensions/subagents/report.ts +0 -2
- package/extensions/subagents/settle.ts +31 -0
- package/extensions/subagents/should-message.ts +59 -0
- package/package.json +13 -13
- package/extensions/session-handoff/refs.ts +0 -145
|
@@ -5,15 +5,22 @@ import type {
|
|
|
5
5
|
SendMessageRequest,
|
|
6
6
|
SendMessageResult,
|
|
7
7
|
} from "../session-messaging/install.ts";
|
|
8
|
+
import {
|
|
9
|
+
createSessionCancelTool,
|
|
10
|
+
createSessionSendMessageTool,
|
|
11
|
+
} from "../session-messaging/pi/tools.ts";
|
|
8
12
|
import type { SessionLifecycle } from "../shared/composition.ts";
|
|
9
|
-
import type { SessionSettings } from "../shared/settings.ts";
|
|
13
|
+
import type { CompactionThresholdSettings, SessionSettings } from "../shared/settings.ts";
|
|
10
14
|
import { isTmuxInstalled } from "../shared/tmux.ts";
|
|
15
|
+
import { safeParseTypeBoxValue } from "../shared/typebox.ts";
|
|
11
16
|
import { SubagentCancellationRouter, type SubagentCancelResult } from "./cancel.ts";
|
|
17
|
+
import { createSubagentContextLimit } from "./context-limit.ts";
|
|
12
18
|
import { createSubagentLaunchTarget, type SubagentLaunchState } from "./launch-target.ts";
|
|
13
19
|
import {
|
|
14
20
|
hasSubagentLaunchEntries,
|
|
15
21
|
SUBAGENT_LAUNCHED_CUSTOM_TYPE,
|
|
16
22
|
SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
|
|
23
|
+
SUBAGENT_REPORT_MESSAGE_SCHEMA,
|
|
17
24
|
SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE,
|
|
18
25
|
} from "./ledger.ts";
|
|
19
26
|
import { openReconcileSession, SubagentReconciler } from "./reconcile.ts";
|
|
@@ -28,10 +35,15 @@ import {
|
|
|
28
35
|
countSubagentReports,
|
|
29
36
|
createSettledChildLifecycle,
|
|
30
37
|
findSelfSubagentIdentity,
|
|
38
|
+
refreshSubagentChildState,
|
|
31
39
|
type SubagentChildSessionState,
|
|
32
40
|
} from "./settle.ts";
|
|
41
|
+
import { shouldMessageSubagent } from "./should-message.ts";
|
|
33
42
|
import { SubagentMessageRouter } from "./wake.ts";
|
|
34
43
|
|
|
44
|
+
const SUBAGENT_PROMPT_SECTION = "pi_sessions_subagent";
|
|
45
|
+
const SUBAGENT_PROMPT = `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.`;
|
|
46
|
+
|
|
35
47
|
interface ParentSessionState extends SubagentParentSession {
|
|
36
48
|
getSessionName(): string | undefined;
|
|
37
49
|
launchState: SubagentLaunchState;
|
|
@@ -51,16 +63,27 @@ export interface SubagentsHandle extends SessionLifecycle {
|
|
|
51
63
|
getLaunchTargets(): readonly HandoffLaunchTarget[];
|
|
52
64
|
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
53
65
|
cancelSession(sessionId: string): Promise<SubagentCancelResult>;
|
|
66
|
+
shouldMessageSubagent(sessionId: string): Promise<boolean>;
|
|
67
|
+
/** Undefined unless the active branch makes this session someone's subagent. */
|
|
68
|
+
getParentSessionId(): string | undefined;
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
export function installSubagents(
|
|
57
72
|
pi: ExtensionAPI,
|
|
58
|
-
deps: {
|
|
73
|
+
deps: {
|
|
74
|
+
settings: SessionSettings;
|
|
75
|
+
messaging: MessagingHandle;
|
|
76
|
+
readCompactionSettings: (cwd: string) => CompactionThresholdSettings;
|
|
77
|
+
},
|
|
59
78
|
): SubagentsHandle {
|
|
60
79
|
pi.registerMessageRenderer(SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE, renderSubagentReportMessage);
|
|
61
80
|
|
|
62
81
|
let epoch = 0;
|
|
63
82
|
let current: CurrentSubagentSession | undefined;
|
|
83
|
+
const contextLimit = createSubagentContextLimit(
|
|
84
|
+
deps.settings.subagents.contextLimit,
|
|
85
|
+
deps.readCompactionSettings,
|
|
86
|
+
);
|
|
64
87
|
|
|
65
88
|
const isCurrentSession = (candidateEpoch: number): boolean =>
|
|
66
89
|
current?.parent.epoch === candidateEpoch;
|
|
@@ -104,6 +127,9 @@ export function installSubagents(
|
|
|
104
127
|
if (!parent) {
|
|
105
128
|
throw new Error("Target session is not ready to receive subagent reports.");
|
|
106
129
|
}
|
|
130
|
+
if (reconciler.hasSentReport(envelope.reportId)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
107
133
|
const incoming = buildIncomingSubagentReport(parent, envelope);
|
|
108
134
|
if (!incoming) {
|
|
109
135
|
return;
|
|
@@ -120,18 +146,24 @@ export function installSubagents(
|
|
|
120
146
|
},
|
|
121
147
|
incoming.delivery,
|
|
122
148
|
);
|
|
149
|
+
reconciler.noteReportSent(envelope.reportId);
|
|
123
150
|
});
|
|
124
151
|
|
|
125
152
|
pi.on("before_agent_start", (event) => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return;
|
|
153
|
+
if (current?.child) {
|
|
154
|
+
event.systemPromptOptions.sections[SUBAGENT_PROMPT_SECTION] = SUBAGENT_PROMPT;
|
|
129
155
|
}
|
|
130
|
-
|
|
131
|
-
systemPrompt: `${event.systemPrompt}
|
|
156
|
+
});
|
|
132
157
|
|
|
133
|
-
|
|
134
|
-
|
|
158
|
+
pi.on("message_end", (event) => {
|
|
159
|
+
const message = event.message;
|
|
160
|
+
if (message.role !== "custom" || message.customType !== SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const report = safeParseTypeBoxValue(SUBAGENT_REPORT_MESSAGE_SCHEMA, message.details);
|
|
164
|
+
if (report && report.writerSessionId === current?.parent.sessionId) {
|
|
165
|
+
reconciler.noteReportDelivered(report.reportId);
|
|
166
|
+
}
|
|
135
167
|
});
|
|
136
168
|
|
|
137
169
|
pi.on("agent_start", (_event, ctx) => {
|
|
@@ -150,18 +182,82 @@ You are working as a subagent on one task delegated by a parent session. The han
|
|
|
150
182
|
? await reconciler.reconcile()
|
|
151
183
|
: undefined;
|
|
152
184
|
if (session.child) {
|
|
185
|
+
await contextLimit.compactIfOverLimit(ctx);
|
|
153
186
|
await settledChildLifecycle.settle(session.parent, session.child, reconciliation);
|
|
154
187
|
}
|
|
155
188
|
});
|
|
156
189
|
|
|
190
|
+
const refreshChildSession = (): void => {
|
|
191
|
+
if (!current) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const previousChild = current.child;
|
|
195
|
+
current.child = refreshSubagentChildState(
|
|
196
|
+
current.parent.sessionId,
|
|
197
|
+
current.parent.getBranch(),
|
|
198
|
+
previousChild,
|
|
199
|
+
);
|
|
200
|
+
current.parent.launchState.depth = current.child?.identity.depth ?? 0;
|
|
201
|
+
if (current.child && current.child !== previousChild) {
|
|
202
|
+
pi.registerTool(
|
|
203
|
+
createSubmitTaskReportTool(pi, deps.messaging, () => {
|
|
204
|
+
const session = current;
|
|
205
|
+
return session?.child
|
|
206
|
+
? { ...session.parent, identity: session.child.identity }
|
|
207
|
+
: undefined;
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
const activeTools = pi.getActiveTools();
|
|
211
|
+
if (!activeTools.includes("submit_task_report")) {
|
|
212
|
+
pi.setActiveTools([...activeTools, "submit_task_report"]);
|
|
213
|
+
}
|
|
214
|
+
} else if (!current.child && previousChild) {
|
|
215
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "submit_task_report"));
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const registerMessagingTools = (): void => {
|
|
220
|
+
const isSubagent = handle.getParentSessionId() !== undefined;
|
|
221
|
+
pi.registerTool(
|
|
222
|
+
createSessionSendMessageTool(handle, {
|
|
223
|
+
role: isSubagent ? { kind: "subagent" } : { kind: "wakeCapable" },
|
|
224
|
+
getCachedRelationTo: deps.messaging.getCachedRelationTo,
|
|
225
|
+
getParentSessionId: () => handle.getParentSessionId(),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
pi.registerTool(
|
|
229
|
+
createSessionCancelTool(handle, {
|
|
230
|
+
role: isSubagent ? { kind: "subagent" } : { kind: "plain" },
|
|
231
|
+
getParentSessionId: () => handle.getParentSessionId(),
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
|
|
157
236
|
pi.on("session_tree", async () => {
|
|
237
|
+
settledChildLifecycle.cancel();
|
|
238
|
+
refreshChildSession();
|
|
239
|
+
registerMessagingTools();
|
|
158
240
|
await reconciler.reconcileAndRestoreSuspended();
|
|
159
241
|
});
|
|
160
242
|
|
|
161
|
-
|
|
243
|
+
const handle: SubagentsHandle = {
|
|
162
244
|
roster,
|
|
163
245
|
sendMessage: (request) => messageRouter.sendMessage(request),
|
|
164
246
|
cancelSession: (sessionId) => cancellationRouter.cancelSession(sessionId),
|
|
247
|
+
shouldMessageSubagent: (sessionId) =>
|
|
248
|
+
shouldMessageSubagent(sessionId, {
|
|
249
|
+
executor: pi,
|
|
250
|
+
messaging: deps.messaging,
|
|
251
|
+
getParent: () => current?.parent,
|
|
252
|
+
openSession: openRosterSession,
|
|
253
|
+
}),
|
|
254
|
+
getParentSessionId() {
|
|
255
|
+
const parent = current?.parent;
|
|
256
|
+
if (!parent) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
return findSelfSubagentIdentity(parent.sessionId, parent.getBranch())?.ownerSessionId;
|
|
260
|
+
},
|
|
165
261
|
getLaunchTargets() {
|
|
166
262
|
const parent = current?.parent;
|
|
167
263
|
if (!parent?.tmuxInstalled || parent.launchState.depth >= deps.settings.subagents.maxDepth) {
|
|
@@ -189,27 +285,10 @@ You are working as a subagent on one task delegated by a parent session. The han
|
|
|
189
285
|
tmuxInstalled: await isTmuxInstalled(pi, ctx.cwd),
|
|
190
286
|
};
|
|
191
287
|
reconciler.beginSession();
|
|
192
|
-
current = {
|
|
193
|
-
|
|
194
|
-
...(identity
|
|
195
|
-
? {
|
|
196
|
-
child: {
|
|
197
|
-
identity,
|
|
198
|
-
requestResponse: identity.requestResponse,
|
|
199
|
-
reportsAtTurnStart: countSubagentReports(sessionStartBranch),
|
|
200
|
-
},
|
|
201
|
-
}
|
|
202
|
-
: {}),
|
|
203
|
-
};
|
|
288
|
+
current = { parent };
|
|
289
|
+
refreshChildSession();
|
|
204
290
|
if (identity) {
|
|
205
|
-
|
|
206
|
-
createSubmitTaskReportTool(pi, deps.messaging, () => {
|
|
207
|
-
const session = current;
|
|
208
|
-
return session?.child
|
|
209
|
-
? { ...session.parent, identity: session.child.identity }
|
|
210
|
-
: undefined;
|
|
211
|
-
}),
|
|
212
|
-
);
|
|
291
|
+
registerMessagingTools();
|
|
213
292
|
}
|
|
214
293
|
if (_event.reason === "reload") {
|
|
215
294
|
await reconciler.reconcile();
|
|
@@ -226,4 +305,5 @@ You are working as a subagent on one task delegated by a parent session. The han
|
|
|
226
305
|
current = undefined;
|
|
227
306
|
},
|
|
228
307
|
};
|
|
308
|
+
return handle;
|
|
229
309
|
}
|
|
@@ -84,6 +84,8 @@ export class SubagentReconciler {
|
|
|
84
84
|
private shuttingDown = false;
|
|
85
85
|
private registered: { epoch: number; sessionIds: Set<string> } | undefined;
|
|
86
86
|
private latestResult: ReconcileResult = emptyReconcileResult();
|
|
87
|
+
private sentReportIds = new Set<string>();
|
|
88
|
+
private pendingRecoveredReportIds = new Set<string>();
|
|
87
89
|
|
|
88
90
|
constructor(private readonly deps: ReconcileDependencies) {}
|
|
89
91
|
|
|
@@ -93,6 +95,25 @@ export class SubagentReconciler {
|
|
|
93
95
|
this.restoreSuspended = false;
|
|
94
96
|
this.registered = undefined;
|
|
95
97
|
this.latestResult = emptyReconcileResult();
|
|
98
|
+
this.sentReportIds.clear();
|
|
99
|
+
this.pendingRecoveredReportIds.clear();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
hasSentReport(reportId: string): boolean {
|
|
103
|
+
return this.sentReportIds.has(reportId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
noteReportSent(reportId: string): void {
|
|
107
|
+
// Pi may queue the message before persisting it. The ledger alone cannot deduplicate it yet.
|
|
108
|
+
this.sentReportIds.add(reportId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
hasPendingRecoveredReports(): boolean {
|
|
112
|
+
return this.pendingRecoveredReportIds.size > 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
noteReportDelivered(reportId: string): void {
|
|
116
|
+
this.pendingRecoveredReportIds.delete(reportId);
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
reconcile(): Promise<ReconcileResult> {
|
|
@@ -199,15 +220,21 @@ export class SubagentReconciler {
|
|
|
199
220
|
continue;
|
|
200
221
|
}
|
|
201
222
|
|
|
223
|
+
let reportLedger: ParentSubagentLedger | undefined;
|
|
202
224
|
for (const report of child.reports) {
|
|
203
|
-
if (
|
|
225
|
+
if (this.hasSentReport(report.reportId)) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Presence and tmux awaits can yield to live delivery; read only when recovery needs it.
|
|
229
|
+
reportLedger ??= collectParentLedger(parent.getBranch(), parent.sessionId);
|
|
230
|
+
if (!reportLedger.receivedReportIds.has(report.reportId)) {
|
|
204
231
|
this.append(parent, SUBAGENT_REPORT_RECEIVED_CUSTOM_TYPE, {
|
|
205
232
|
writerSessionId: parent.sessionId,
|
|
206
233
|
childSessionId: child.launch.childSessionId,
|
|
207
234
|
reportId: report.reportId,
|
|
208
235
|
});
|
|
209
236
|
}
|
|
210
|
-
if (!
|
|
237
|
+
if (!reportLedger.deliveredReportIds.has(report.reportId)) {
|
|
211
238
|
this.deliverRecoveredReport(parent, child.launch, report);
|
|
212
239
|
}
|
|
213
240
|
}
|
|
@@ -340,12 +367,35 @@ export class SubagentReconciler {
|
|
|
340
367
|
...report,
|
|
341
368
|
provenance: "recovered",
|
|
342
369
|
};
|
|
343
|
-
this.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
370
|
+
this.requireCurrent(parent);
|
|
371
|
+
const delivery = parent.isIdle()
|
|
372
|
+
? { triggerTurn: true as const }
|
|
373
|
+
: { deliverAs: "steer" as const };
|
|
374
|
+
this.pendingRecoveredReportIds.add(report.reportId);
|
|
375
|
+
try {
|
|
376
|
+
this.deps.executor.sendMessage(
|
|
377
|
+
{
|
|
378
|
+
customType: SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
|
|
379
|
+
content: formatReportForModel(launch.title, message),
|
|
380
|
+
display: true,
|
|
381
|
+
details: message,
|
|
382
|
+
},
|
|
383
|
+
delivery,
|
|
384
|
+
);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
this.pendingRecoveredReportIds.delete(report.reportId);
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
this.noteReportSent(report.reportId);
|
|
390
|
+
// Non-streaming steering appends directly, without an extension message_end event.
|
|
391
|
+
if (
|
|
392
|
+
"deliverAs" in delivery &&
|
|
393
|
+
collectParentLedger(parent.getBranch(), parent.sessionId).deliveredReportIds.has(
|
|
394
|
+
report.reportId,
|
|
395
|
+
)
|
|
396
|
+
) {
|
|
397
|
+
this.noteReportDelivered(report.reportId);
|
|
398
|
+
}
|
|
349
399
|
}
|
|
350
400
|
|
|
351
401
|
private sendDisownedMessage(parent: ReconcileParentSession): void {
|
|
@@ -359,17 +409,6 @@ export class SubagentReconciler {
|
|
|
359
409
|
});
|
|
360
410
|
}
|
|
361
411
|
|
|
362
|
-
private sendMessage(
|
|
363
|
-
parent: ReconcileParentSession,
|
|
364
|
-
message: { customType: string; content: string; display: boolean; details: unknown },
|
|
365
|
-
): void {
|
|
366
|
-
this.requireCurrent(parent);
|
|
367
|
-
const delivery = parent.isIdle()
|
|
368
|
-
? { triggerTurn: true as const }
|
|
369
|
-
: { deliverAs: "steer" as const };
|
|
370
|
-
this.deps.executor.sendMessage(message, delivery);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
412
|
private requireCurrent(parent: ReconcileParentSession): void {
|
|
374
413
|
if (!this.deps.isCurrent(parent.epoch)) {
|
|
375
414
|
throw new Error("The parent session changed during subagent reconciliation.");
|
|
@@ -46,8 +46,6 @@ export function createSubmitTaskReportTool(
|
|
|
46
46
|
promptSnippet: "Submit the current task report to the parent session and end the turn",
|
|
47
47
|
promptGuidelines: [
|
|
48
48
|
"Call submit_task_report exactly once as the final tool call for every delegated task or follow-up that expects a response.",
|
|
49
|
-
"Do not send task results through session_send_message; submit_task_report is the only supported means to send final results to the parent.",
|
|
50
|
-
"If you receive steering instructions from the parent via session_send_message, you may optionally reply to the parent using the session_send_message tool if the parent's message calls for something that is out of band of what would be included in the report.",
|
|
51
49
|
],
|
|
52
50
|
parameters: TASK_REPORT_SCHEMA,
|
|
53
51
|
async execute(_toolCallId, params) {
|
|
@@ -56,12 +56,17 @@ export function createSettledChildLifecycle(
|
|
|
56
56
|
cancel,
|
|
57
57
|
async settle(parent, child, reconciliation) {
|
|
58
58
|
cancel();
|
|
59
|
+
// Pi defers report turns sent during agent_settled without changing idle or pending state.
|
|
60
|
+
if (reconciler.hasPendingRecoveredReports()) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
59
63
|
const currentGeneration = generation;
|
|
60
64
|
let phase: "owned-subagents" | "settle" | "observer" =
|
|
61
65
|
reconciliation && hasRunningOwnedSubagents(reconciliation) ? "owned-subagents" : "settle";
|
|
62
66
|
const canAdvance = (): boolean =>
|
|
63
67
|
generation === currentGeneration &&
|
|
64
68
|
isCurrentSession(parent.epoch) &&
|
|
69
|
+
!reconciler.hasPendingRecoveredReports() &&
|
|
65
70
|
!parent.hasPendingMessages() &&
|
|
66
71
|
parent.isIdle();
|
|
67
72
|
const schedule = (delayMs: number): void => {
|
|
@@ -126,6 +131,32 @@ export function countSubagentReports(branch: readonly SessionEntry[]): number {
|
|
|
126
131
|
return getChildSubagentLifecycle(branch).reports.length;
|
|
127
132
|
}
|
|
128
133
|
|
|
134
|
+
export function refreshSubagentChildState(
|
|
135
|
+
sessionId: string,
|
|
136
|
+
branch: readonly SessionEntry[],
|
|
137
|
+
child: SubagentChildSessionState | undefined,
|
|
138
|
+
): SubagentChildSessionState | undefined {
|
|
139
|
+
const identity = findSelfSubagentIdentity(sessionId, branch);
|
|
140
|
+
if (!identity) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const reports = countSubagentReports(branch);
|
|
144
|
+
if (
|
|
145
|
+
child?.identity.childSessionId === identity.childSessionId &&
|
|
146
|
+
child.identity.ownerSessionId === identity.ownerSessionId &&
|
|
147
|
+
child.identity.depth === identity.depth &&
|
|
148
|
+
child.identity.requestResponse === identity.requestResponse &&
|
|
149
|
+
child.reportsAtTurnStart <= reports
|
|
150
|
+
) {
|
|
151
|
+
return child;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
identity,
|
|
155
|
+
requestResponse: identity.requestResponse,
|
|
156
|
+
reportsAtTurnStart: reports,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
129
160
|
export function findSelfSubagentIdentity(
|
|
130
161
|
sessionId: string,
|
|
131
162
|
branch: readonly SessionEntry[],
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isSessionStarting } from "../session-handoff/metadata.ts";
|
|
3
|
+
import { listTmuxWindows, type TmuxExecutor, tmuxSessionName } from "../shared/tmux.ts";
|
|
4
|
+
import { classifySubagent } from "./classify.ts";
|
|
5
|
+
import { collectParentLedger, getChildSubagentLifecycle } from "./ledger.ts";
|
|
6
|
+
|
|
7
|
+
interface SubagentSession {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
getBranch(): readonly SessionEntry[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ShouldMessageSubagentDependencies {
|
|
13
|
+
executor: TmuxExecutor;
|
|
14
|
+
messaging: { listSessions(): Promise<string[]> };
|
|
15
|
+
getParent(): SubagentSession | undefined;
|
|
16
|
+
openSession(path: string): SubagentSession;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function shouldMessageSubagent(
|
|
20
|
+
sessionId: string,
|
|
21
|
+
deps: ShouldMessageSubagentDependencies,
|
|
22
|
+
): Promise<boolean> {
|
|
23
|
+
const parent = deps.getParent();
|
|
24
|
+
if (!parent) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const ledger = collectParentLedger(parent.getBranch(), parent.sessionId);
|
|
28
|
+
const launch = ledger.launches.find((entry) => entry.childSessionId === sessionId);
|
|
29
|
+
if (!launch || ledger.cancelledChildIds.has(sessionId)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const [windows, liveSessions] = await Promise.all([
|
|
34
|
+
listTmuxWindows(deps.executor, tmuxSessionName(parent.sessionId)),
|
|
35
|
+
deps.messaging.listSessions(),
|
|
36
|
+
]);
|
|
37
|
+
let childBranch: readonly SessionEntry[] | undefined;
|
|
38
|
+
try {
|
|
39
|
+
const child = deps.openSession(launch.childSessionFile);
|
|
40
|
+
if (child.sessionId === sessionId) {
|
|
41
|
+
childBranch = child.getBranch();
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// An unreadable transcript cannot establish that an owned child has finished.
|
|
45
|
+
}
|
|
46
|
+
const lifecycle = childBranch ? getChildSubagentLifecycle(childBranch) : undefined;
|
|
47
|
+
const brokerLive = liveSessions.includes(sessionId);
|
|
48
|
+
const state = classifySubagent({
|
|
49
|
+
hasWindow: windows.some((window) => window.piSessionId === sessionId),
|
|
50
|
+
brokerLive,
|
|
51
|
+
hasRegistered: brokerLive,
|
|
52
|
+
awaitingKickoff: childBranch ? isSessionStarting(childBranch) : false,
|
|
53
|
+
cancelled: false,
|
|
54
|
+
suspended: ledger.suspendedChildIds.has(sessionId),
|
|
55
|
+
hasReportOrClosure: Boolean(lifecycle?.reports.length || lifecycle?.closed),
|
|
56
|
+
childReadable: childBranch !== undefined,
|
|
57
|
+
});
|
|
58
|
+
return state !== "completed" && state !== "stopped";
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,21 +37,21 @@
|
|
|
37
37
|
"check": "mise run check"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@biomejs/biome": "^2.
|
|
41
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
42
|
-
"@earendil-works/pi-ai": "^0.
|
|
43
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
44
|
-
"@earendil-works/pi-tui": "^0.
|
|
45
|
-
"@types/node": "^
|
|
40
|
+
"@biomejs/biome": "^2.5.10",
|
|
41
|
+
"@earendil-works/pi-agent-core": "^0.87.1",
|
|
42
|
+
"@earendil-works/pi-ai": "^0.87.1",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
44
|
+
"@earendil-works/pi-tui": "^0.87.1",
|
|
45
|
+
"@types/node": "^24.13.3",
|
|
46
46
|
"strip-ansi": "^7.2.0",
|
|
47
|
-
"typebox": "
|
|
47
|
+
"typebox": "1.3.27",
|
|
48
48
|
"typescript": "^7.0.0",
|
|
49
|
-
"vitest": "^4.1.
|
|
49
|
+
"vitest": "^4.1.11"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@earendil-works/pi-agent-core": ">=0.
|
|
53
|
-
"@earendil-works/pi-ai": ">=0.
|
|
54
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
55
|
-
"@earendil-works/pi-tui": ">=0.
|
|
52
|
+
"@earendil-works/pi-agent-core": ">=0.87.1",
|
|
53
|
+
"@earendil-works/pi-ai": ">=0.87.1",
|
|
54
|
+
"@earendil-works/pi-coding-agent": ">=0.87.1",
|
|
55
|
+
"@earendil-works/pi-tui": ">=0.87.1"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import { parseSessionFile } from "../session-search/extract.ts";
|
|
3
|
-
import {
|
|
4
|
-
getSessionById,
|
|
5
|
-
type SessionLineageRow,
|
|
6
|
-
type SessionOrigin,
|
|
7
|
-
withSessionIndex,
|
|
8
|
-
} from "../shared/session-index/index.ts";
|
|
9
|
-
|
|
10
|
-
const HANDOFF_REF_PREFIX = "@handoff/";
|
|
11
|
-
const SESSION_REFERENCE_HELP =
|
|
12
|
-
"Expected an absolute session path, a raw session id, or @handoff/<full-session-id>.";
|
|
13
|
-
|
|
14
|
-
export type SessionReferenceKind = "path" | "session_id" | "handoff_ref";
|
|
15
|
-
|
|
16
|
-
export interface ResolvedSessionReference {
|
|
17
|
-
input: string;
|
|
18
|
-
kind: SessionReferenceKind;
|
|
19
|
-
canonicalRef: string;
|
|
20
|
-
sessionId: string;
|
|
21
|
-
sessionPath: string;
|
|
22
|
-
sessionName: string;
|
|
23
|
-
parentSessionPath?: string | undefined;
|
|
24
|
-
parentSessionId?: string | undefined;
|
|
25
|
-
sessionOrigin?: SessionOrigin | undefined;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface SessionReferenceResolution {
|
|
29
|
-
resolved?: ResolvedSessionReference | undefined;
|
|
30
|
-
error?: string | undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function formatHandoffRef(sessionId: string): string {
|
|
34
|
-
return `${HANDOFF_REF_PREFIX}${sessionId}`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function isHandoffRef(value: string): boolean {
|
|
38
|
-
return value.startsWith(HANDOFF_REF_PREFIX);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function resolveSessionReference(
|
|
42
|
-
reference: string,
|
|
43
|
-
options: { indexPath: string },
|
|
44
|
-
): SessionReferenceResolution {
|
|
45
|
-
const trimmed = reference.trim();
|
|
46
|
-
if (!trimmed) {
|
|
47
|
-
return { error: `Missing session reference. ${SESSION_REFERENCE_HELP}` };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (path.isAbsolute(trimmed)) {
|
|
51
|
-
return resolveAbsoluteSessionPath(trimmed);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (isHandoffRef(trimmed)) {
|
|
55
|
-
return resolveIndexedReference(
|
|
56
|
-
trimmed,
|
|
57
|
-
trimmed.slice(HANDOFF_REF_PREFIX.length),
|
|
58
|
-
"handoff_ref",
|
|
59
|
-
options,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return resolveIndexedReference(trimmed, trimmed, "session_id", options);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function resolveAbsoluteSessionPath(sessionPath: string): SessionReferenceResolution {
|
|
67
|
-
try {
|
|
68
|
-
const parsed = parseSessionFile(sessionPath);
|
|
69
|
-
if (!parsed) {
|
|
70
|
-
return { error: `Unable to parse session file: ${sessionPath}` };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
resolved: {
|
|
75
|
-
input: sessionPath,
|
|
76
|
-
kind: "path",
|
|
77
|
-
canonicalRef: formatHandoffRef(parsed.header.id),
|
|
78
|
-
sessionId: parsed.header.id,
|
|
79
|
-
sessionPath,
|
|
80
|
-
sessionName: parsed.sessionName,
|
|
81
|
-
parentSessionPath: normalizeOptionalString(parsed.header.parentSession),
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
} catch (error) {
|
|
85
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
86
|
-
return { error: `Session file not found: ${sessionPath}` };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return { error: `Unable to load session file: ${sessionPath}` };
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function resolveIndexedReference(
|
|
94
|
-
input: string,
|
|
95
|
-
rawValue: string,
|
|
96
|
-
kind: SessionReferenceKind,
|
|
97
|
-
options: { indexPath: string },
|
|
98
|
-
): SessionReferenceResolution {
|
|
99
|
-
const value = rawValue.trim();
|
|
100
|
-
if (!value) {
|
|
101
|
-
return { error: `Invalid session reference: ${input}. ${SESSION_REFERENCE_HELP}` };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
return withSessionIndex(options.indexPath, { mode: "read", required: true }, ({ db }) => {
|
|
106
|
-
const exactMatch = getSessionById(db, value);
|
|
107
|
-
if (exactMatch) {
|
|
108
|
-
return { resolved: buildResolvedReference(input, kind, exactMatch) };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { error: `No session found for reference: ${input}. ${SESSION_REFERENCE_HELP}` };
|
|
112
|
-
});
|
|
113
|
-
} catch {
|
|
114
|
-
return {
|
|
115
|
-
error: `Session reference resolution requires a current index at ${options.indexPath}. Run /session-index and press r to rebuild it.`,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function buildResolvedReference(
|
|
121
|
-
input: string,
|
|
122
|
-
kind: SessionReferenceKind,
|
|
123
|
-
session: SessionLineageRow,
|
|
124
|
-
): ResolvedSessionReference {
|
|
125
|
-
return {
|
|
126
|
-
input,
|
|
127
|
-
kind,
|
|
128
|
-
canonicalRef: formatHandoffRef(session.sessionId),
|
|
129
|
-
sessionId: session.sessionId,
|
|
130
|
-
sessionPath: session.sessionPath,
|
|
131
|
-
sessionName: session.sessionName,
|
|
132
|
-
parentSessionPath: session.parentSessionPath,
|
|
133
|
-
parentSessionId: session.parentSessionId,
|
|
134
|
-
sessionOrigin: session.sessionOrigin,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function normalizeOptionalString(value: string | undefined): string | undefined {
|
|
139
|
-
if (typeof value !== "string") {
|
|
140
|
-
return undefined;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const trimmed = value.trim();
|
|
144
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
145
|
-
}
|