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.
Files changed (35) hide show
  1. package/README.md +6 -3
  2. package/extensions/pi-sessions.ts +20 -6
  3. package/extensions/session-ask/install.ts +24 -10
  4. package/extensions/session-auto-title/generate.ts +17 -14
  5. package/extensions/session-auto-title/install.ts +6 -29
  6. package/extensions/session-auto-title/retitle.ts +6 -19
  7. package/extensions/session-auto-title/wizard.ts +97 -31
  8. package/extensions/session-handoff/board-loading.ts +93 -0
  9. package/extensions/session-handoff/board.ts +239 -127
  10. package/extensions/session-handoff/install.ts +5 -5
  11. package/extensions/session-handoff/picker.ts +167 -19
  12. package/extensions/session-handoff/review.ts +56 -7
  13. package/extensions/session-handoff/ui.ts +32 -7
  14. package/extensions/session-hooks/install.ts +7 -20
  15. package/extensions/session-index/install.ts +8 -86
  16. package/extensions/session-index/loader.ts +80 -0
  17. package/extensions/session-index/panel.ts +103 -0
  18. package/extensions/session-messaging/broker/spawn.ts +35 -12
  19. package/extensions/session-messaging/pi/reachable-tool.ts +6 -1
  20. package/extensions/session-messaging/pi/tools.ts +79 -12
  21. package/extensions/session-search/extract.ts +120 -91
  22. package/extensions/session-search/hooks.ts +25 -203
  23. package/extensions/session-search/reindex.ts +13 -8
  24. package/extensions/shared/legend.ts +116 -0
  25. package/extensions/shared/session-index/scoring.ts +0 -1
  26. package/extensions/shared/session-index/search.ts +76 -49
  27. package/extensions/shared/settings.ts +22 -0
  28. package/extensions/subagents/context-limit.ts +58 -0
  29. package/extensions/subagents/install.ts +110 -30
  30. package/extensions/subagents/reconcile.ts +58 -19
  31. package/extensions/subagents/report.ts +0 -2
  32. package/extensions/subagents/settle.ts +31 -0
  33. package/extensions/subagents/should-message.ts +59 -0
  34. package/package.json +13 -13
  35. 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: { settings: SessionSettings; messaging: MessagingHandle },
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
- const child = current?.child;
127
- if (!child) {
128
- return;
153
+ if (current?.child) {
154
+ event.systemPromptOptions.sections[SUBAGENT_PROMPT_SECTION] = SUBAGENT_PROMPT;
129
155
  }
130
- return {
131
- systemPrompt: `${event.systemPrompt}
156
+ });
132
157
 
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
- };
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
- return {
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
- parent,
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
- 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
- );
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 (!ledger.receivedReportIds.has(report.reportId)) {
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 (!ledger.deliveredReportIds.has(report.reportId)) {
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.sendMessage(parent, {
344
- customType: SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE,
345
- content: formatReportForModel(launch.title, message),
346
- display: true,
347
- details: message,
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.12.1",
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.4.14",
41
- "@earendil-works/pi-agent-core": "^0.84.1",
42
- "@earendil-works/pi-ai": "^0.84.1",
43
- "@earendil-works/pi-coding-agent": "^0.84.1",
44
- "@earendil-works/pi-tui": "^0.84.1",
45
- "@types/node": "^25.6.2",
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": "^1.1.38",
47
+ "typebox": "1.3.27",
48
48
  "typescript": "^7.0.0",
49
- "vitest": "^4.1.5"
49
+ "vitest": "^4.1.11"
50
50
  },
51
51
  "peerDependencies": {
52
- "@earendil-works/pi-agent-core": ">=0.83.0",
53
- "@earendil-works/pi-ai": ">=0.83.0",
54
- "@earendil-works/pi-coding-agent": ">=0.83.0",
55
- "@earendil-works/pi-tui": ">=0.83.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
- }