pi-sessions 0.14.3 → 0.16.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.
Files changed (31) hide show
  1. package/README.md +63 -9
  2. package/extensions/hosts/contract.ts +51 -0
  3. package/extensions/hosts/registry.ts +129 -0
  4. package/extensions/pi-sessions.ts +36 -10
  5. package/extensions/session-handoff/install.ts +4 -1
  6. package/extensions/session-handoff/launch/backend.ts +3 -10
  7. package/extensions/session-handoff/launch-options.ts +17 -2
  8. package/extensions/session-handoff/launch-target.ts +28 -16
  9. package/extensions/session-handoff/metadata.ts +2 -0
  10. package/extensions/session-handoff/query.ts +5 -4
  11. package/extensions/session-handoff/spawn.ts +2 -1
  12. package/extensions/session-handoff/tool-schema.ts +10 -2
  13. package/extensions/session-handoff/tool.ts +1 -0
  14. package/extensions/session-hooks/install.ts +14 -5
  15. package/extensions/session-index/install.ts +25 -4
  16. package/extensions/session-index/panel.ts +9 -0
  17. package/extensions/session-messaging/message-router.ts +91 -0
  18. package/extensions/session-messaging/pi/reachable-contract.ts +2 -1
  19. package/extensions/session-messaging/pi/reachable-tool.ts +24 -4
  20. package/extensions/session-messaging/pi/tools.ts +9 -1
  21. package/extensions/shared/session-index/access.ts +42 -5
  22. package/extensions/shared/session-index/common.ts +1 -0
  23. package/extensions/shared/session-index/recovery.ts +270 -0
  24. package/extensions/shared/session-index/schema.ts +9 -1
  25. package/extensions/shared/session-index/sqlite.ts +20 -10
  26. package/extensions/{session-search/hooks.ts → shared/session-index/sync.ts} +40 -16
  27. package/extensions/subagents/install.ts +13 -13
  28. package/extensions/subagents/settle.ts +1 -1
  29. package/extensions/subagents/wake.ts +32 -116
  30. package/package.json +5 -5
  31. package/extensions/session-search/reindex.ts +0 -97
@@ -0,0 +1,270 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { setTimeout as delay, setImmediate as yieldToEventLoop } from "node:timers/promises";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { type Static, Type } from "typebox";
7
+ import { listSessionFiles } from "../../session-search/extract.ts";
8
+ import { parseTypeBoxValue } from "../typebox.ts";
9
+ import { INDEX_SCHEMA_VERSION, ROW_COUNT_SCHEMA, type SessionIndexDatabase } from "./common.ts";
10
+ import {
11
+ ensureIndexDir,
12
+ getMetadata,
13
+ initializeSchema,
14
+ openIndexDatabase,
15
+ setMetadata,
16
+ } from "./schema.ts";
17
+ import { syncSessionFileWithDb } from "./sync.ts";
18
+
19
+ export const INDEX_UPDATED_WARNING = "pi-sessions was updated; /reload to use it";
20
+ const BUILD = `index-v${INDEX_SCHEMA_VERSION}:${createHash("sha256")
21
+ .update(
22
+ ["recovery.ts", "schema.ts", "sync.ts"]
23
+ .map((file) => readFileSync(new URL(file, import.meta.url)))
24
+ .join(""),
25
+ )
26
+ .digest("hex")
27
+ .slice(0, 16)}`;
28
+ const FAILURE_SCHEMA = Type.Object({
29
+ attempts: Type.Integer({ minimum: 1 }),
30
+ nextAllowedAt: Type.Number(),
31
+ error: Type.String(),
32
+ build: Type.String(),
33
+ });
34
+ export type IndexRecoveryFailure = Static<typeof FAILURE_SCHEMA>;
35
+ export interface ReindexResult {
36
+ sessionCount: number;
37
+ chunkCount: number;
38
+ indexPath: string;
39
+ skipped: string[];
40
+ }
41
+ const running = new Map<string, Promise<ReindexResult | undefined>>();
42
+ const notices = new Map<string, (message: string | undefined) => void>();
43
+ const shown = new Map<string, string>();
44
+ const disabled = new Set<string>();
45
+
46
+ export function observeIndexRecovery(
47
+ indexPath: string,
48
+ notify: (message: string | undefined) => void,
49
+ ): void {
50
+ notices.set(indexPath, notify);
51
+ const message = getIndexRecoveryMessage(indexPath);
52
+ notify(message);
53
+ if (message) shown.set(indexPath, message);
54
+ }
55
+
56
+ function report(indexPath: string, message: string): void {
57
+ if (shown.get(indexPath) === message) return;
58
+ shown.set(indexPath, message);
59
+ const notify = notices.get(indexPath);
60
+ if (notify) notify(message);
61
+ else console.error(message);
62
+ }
63
+
64
+ export function disableOlderIndexClient(indexPath: string): void {
65
+ disabled.add(indexPath);
66
+ report(indexPath, INDEX_UPDATED_WARNING);
67
+ }
68
+
69
+ export function isIndexClientDisabled(indexPath: string): boolean {
70
+ return disabled.has(indexPath);
71
+ }
72
+
73
+ export function readIndexRecoveryFailure(indexPath: string): IndexRecoveryFailure | undefined {
74
+ const file = `${indexPath}.failure.json`;
75
+ let raw: string;
76
+ try {
77
+ raw = readFileSync(file, "utf8");
78
+ } catch (error) {
79
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
80
+ throw error;
81
+ }
82
+ return parseTypeBoxValue(FAILURE_SCHEMA, JSON.parse(raw), "Invalid index recovery record");
83
+ }
84
+
85
+ export function getIndexRecoveryMessage(indexPath: string): string | undefined {
86
+ if (disabled.has(indexPath)) return INDEX_UPDATED_WARNING;
87
+ const failure = readIndexRecoveryFailure(indexPath);
88
+ if (failure)
89
+ return `Session indexing failed: ${failure.error}. Retry after ${new Date(failure.nextAllowedAt).toISOString()}; /session-index can rebuild now.`;
90
+ if (running.has(indexPath)) return "Session indexing in progress; try again shortly.";
91
+ if (existsSync(`${indexPath}.lock.sqlite`)) {
92
+ const release = acquireIndexLock(indexPath);
93
+ if (!release) return "Session indexing in progress; try again shortly.";
94
+ release();
95
+ }
96
+ return undefined;
97
+ }
98
+
99
+ // All index writers share this lock with recovery. Reads are short-lived and may
100
+ // finish on the old inode after an atomic replacement (no WAL sidecars).
101
+ // SQLite owns the lock lifetime, including process death; never unlink its file.
102
+ export function acquireIndexLock(indexPath: string): (() => void) | undefined {
103
+ ensureIndexDir(dirname(indexPath));
104
+ let lock: SessionIndexDatabase | undefined;
105
+ try {
106
+ lock = openIndexDatabase(`${indexPath}.lock.sqlite`, { create: true, timeoutMs: 0 });
107
+ lock.exec("BEGIN EXCLUSIVE");
108
+ const held = lock;
109
+ return () => held.close();
110
+ } catch (error) {
111
+ lock?.close();
112
+ if (isSqliteBusy(error)) return undefined;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ function isSqliteBusy(error: unknown): boolean {
118
+ return (
119
+ error instanceof Error &&
120
+ (("code" in error && error.code === "SQLITE_BUSY") ||
121
+ ("errcode" in error && error.errcode === 5))
122
+ );
123
+ }
124
+
125
+ export function requestIndexRecovery(
126
+ indexPath: string,
127
+ reconcile = false,
128
+ ): Promise<ReindexResult | undefined> {
129
+ const pending = running.get(indexPath);
130
+ if (pending) return pending;
131
+ const sessionsDir = join(getAgentDir(), "sessions");
132
+ const task = yieldToEventLoop()
133
+ .then(() => recover(indexPath, sessionsDir, false, reconcile))
134
+ .catch((error: unknown) => {
135
+ report(indexPath, `Session indexing failed: ${String(error)}`);
136
+ return undefined;
137
+ })
138
+ .finally(() => running.delete(indexPath));
139
+ running.set(indexPath, task);
140
+ return task;
141
+ }
142
+
143
+ export async function rebuildSessionIndex(options: { indexPath: string }): Promise<ReindexResult> {
144
+ await running.get(options.indexPath);
145
+ const result = await recover(options.indexPath, join(getAgentDir(), "sessions"), true, true);
146
+ if (!result)
147
+ throw new Error(
148
+ getIndexRecoveryMessage(options.indexPath) ?? "Session index is busy; try again shortly.",
149
+ );
150
+ return result;
151
+ }
152
+
153
+ async function recover(
154
+ indexPath: string,
155
+ sessionsDir: string,
156
+ manual: boolean,
157
+ reconcile: boolean,
158
+ ): Promise<ReindexResult | undefined> {
159
+ if (disabled.has(indexPath)) return undefined;
160
+ let release = acquireIndexLock(indexPath);
161
+ const deadline = Date.now() + 30_000;
162
+ while (!release && Date.now() < deadline) {
163
+ await delay(50);
164
+ release = acquireIndexLock(indexPath);
165
+ }
166
+ if (!release) return undefined;
167
+ let db: SessionIndexDatabase | undefined;
168
+ const temp = `${indexPath}.${randomUUID()}.tmp`;
169
+ try {
170
+ const failure = readIndexRecoveryFailure(indexPath);
171
+ let version: number | undefined;
172
+ let corrupt = false;
173
+ if (existsSync(indexPath)) {
174
+ try {
175
+ const probe = openIndexDatabase(indexPath, { create: false, mode: "read" });
176
+ try {
177
+ version = Number(getMetadata(probe, "schema_version"));
178
+ if (version > INDEX_SCHEMA_VERSION) {
179
+ disableOlderIndexClient(indexPath);
180
+ return undefined;
181
+ }
182
+ probe.prepare("SELECT COUNT(*) FROM sessions").get();
183
+ } finally {
184
+ probe.close();
185
+ }
186
+ } catch (error) {
187
+ if (isSqliteBusy(error)) throw error;
188
+ corrupt = true;
189
+ }
190
+ }
191
+ if (!manual && failure && Date.now() < failure.nextAllowedAt) {
192
+ report(indexPath, getIndexRecoveryMessage(indexPath) ?? failure.error);
193
+ return undefined;
194
+ }
195
+ const rebuild = manual || corrupt || version !== INDEX_SCHEMA_VERSION;
196
+ if (!rebuild && !reconcile) {
197
+ if (!failure) notices.get(indexPath)?.(undefined);
198
+ return undefined;
199
+ }
200
+ db = openIndexDatabase(rebuild ? temp : indexPath, { create: rebuild });
201
+ if (rebuild) initializeSchema(db);
202
+ const skipped: string[] = [];
203
+ for (const file of listSessionFiles(sessionsDir)) {
204
+ if (!syncSessionFileWithDb(db, file, "reconcile")) skipped.push(file);
205
+ await yieldToEventLoop();
206
+ }
207
+ const indexedDb = db;
208
+ const count = (table: string) =>
209
+ parseTypeBoxValue(
210
+ ROW_COUNT_SCHEMA,
211
+ indexedDb.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get(),
212
+ "Invalid index count",
213
+ ).count;
214
+ const result = {
215
+ sessionCount: count("sessions"),
216
+ chunkCount: count("session_text_chunks"),
217
+ indexPath,
218
+ skipped,
219
+ };
220
+ db.transaction(() => {
221
+ setMetadata(indexedDb, rebuild ? "indexed_at" : "reconciled_at", new Date().toISOString());
222
+ if (rebuild) setMetadata(indexedDb, "build", BUILD);
223
+ });
224
+ db.close();
225
+ db = undefined;
226
+ if (rebuild) {
227
+ if (corrupt) {
228
+ const backup = `${indexPath}.bad-${Date.now()}-${randomUUID()}`;
229
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
230
+ if (existsSync(`${indexPath}${suffix}`))
231
+ renameSync(`${indexPath}${suffix}`, `${backup}${suffix}`);
232
+ }
233
+ } else if (existsSync(indexPath)) {
234
+ // Upgrade legacy WAL indexes before swapping: SQLite checkpoints and
235
+ // refuses the mode change while another connection still needs its WAL.
236
+ const previous = openIndexDatabase(indexPath, { create: false });
237
+ previous.close();
238
+ }
239
+ renameSync(temp, indexPath);
240
+ }
241
+ rmSync(`${indexPath}.failure.json`, { force: true });
242
+ shown.delete(indexPath);
243
+ notices.get(indexPath)?.(undefined);
244
+ if (skipped.length)
245
+ report(
246
+ indexPath,
247
+ `Session index skipped ${skipped.length} unreadable transcript(s): ${skipped.join(", ")}`,
248
+ );
249
+ return result;
250
+ } catch (error) {
251
+ const attempts = (readIndexRecoveryFailure(indexPath)?.attempts ?? 0) + 1;
252
+ const failure: IndexRecoveryFailure = {
253
+ attempts,
254
+ nextAllowedAt: Date.now() + (attempts === 1 ? 60_000 : attempts === 2 ? 300_000 : 1_800_000),
255
+ error: error instanceof Error ? error.message : String(error),
256
+ build: BUILD,
257
+ };
258
+ const record = `${indexPath}.failure.json`;
259
+ writeFileSync(`${record}.tmp`, JSON.stringify(failure));
260
+ renameSync(`${record}.tmp`, record);
261
+ report(indexPath, getIndexRecoveryMessage(indexPath) ?? failure.error);
262
+ if (manual) throw error;
263
+ return undefined;
264
+ } finally {
265
+ db?.close();
266
+ for (const suffix of ["", "-wal", "-shm", "-journal"])
267
+ rmSync(`${temp}${suffix}`, { force: true });
268
+ release();
269
+ }
270
+ }
@@ -22,7 +22,12 @@ export function openIndexDatabase(
22
22
  ): SessionIndexDatabase {
23
23
  const create = options?.create ?? true;
24
24
  const readonly = options?.mode === "read";
25
- return openSqlite(dbPath, { create, readonly, timeoutMs: options?.timeoutMs });
25
+ return openSqlite(dbPath, {
26
+ create,
27
+ readonly,
28
+ timeoutMs: options?.timeoutMs,
29
+ journalMode: "DELETE",
30
+ });
26
31
  }
27
32
 
28
33
  export function initializeSchema(db: SessionIndexDatabase): void {
@@ -180,6 +185,9 @@ export function getIndexStatus(dbPath: string): SessionIndexStatus {
180
185
  try {
181
186
  db = openIndexDatabase(dbPath, { create: false, mode: "read" });
182
187
  const schemaVersionRaw = getMetadata(db, "schema_version");
188
+ if (Number(schemaVersionRaw) !== INDEX_SCHEMA_VERSION) {
189
+ return { dbPath, exists: true, schemaVersion: Number(schemaVersionRaw) };
190
+ }
183
191
  const lastFullReindexAt = getMetadata(db, "indexed_at");
184
192
  const sessionCountRow = parseTypeBoxValue(
185
193
  ROW_COUNT_SCHEMA,
@@ -47,7 +47,12 @@ function loadDatabaseConstructor(): SqliteConstructor {
47
47
 
48
48
  export function openSqlite(
49
49
  dbPath: string,
50
- options: { create: boolean; readonly?: boolean | undefined; timeoutMs?: number | undefined },
50
+ options: {
51
+ create: boolean;
52
+ readonly?: boolean | undefined;
53
+ timeoutMs?: number | undefined;
54
+ journalMode?: "DELETE" | "WAL";
55
+ },
51
56
  ): SqliteDatabase {
52
57
  const Database = loadDatabaseConstructor();
53
58
  const readonly = options.readonly ?? false;
@@ -66,15 +71,20 @@ export function openSqlite(
66
71
  })
67
72
  : new Database(dbPath, { readOnly: readonly });
68
73
 
69
- db.exec(`PRAGMA busy_timeout = ${options.timeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS}`);
70
- db.exec("PRAGMA foreign_keys = ON");
71
-
72
- if (!readonly) {
73
- // journal_mode is the connection's first write-capable lock acquisition (and
74
- // may run WAL recovery), and immediate transactions rely on busy_timeout to
75
- // queue behind concurrent writers instead of failing.
76
- db.exec("PRAGMA journal_mode = WAL");
77
- db.exec("PRAGMA synchronous = NORMAL");
74
+ try {
75
+ db.exec(`PRAGMA busy_timeout = ${options.timeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS}`);
76
+ db.exec("PRAGMA foreign_keys = ON");
77
+
78
+ if (!readonly) {
79
+ // journal_mode is the connection's first write-capable lock acquisition (and
80
+ // may run WAL recovery), and immediate transactions rely on busy_timeout to
81
+ // queue behind concurrent writers instead of failing.
82
+ db.exec(`PRAGMA journal_mode = ${options.journalMode ?? "WAL"}`);
83
+ db.exec("PRAGMA synchronous = NORMAL");
84
+ }
85
+ } catch (error) {
86
+ db.close();
87
+ throw error;
78
88
  }
79
89
 
80
90
  return withStatementCache(db);
@@ -1,4 +1,14 @@
1
1
  import { existsSync, type Stats, statSync } from "node:fs";
2
+ import {
3
+ createSessionNameChunk,
4
+ type ExtractedSessionRecord,
5
+ type ExtractedSessionTail,
6
+ extractSessionRecord,
7
+ extractSessionTail,
8
+ inferSessionOrigin,
9
+ type SessionFileTouch,
10
+ } from "../../session-search/extract.ts";
11
+ import { deriveSessionRepoRoots } from "../../session-search/normalize.ts";
2
12
  import {
3
13
  clearSessionChunksBySourceKind,
4
14
  clearSessionIndexedData,
@@ -14,17 +24,7 @@ import {
14
24
  setMetadata,
15
25
  upsertSession,
16
26
  withSessionIndex,
17
- } from "../shared/session-index/index.ts";
18
- import {
19
- createSessionNameChunk,
20
- type ExtractedSessionRecord,
21
- type ExtractedSessionTail,
22
- extractSessionRecord,
23
- extractSessionTail,
24
- inferSessionOrigin,
25
- type SessionFileTouch,
26
- } from "./extract.ts";
27
- import { deriveSessionRepoRoots } from "./normalize.ts";
27
+ } from "./index.ts";
28
28
 
29
29
  export interface SessionHookController {
30
30
  handleSessionStart(sessionFile: string | undefined): Promise<boolean>;
@@ -107,32 +107,56 @@ interface TailSyncBaseline extends SessionRow {
107
107
  indexedFileAnchor: string;
108
108
  }
109
109
 
110
- function syncSessionFileWithDb(
110
+ export function syncSessionFileWithDb(
111
111
  db: SessionIndexDatabase,
112
112
  sessionFile: string,
113
113
  eventType: string,
114
114
  sessionOrigin?: SessionOrigin,
115
115
  ): boolean {
116
116
  const baseline = asTailSyncBaseline(getSessionRowByPath(db, sessionFile));
117
- const stat = statSync(sessionFile);
117
+ let stat: Stats;
118
+ try {
119
+ stat = statSync(sessionFile);
120
+ } catch {
121
+ return false;
122
+ }
118
123
 
119
124
  if (baseline && isIndexCurrent(baseline, stat, sessionOrigin)) {
120
- db.transaction(() => writeHookSyncMetadata(db, eventType));
125
+ if (eventType !== "reconcile") db.transaction(() => writeHookSyncMetadata(db, eventType));
121
126
  return true;
122
127
  }
123
128
 
124
129
  if (baseline && stat.size > baseline.indexedFileSize) {
125
- const tail = extractSessionTail(sessionFile, baseline);
130
+ let tail: ExtractedSessionTail | undefined;
131
+ try {
132
+ tail = extractSessionTail(sessionFile, baseline);
133
+ } catch {
134
+ return false;
135
+ }
126
136
  if (tail && applyTailSync(db, baseline, tail, eventType, sessionOrigin)) {
127
137
  return true;
128
138
  }
129
139
  }
130
140
 
131
- const extracted = extractSessionRecord(sessionFile);
141
+ let extracted: ExtractedSessionRecord | undefined;
142
+ try {
143
+ extracted = extractSessionRecord(sessionFile);
144
+ } catch {
145
+ return false;
146
+ }
132
147
  if (!extracted) {
133
148
  return false;
134
149
  }
135
150
 
151
+ const existing = getSessionById(db, extracted.sessionId);
152
+ if (
153
+ existing &&
154
+ existing.sessionPath !== sessionFile &&
155
+ (existing.modifiedAt > extracted.modifiedAt ||
156
+ (existing.modifiedAt === extracted.modifiedAt && existing.sessionPath > sessionFile))
157
+ )
158
+ return true;
159
+
136
160
  applyFullSync(db, extracted, eventType, sessionOrigin);
137
161
  return true;
138
162
  }
@@ -9,6 +9,7 @@ import type {
9
9
  SendMessageRequest,
10
10
  SendMessageResult,
11
11
  } from "../session-messaging/install.ts";
12
+ import type { SessionWaker } from "../session-messaging/message-router.ts";
12
13
  import {
13
14
  createSessionCancelTool,
14
15
  createSessionSendMessageTool,
@@ -43,7 +44,7 @@ import {
43
44
  type SubagentChildSessionState,
44
45
  } from "./settle.ts";
45
46
  import { shouldMessageSubagent } from "./should-message.ts";
46
- import { SubagentMessageRouter } from "./wake.ts";
47
+ import { SubagentWaker } from "./wake.ts";
47
48
 
48
49
  const SUBAGENT_PROMPT_SECTION = "pi_sessions_subagent";
49
50
  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.`;
@@ -65,6 +66,7 @@ interface CurrentSubagentSession {
65
66
 
66
67
  export interface SubagentsHandle extends SessionLifecycle {
67
68
  roster: SubagentRoster;
69
+ waker: SessionWaker;
68
70
  getLaunchTargets(): readonly HandoffLaunchTarget[];
69
71
  sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
70
72
  cancelSession(sessionId: string): Promise<SubagentCancelResult>;
@@ -79,6 +81,8 @@ export function installSubagents(
79
81
  settings: SessionSettings;
80
82
  messaging: MessagingHandle;
81
83
  readCompactionSettings: (cwd: string) => CompactionThresholdSettings;
84
+ sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
85
+ hasHostedSessions?: () => boolean;
82
86
  },
83
87
  ): SubagentsHandle {
84
88
  pi.registerMessageRenderer(SUBAGENT_REPORT_MESSAGE_CUSTOM_TYPE, renderSubagentReportMessage);
@@ -107,18 +111,12 @@ export function installSubagents(
107
111
  reconcile: () => reconciler.reconcile(),
108
112
  openSession: openRosterSession,
109
113
  });
110
- const messageRouter = new SubagentMessageRouter(
111
- pi,
112
- deps.messaging,
113
- () => current?.parent,
114
- isCurrentSession,
115
- {
116
- onMaterialize: (launch) => pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launch),
117
- afterOwnedSend: async () => {
118
- await reconciler.reconcile();
119
- },
114
+ const waker = new SubagentWaker(pi, deps.messaging, () => current?.parent, isCurrentSession, {
115
+ onMaterialize: (launch) => pi.appendEntry(SUBAGENT_LAUNCHED_CUSTOM_TYPE, launch),
116
+ afterSend: async () => {
117
+ await reconciler.reconcile();
120
118
  },
121
- );
119
+ });
122
120
  const cancellationRouter = new SubagentCancellationRouter(
123
121
  pi,
124
122
  deps.messaging,
@@ -232,6 +230,7 @@ export function installSubagents(
232
230
  pi.registerTool(
233
231
  createSessionSendMessageTool(handle, {
234
232
  role: isSubagent ? { kind: "subagent" } : { kind: "wakeCapable" },
233
+ hostedSessions: deps.hasHostedSessions?.() ?? false,
235
234
  getCachedRelationTo: deps.messaging.getCachedRelationTo,
236
235
  getParentSessionId: () => handle.getParentSessionId(),
237
236
  }),
@@ -257,7 +256,8 @@ export function installSubagents(
257
256
 
258
257
  const handle: SubagentsHandle = {
259
258
  roster,
260
- sendMessage: (request) => messageRouter.sendMessage(request),
259
+ waker,
260
+ sendMessage: deps.sendMessage,
261
261
  cancelSession: (sessionId) => cancellationRouter.cancelSession(sessionId),
262
262
  shouldMessageSubagent: (sessionId) =>
263
263
  shouldMessageSubagent(sessionId, {
@@ -170,7 +170,7 @@ export function findSelfSubagentIdentity(
170
170
  if (
171
171
  bootstrap?.launch !== SUBAGENT_LAUNCH ||
172
172
  bootstrap.sessionId !== sessionId ||
173
- bootstrap.subagent.childSessionId !== sessionId
173
+ bootstrap.subagent?.childSessionId !== sessionId
174
174
  ) {
175
175
  return undefined;
176
176
  }