pi-sessions 0.10.0 → 0.11.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 (34) hide show
  1. package/README.md +25 -20
  2. package/dist/session-messaging/broker/client-frame.js +62 -0
  3. package/dist/session-messaging/broker/process.js +2 -2
  4. package/dist/shared/session-broker/framing.js +4 -3
  5. package/extensions/pi-sessions.ts +14 -1
  6. package/extensions/session-auto-title/generate.ts +56 -66
  7. package/extensions/session-auto-title/install.ts +2 -0
  8. package/extensions/session-auto-title/runs.ts +50 -0
  9. package/extensions/session-handoff/kickoff.ts +4 -3
  10. package/extensions/session-handoff/metadata.ts +1 -1
  11. package/extensions/session-messaging/broker/client-frame.ts +113 -0
  12. package/extensions/session-messaging/broker/process.ts +12 -21
  13. package/extensions/session-messaging/pi/reachable-contract.ts +59 -0
  14. package/extensions/session-messaging/pi/reachable-presenter.ts +49 -0
  15. package/extensions/session-messaging/pi/reachable-tool.ts +190 -0
  16. package/extensions/session-messaging/pi/reachable-view-model.ts +59 -0
  17. package/extensions/session-messaging/pi/renderer.ts +1 -1
  18. package/extensions/session-messaging/pi/tools.ts +8 -8
  19. package/extensions/session-search/hooks.ts +45 -52
  20. package/extensions/session-search/install.ts +3 -158
  21. package/extensions/session-search/reindex.ts +3 -3
  22. package/extensions/session-search/renderer.ts +1 -32
  23. package/extensions/session-search/tool-contract.ts +1 -21
  24. package/extensions/shared/model-runtime.ts +8 -1
  25. package/extensions/shared/session-broker/client.ts +3 -4
  26. package/extensions/shared/session-broker/framing.ts +6 -7
  27. package/extensions/shared/session-index/lineage.ts +6 -2
  28. package/extensions/shared/session-index/sqlite.ts +44 -17
  29. package/extensions/shared/settings.ts +13 -2
  30. package/extensions/shared/tmux.ts +22 -0
  31. package/extensions/subagents/report-message-renderer.ts +2 -2
  32. package/package.json +17 -29
  33. package/dist/shared/session-broker/protocol.js +0 -163
  34. package/dist/shared/typebox.js +0 -36
@@ -2,18 +2,17 @@ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { createServer, type Socket } from "node:net";
3
3
  import { join } from "node:path";
4
4
  import { readFrames, writeFrame } from "../../shared/session-broker/framing.ts";
5
- import {
6
- CLIENT_FRAME_SCHEMA,
7
- type OutboundSessionEnvelope,
8
- type SessionEnvelope,
9
- type SessionMessagingClientFrame,
10
- type SessionMessagingIncomingAckClientFrame,
11
- type SessionMessagingSendClientFrame,
12
- } from "../../shared/session-broker/protocol.ts";
13
5
  import {
14
6
  getSessionMessagingDir,
15
7
  getSessionMessagingSocketPath,
16
8
  } from "../../shared/session-broker/socket-path.ts";
9
+ import {
10
+ type BrokerClientFrame,
11
+ type IncomingAckClientFrame,
12
+ parseClientFrame,
13
+ type RoutedEnvelope,
14
+ type SendClientFrame,
15
+ } from "./client-frame.ts";
17
16
 
18
17
  const SEND_TIMEOUT_MS = 10_000;
19
18
  const EXIT_IDLE_MS = 5_000;
@@ -71,7 +70,7 @@ function resolveTarget(target: string): { sessionId: string; socket: Socket } |
71
70
 
72
71
  function handleFrame(
73
72
  socket: Socket,
74
- message: SessionMessagingClientFrame,
73
+ message: BrokerClientFrame,
75
74
  getSessionId: () => string | undefined,
76
75
  setSessionId: (sessionId: string | undefined) => void,
77
76
  ): void {
@@ -129,7 +128,7 @@ function handleFrame(
129
128
  function handleSend(
130
129
  socket: Socket,
131
130
  currentSessionId: string | undefined,
132
- message: SessionMessagingSendClientFrame,
131
+ message: SendClientFrame,
133
132
  ): void {
134
133
  const source = currentSessionId ? sessions.get(currentSessionId) : undefined;
135
134
 
@@ -206,7 +205,7 @@ function failPendingSendsTo(targetSessionId: string): void {
206
205
  }
207
206
  }
208
207
 
209
- function handleIncomingAck(socket: Socket, message: SessionMessagingIncomingAckClientFrame): void {
208
+ function handleIncomingAck(socket: Socket, message: IncomingAckClientFrame): void {
210
209
  const pending = pendingSends.get(message.requestId);
211
210
  if (!pending || pending.target !== socket) return;
212
211
 
@@ -220,11 +219,7 @@ function handleIncomingAck(socket: Socket, message: SessionMessagingIncomingAckC
220
219
  });
221
220
  }
222
221
 
223
- function stampEnvelope(
224
- envelope: OutboundSessionEnvelope,
225
- source: string,
226
- target: string,
227
- ): SessionEnvelope {
222
+ function stampEnvelope(envelope: RoutedEnvelope, source: string, target: string): RoutedEnvelope {
228
223
  return { ...envelope, source, target };
229
224
  }
230
225
 
@@ -251,11 +246,7 @@ async function readConnection(
251
246
  setSessionId: (sessionId: string | undefined) => void,
252
247
  ): Promise<void> {
253
248
  try {
254
- for await (const frame of readFrames(
255
- socket,
256
- CLIENT_FRAME_SCHEMA,
257
- "Invalid session messaging client frame",
258
- )) {
249
+ for await (const frame of readFrames(socket, parseClientFrame)) {
259
250
  handleFrame(socket, frame, getSessionId, setSessionId);
260
251
  }
261
252
  } catch (error) {
@@ -0,0 +1,59 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ const SCOPE_SCHEMA = Type.Union([
4
+ Type.Literal("user"),
5
+ Type.Literal("branch"),
6
+ Type.Literal("tree"),
7
+ ]);
8
+
9
+ /** Registered when subagents are active; the roster is what makes branch/tree answerable. */
10
+ export const SESSION_REACHABLE_PARAMS = Type.Object({
11
+ scope: Type.Optional(
12
+ Type.Union([Type.Literal("user"), Type.Literal("branch"), Type.Literal("tree")], {
13
+ description:
14
+ 'Which sessions to list: "user" for live user-facing sessions, "branch" for subagents launched from the active conversation branch, "tree" for subagents launched anywhere in this session\'s conversation tree. Defaults to "user".',
15
+ }),
16
+ ),
17
+ });
18
+
19
+ /** Registered when subagents are inactive; only live user sessions can ever be returned. */
20
+ export const SESSION_REACHABLE_USER_PARAMS = Type.Object({});
21
+
22
+ const REACHABLE_USER_SESSION_SCHEMA = Type.Object({
23
+ kind: Type.Literal("user"),
24
+ sessionId: Type.String(),
25
+ title: Type.Optional(Type.String()),
26
+ cwd: Type.Optional(Type.String()),
27
+ modifiedAt: Type.Optional(Type.String()),
28
+ relation: Type.Optional(Type.String()),
29
+ state: Type.Union([Type.Literal("live"), Type.Literal("starting")]),
30
+ });
31
+
32
+ const REACHABLE_SUBAGENT_SCHEMA = Type.Object({
33
+ kind: Type.Literal("subagent"),
34
+ sessionId: Type.String(),
35
+ title: Type.String(),
36
+ goal: Type.String(),
37
+ cwd: Type.String(),
38
+ state: Type.String(),
39
+ depth: Type.Number(),
40
+ onActiveBranch: Type.Boolean(),
41
+ launchedAt: Type.String(),
42
+ model: Type.Optional(Type.String()),
43
+ ownerSessionId: Type.String(),
44
+ ownerTitle: Type.String(),
45
+ ownerIsCurrentSession: Type.Boolean(),
46
+ resumeCommand: Type.String(),
47
+ });
48
+
49
+ export const SESSION_REACHABLE_TOOL_DETAILS_SCHEMA = Type.Object({
50
+ scope: SCOPE_SCHEMA,
51
+ sessions: Type.Array(Type.Union([REACHABLE_USER_SESSION_SCHEMA, REACHABLE_SUBAGENT_SCHEMA])),
52
+ });
53
+
54
+ export type SessionReachableScope = Static<typeof SCOPE_SCHEMA>;
55
+ export type SessionReachableParams = Static<typeof SESSION_REACHABLE_PARAMS>;
56
+ export type ReachableUserSession = Static<typeof REACHABLE_USER_SESSION_SCHEMA>;
57
+ export type ReachableSubagent = Static<typeof REACHABLE_SUBAGENT_SCHEMA>;
58
+ export type ReachableSession = ReachableUserSession | ReachableSubagent;
59
+ export type SessionReachableToolDetails = Static<typeof SESSION_REACHABLE_TOOL_DETAILS_SCHEMA>;
@@ -0,0 +1,49 @@
1
+ import type { ExpandableContentPresentation } from "../../shared/rendering/expandable-content-layout.ts";
2
+ import type { RenderTheme } from "../../shared/rendering/theme.ts";
3
+ import type {
4
+ ReachableSessionRowViewModel,
5
+ SessionReachableViewModel,
6
+ } from "./reachable-view-model.ts";
7
+
8
+ const COLLAPSED_ROW_COUNT = 6;
9
+
10
+ export function buildSessionReachablePresentation(
11
+ model: SessionReachableViewModel,
12
+ theme: RenderTheme,
13
+ ): ExpandableContentPresentation {
14
+ const header = theme.fg(
15
+ "muted",
16
+ `scope: ${model.scope} • ${formatSessionCount(model.rows.length)}`,
17
+ );
18
+
19
+ if (model.rows.length === 0) {
20
+ return {
21
+ header,
22
+ metadata: [theme.fg("warning", formatEmptyText(model.scope))],
23
+ };
24
+ }
25
+
26
+ return {
27
+ header,
28
+ body: {
29
+ text: model.rows.map((row, index) => formatRow(row, index, theme)).join("\n"),
30
+ collapsedRows: COLLAPSED_ROW_COUNT,
31
+ },
32
+ };
33
+ }
34
+
35
+ function formatRow(row: ReachableSessionRowViewModel, index: number, theme: RenderTheme): string {
36
+ const annotations =
37
+ row.annotations.length > 0 ? ` ${theme.fg("dim", `[${row.annotations.join(" • ")}]`)}` : "";
38
+ const location = row.location ? ` ${theme.fg("dim", `(${row.location})`)}` : "";
39
+ const heading = `${index + 1}. ${theme.bold(row.label)}${annotations}${location}`;
40
+ return row.detail ? `${heading}\n${theme.fg("dim", ` ${row.detail}`)}` : heading;
41
+ }
42
+
43
+ function formatSessionCount(count: number): string {
44
+ return count === 1 ? "1 session" : `${count} sessions`;
45
+ }
46
+
47
+ function formatEmptyText(scope: SessionReachableViewModel["scope"]): string {
48
+ return scope === "user" ? "No other live sessions." : "No subagents launched from this session.";
49
+ }
@@ -0,0 +1,190 @@
1
+ import {
2
+ defineTool,
3
+ type ExtensionContext,
4
+ SessionManager,
5
+ type ToolDefinition,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Text } from "@earendil-works/pi-tui";
8
+ import { isSessionStarting } from "../../session-handoff/metadata.ts";
9
+ import { formatError } from "../../shared/errors.ts";
10
+ import { ExpandableContentLayout } from "../../shared/rendering/expandable-content-layout.ts";
11
+ import {
12
+ getSessionById,
13
+ type SessionLineageRow,
14
+ withSessionIndex,
15
+ } from "../../shared/session-index/index.ts";
16
+ import { safeParseTypeBoxValue } from "../../shared/typebox.ts";
17
+ import {
18
+ type ReachableSubagent,
19
+ type ReachableUserSession,
20
+ SESSION_REACHABLE_PARAMS,
21
+ SESSION_REACHABLE_TOOL_DETAILS_SCHEMA,
22
+ SESSION_REACHABLE_USER_PARAMS,
23
+ type SessionReachableParams,
24
+ type SessionReachableScope,
25
+ type SessionReachableToolDetails,
26
+ } from "./reachable-contract.ts";
27
+ import { buildSessionReachablePresentation } from "./reachable-presenter.ts";
28
+ import { buildSessionReachableView } from "./reachable-view-model.ts";
29
+
30
+ /** The subagent facts this tool needs; the roster satisfies it structurally. */
31
+ export interface ReachableSubagentEntry {
32
+ sessionId: string;
33
+ title: string;
34
+ goal: string;
35
+ cwd: string;
36
+ state: string;
37
+ depth: number;
38
+ onActiveBranch: boolean;
39
+ launchedAt: string;
40
+ model?: string | undefined;
41
+ ownerSessionId: string;
42
+ ownerTitle: string;
43
+ ownerIsCurrentSession: boolean;
44
+ resumeCommand: string;
45
+ }
46
+
47
+ export interface SessionReachableDeps {
48
+ indexPath: string;
49
+ listSessions(): Promise<string[]>;
50
+ getRelationTo(sessionId: string): string | undefined;
51
+ listSubagents?:
52
+ | ((
53
+ scope: Exclude<SessionReachableScope, "user">,
54
+ ) => Promise<readonly ReachableSubagentEntry[]>)
55
+ | undefined;
56
+ }
57
+
58
+ interface SessionReachableRendererState {
59
+ layout?: ExpandableContentLayout | undefined;
60
+ }
61
+
62
+ export function createSessionReachableTool(deps: SessionReachableDeps): ToolDefinition {
63
+ const subagentsAvailable = Boolean(deps.listSubagents);
64
+
65
+ return defineTool({
66
+ name: "session_reachable",
67
+ label: "Reachable Sessions",
68
+ description: subagentsAvailable
69
+ ? "List the sessions this session can address: live user sessions, or its own subagents."
70
+ : "List the live user sessions this session can address.",
71
+ promptSnippet: subagentsAvailable
72
+ ? "List addressable live sessions and owned subagents"
73
+ : "List addressable live sessions",
74
+ promptGuidelines: [
75
+ "Before session_send_message, use session_reachable to find the target session id.",
76
+ ],
77
+ parameters: subagentsAvailable ? SESSION_REACHABLE_PARAMS : SESSION_REACHABLE_USER_PARAMS,
78
+ renderResult(result, options, theme, context) {
79
+ const output = result.content.find((item) => item.type === "text")?.text ?? "";
80
+ if (context.isError) {
81
+ return new Text(output ? theme.fg("error", output) : "", 0, 0);
82
+ }
83
+
84
+ const details = safeParseTypeBoxValue(SESSION_REACHABLE_TOOL_DETAILS_SCHEMA, result.details);
85
+ if (!details) {
86
+ return new Text(output, 0, 0);
87
+ }
88
+
89
+ const state = context.state as SessionReachableRendererState;
90
+ const layout = state.layout ?? new ExpandableContentLayout(theme);
91
+ state.layout = layout;
92
+ layout.update(
93
+ buildSessionReachablePresentation(buildSessionReachableView(details), theme),
94
+ options.expanded,
95
+ );
96
+ return layout;
97
+ },
98
+ async execute(_toolCallId, params: SessionReachableParams, _signal, _onUpdate, ctx) {
99
+ const scope = params.scope ?? "user";
100
+ const sessions =
101
+ scope === "user"
102
+ ? await listReachableUserSessions(deps, ctx)
103
+ : await listReachableSubagents(deps, scope);
104
+
105
+ const details: SessionReachableToolDetails = { scope, sessions };
106
+ return {
107
+ content: [{ type: "text" as const, text: JSON.stringify(details, null, 2) }],
108
+ details,
109
+ };
110
+ },
111
+ });
112
+ }
113
+
114
+ async function listReachableUserSessions(
115
+ deps: SessionReachableDeps,
116
+ ctx: ExtensionContext,
117
+ ): Promise<ReachableUserSession[]> {
118
+ const currentSessionId = ctx.sessionManager.getSessionId();
119
+ let liveSessionIds: string[];
120
+ try {
121
+ liveSessionIds = await deps.listSessions();
122
+ } catch (error) {
123
+ throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
124
+ }
125
+
126
+ const targetIds = liveSessionIds.filter((sessionId) => sessionId !== currentSessionId);
127
+ return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
128
+ const sessions: ReachableUserSession[] = [];
129
+ for (const sessionId of targetIds) {
130
+ const row = getSessionById(db, sessionId);
131
+ if (row?.sessionOrigin === "subagent") {
132
+ continue;
133
+ }
134
+ const relation = deps.getRelationTo(sessionId);
135
+ sessions.push({
136
+ kind: "user",
137
+ sessionId,
138
+ state: row && isStartingSession(row) ? "starting" : "live",
139
+ ...(row?.sessionName ? { title: row.sessionName } : {}),
140
+ ...(row?.cwd ? { cwd: row.cwd } : {}),
141
+ ...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
142
+ ...(relation === undefined ? {} : { relation }),
143
+ });
144
+ }
145
+ return sessions.sort(compareByModifiedDesc);
146
+ });
147
+ }
148
+
149
+ async function listReachableSubagents(
150
+ deps: SessionReachableDeps,
151
+ scope: Exclude<SessionReachableScope, "user">,
152
+ ): Promise<ReachableSubagent[]> {
153
+ if (!deps.listSubagents) {
154
+ throw new Error(`session_reachable scope "${scope}" requires subagents to be active.`);
155
+ }
156
+
157
+ try {
158
+ const entries = await deps.listSubagents(scope);
159
+ return entries.map((entry) => ({
160
+ kind: "subagent",
161
+ sessionId: entry.sessionId,
162
+ title: entry.title,
163
+ goal: entry.goal,
164
+ cwd: entry.cwd,
165
+ state: entry.state,
166
+ depth: entry.depth,
167
+ onActiveBranch: entry.onActiveBranch,
168
+ launchedAt: entry.launchedAt,
169
+ ...(entry.model === undefined ? {} : { model: entry.model }),
170
+ ownerSessionId: entry.ownerSessionId,
171
+ ownerTitle: entry.ownerTitle,
172
+ ownerIsCurrentSession: entry.ownerIsCurrentSession,
173
+ resumeCommand: entry.resumeCommand,
174
+ }));
175
+ } catch (error) {
176
+ throw new Error(`Subagent roster is unavailable: ${formatError(error)}`);
177
+ }
178
+ }
179
+
180
+ function isStartingSession(row: SessionLineageRow): boolean {
181
+ try {
182
+ return isSessionStarting(SessionManager.open(row.sessionPath).getBranch());
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ function compareByModifiedDesc(left: ReachableUserSession, right: ReachableUserSession): number {
189
+ return (right.modifiedAt ?? "").localeCompare(left.modifiedAt ?? "");
190
+ }
@@ -0,0 +1,59 @@
1
+ import path from "node:path";
2
+ import { formatSessionTitleOrShortId } from "../../shared/session-ui.ts";
3
+ import { normalizeOptionalText } from "../../shared/text.ts";
4
+ import type {
5
+ ReachableSession,
6
+ SessionReachableScope,
7
+ SessionReachableToolDetails,
8
+ } from "./reachable-contract.ts";
9
+
10
+ export interface ReachableSessionRowViewModel {
11
+ label: string;
12
+ sessionId: string;
13
+ annotations: string[];
14
+ location?: string | undefined;
15
+ detail?: string | undefined;
16
+ }
17
+
18
+ export interface SessionReachableViewModel {
19
+ scope: SessionReachableScope;
20
+ rows: ReachableSessionRowViewModel[];
21
+ }
22
+
23
+ export function buildSessionReachableView(
24
+ details: SessionReachableToolDetails,
25
+ ): SessionReachableViewModel {
26
+ return {
27
+ scope: details.scope,
28
+ rows: details.sessions.map(buildReachableSessionRow),
29
+ };
30
+ }
31
+
32
+ function buildReachableSessionRow(session: ReachableSession): ReachableSessionRowViewModel {
33
+ if (session.kind === "user") {
34
+ return {
35
+ label: formatSessionTitleOrShortId(session.title, session.sessionId),
36
+ sessionId: session.sessionId,
37
+ annotations: [session.state, ...(session.relation ? [session.relation] : [])],
38
+ location: formatLocation(session.cwd),
39
+ };
40
+ }
41
+
42
+ return {
43
+ label: formatSessionTitleOrShortId(session.title, session.sessionId),
44
+ sessionId: session.sessionId,
45
+ annotations: [
46
+ session.state,
47
+ `depth ${session.depth}`,
48
+ ...(session.onActiveBranch ? [] : ["history"]),
49
+ ...(session.ownerIsCurrentSession ? [] : [`owner ${session.ownerTitle}`]),
50
+ ],
51
+ location: formatLocation(session.cwd),
52
+ detail: normalizeOptionalText(session.goal),
53
+ };
54
+ }
55
+
56
+ function formatLocation(cwd: string | undefined): string | undefined {
57
+ if (!cwd) return undefined;
58
+ return path.basename(cwd) || cwd;
59
+ }
@@ -17,7 +17,7 @@ export const renderIncomingSessionMessage: MessageRenderer = (message, options,
17
17
  buildIncomingMessagePresentation(buildIncomingMessageView(details), theme),
18
18
  options.expanded,
19
19
  );
20
- const box = new Box(1, 1, (text) => theme.bg("toolSuccessBg", text));
20
+ const box = new Box(options.outputPad, 1, (text) => theme.bg("toolSuccessBg", text));
21
21
  box.addChild(layout);
22
22
  return box;
23
23
  };
@@ -66,13 +66,8 @@ export function createSessionSendMessageTool(
66
66
  ? "Send a message to another pi session or a subagent"
67
67
  : "Send a message to another live pi session";
68
68
  const promptGuidelines = options.wakeCapable
69
- ? [
70
- "Use session_search with live: true to discover live sessions.",
71
- "It is always possible to session_send_message to an owned subagent",
72
- ]
73
- : [
74
- "Before session_send_message, use session_search with live: true to list all live sessions and find the target.",
75
- ];
69
+ ? ["Use session_reachable to discover live sessions and owned subagents."]
70
+ : ["Use session_reachable to list live sessions and find the target."];
76
71
 
77
72
  return defineTool({
78
73
  name: "session_send_message",
@@ -194,7 +189,12 @@ export function createSessionCancelTool(service: CancelSessionService): ToolDefi
194
189
  throw new Error(buildUnknownCancellationError(result.target));
195
190
  }
196
191
  return {
197
- content: [{ type: "text" as const, text: buildCancelSessionModelText(details) }],
192
+ content: [
193
+ {
194
+ type: "text" as const,
195
+ text: buildCancelSessionModelText(details),
196
+ },
197
+ ],
198
198
  details,
199
199
  };
200
200
  }
@@ -228,7 +228,7 @@ function syncSessionFileWithDb(
228
228
  const stat = statSync(sessionFile);
229
229
 
230
230
  if (baseline && isIndexCurrent(baseline, stat, sessionOrigin)) {
231
- db.transaction(() => writeHookSyncMetadata(db, eventType)).immediate();
231
+ db.transaction(() => writeHookSyncMetadata(db, eventType));
232
232
  return true;
233
233
  }
234
234
 
@@ -279,11 +279,6 @@ function isIndexCurrent(
279
279
  );
280
280
  }
281
281
 
282
- // All write transactions are immediate: they read before they write, and a
283
- // deferred transaction whose snapshot goes stale fails with SQLITE_BUSY on the
284
- // read-to-write upgrade without ever invoking the busy handler. Immediate mode
285
- // takes the write lock at BEGIN, where busy_timeout queues us behind concurrent
286
- // writers from other pi processes.
287
282
  function applyFullSync(
288
283
  db: SessionIndexDatabase,
289
284
  extracted: ExtractedSessionRecord,
@@ -312,7 +307,7 @@ function applyFullSync(
312
307
  }
313
308
 
314
309
  writeHookSyncMetadata(db, eventType);
315
- }).immediate();
310
+ });
316
311
  }
317
312
 
318
313
  function applyTailSync(
@@ -322,60 +317,58 @@ function applyTailSync(
322
317
  eventType: string,
323
318
  sessionOrigin?: SessionOrigin,
324
319
  ): boolean {
325
- return db
326
- .transaction((): boolean => {
327
- // Another process may have advanced the index between our baseline read
328
- // and this transaction; the tail deltas would then double-count.
329
- const current = getSessionRowByPath(db, baseline.sessionPath);
330
- if (
331
- !current ||
332
- current.sessionId !== baseline.sessionId ||
333
- current.indexedFileSize !== baseline.indexedFileSize
334
- ) {
335
- return false;
336
- }
320
+ return db.transaction((): boolean => {
321
+ // Another process may have advanced the index between our baseline read
322
+ // and this transaction; the tail deltas would then double-count.
323
+ const current = getSessionRowByPath(db, baseline.sessionPath);
324
+ if (
325
+ !current ||
326
+ current.sessionId !== baseline.sessionId ||
327
+ current.indexedFileSize !== baseline.indexedFileSize
328
+ ) {
329
+ return false;
330
+ }
337
331
 
338
- const scan = tail.scan;
339
- upsertSession(db, buildTailSessionRow(baseline, tail, sessionOrigin), "hook");
340
-
341
- if (scan.sessionName !== undefined && scan.sessionName !== baseline.sessionName) {
342
- clearSessionChunksBySourceKind(db, baseline.sessionId, "session_name");
343
- if (scan.sessionName && scan.sessionNameEntryId) {
344
- insertTextChunk(db, {
345
- sessionId: baseline.sessionId,
346
- ...createSessionNameChunk(
347
- scan.sessionName,
348
- scan.sessionNameTs ?? baseline.startedAt,
349
- scan.sessionNameEntryId,
350
- ),
351
- });
352
- }
353
- }
332
+ const scan = tail.scan;
333
+ upsertSession(db, buildTailSessionRow(baseline, tail, sessionOrigin), "hook");
354
334
 
355
- if (!baseline.handoffGoal && scan.handoffMetadata) {
356
- const { entryId, ts, metadata } = scan.handoffMetadata;
335
+ if (scan.sessionName !== undefined && scan.sessionName !== baseline.sessionName) {
336
+ clearSessionChunksBySourceKind(db, baseline.sessionId, "session_name");
337
+ if (scan.sessionName && scan.sessionNameEntryId) {
357
338
  insertTextChunk(db, {
358
339
  sessionId: baseline.sessionId,
359
- entryId,
360
- entryType: "custom",
361
- ts,
362
- sourceKind: "handoff_goal",
363
- text: metadata.goal,
340
+ ...createSessionNameChunk(
341
+ scan.sessionName,
342
+ scan.sessionNameTs ?? baseline.startedAt,
343
+ scan.sessionNameEntryId,
344
+ ),
364
345
  });
365
346
  }
347
+ }
366
348
 
367
- for (const chunk of scan.chunks) {
368
- insertTextChunk(db, { sessionId: baseline.sessionId, ...chunk });
369
- }
349
+ if (!baseline.handoffGoal && scan.handoffMetadata) {
350
+ const { entryId, ts, metadata } = scan.handoffMetadata;
351
+ insertTextChunk(db, {
352
+ sessionId: baseline.sessionId,
353
+ entryId,
354
+ entryType: "custom",
355
+ ts,
356
+ sourceKind: "handoff_goal",
357
+ text: metadata.goal,
358
+ });
359
+ }
370
360
 
371
- for (const fileTouch of scan.fileTouches) {
372
- insertSessionFileTouch(db, { sessionId: baseline.sessionId, ...fileTouch });
373
- }
361
+ for (const chunk of scan.chunks) {
362
+ insertTextChunk(db, { sessionId: baseline.sessionId, ...chunk });
363
+ }
374
364
 
375
- writeHookSyncMetadata(db, eventType);
376
- return true;
377
- })
378
- .immediate();
365
+ for (const fileTouch of scan.fileTouches) {
366
+ insertSessionFileTouch(db, { sessionId: baseline.sessionId, ...fileTouch });
367
+ }
368
+
369
+ writeHookSyncMetadata(db, eventType);
370
+ return true;
371
+ });
379
372
  }
380
373
 
381
374
  function buildTailSessionRow(