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
@@ -1,24 +1,14 @@
1
- import {
2
- type ExtensionAPI,
3
- type ExtensionContext,
4
- SessionManager,
5
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
2
  import { Type } from "typebox";
7
- import { isSessionStarting } from "../session-handoff/metadata.ts";
8
- import type { MessagingHandle } from "../session-messaging/install.ts";
9
3
  import type { IndexHandle } from "../shared/composition.ts";
10
- import { formatError } from "../shared/errors.ts";
11
4
  import { stripSearchSnippetMarkers } from "../shared/search-snippet.ts";
12
5
  import {
13
- getSessionById,
14
6
  type SearchSessionsParams,
15
7
  type SessionSearchEvidence,
16
8
  searchSessions,
17
9
  withSessionIndex,
18
10
  } from "../shared/session-index/index.ts";
19
11
  import type { SessionSettings } from "../shared/settings.ts";
20
- import type { SubagentState } from "../subagents/classify.ts";
21
- import type { SubagentRoster } from "../subagents/roster.ts";
22
12
  import { renderSessionSearchResult } from "./renderer.ts";
23
13
  import type {
24
14
  SessionSearchResult,
@@ -31,8 +21,6 @@ const DEFAULT_SESSION_SEARCH_LIMIT = 6;
31
21
  export interface SearchInstallDeps {
32
22
  settings: SessionSettings;
33
23
  index: IndexHandle;
34
- messaging?: MessagingHandle | undefined;
35
- roster?: SubagentRoster | undefined;
36
24
  }
37
25
 
38
26
  export function installSearch(pi: ExtensionAPI, deps: SearchInstallDeps): void {
@@ -46,7 +34,6 @@ export function installSearch(pi: ExtensionAPI, deps: SearchInstallDeps): void {
46
34
  promptGuidelines: [
47
35
  "Omit queries in session_search to list matching sessions chronologically.",
48
36
  "After session_search finds a session id, switch to session_ask for questions about it.",
49
- 'To identify your own subagents, use session_search with relationScope: "branch".',
50
37
  ],
51
38
  parameters: Type.Object({
52
39
  query: Type.Optional(
@@ -105,22 +92,11 @@ export function installSearch(pi: ExtensionAPI, deps: SearchInstallDeps): void {
105
92
  },
106
93
  ),
107
94
  ),
108
- live: Type.Optional(
109
- Type.Boolean({
110
- description: "When true, only return currently active sessions",
111
- }),
112
- ),
113
95
  kind: Type.Optional(
114
96
  Type.Union([Type.Literal("user"), Type.Literal("subagent")], {
115
97
  description: "Filter by session kind",
116
98
  }),
117
99
  ),
118
- relationScope: Type.Optional(
119
- Type.Union([Type.Literal("branch"), Type.Literal("tree")], {
120
- description:
121
- "Restrict results to subagents launched from the active branch or anywhere in this session's conversation tree",
122
- }),
123
- ),
124
100
  limit: Type.Optional(
125
101
  Type.Number({
126
102
  description: "Number of matches to return",
@@ -142,35 +118,9 @@ export function installSearch(pi: ExtensionAPI, deps: SearchInstallDeps): void {
142
118
  details: progressDetails,
143
119
  });
144
120
 
145
- const scope = await resolveSearchScope(params, deps);
146
-
147
121
  return withSessionIndex(indexPath, { mode: "read", required: true }, ({ db, status }) => {
148
- const results = searchSessions(
149
- db,
150
- buildSearchParams(params, ctx, scope.includeSessionIds),
151
- ).map((result): SessionSearchResult => {
152
- const annotation = scope.annotations.get(result.sessionId);
153
- if (!annotation) {
154
- return result;
155
- }
156
- if ("depth" in annotation) {
157
- return {
158
- ...result,
159
- state: annotation.state,
160
- depth: annotation.depth,
161
- onActiveBranch: annotation.onActiveBranch,
162
- };
163
- }
164
- return { ...result, state: annotation.state };
165
- });
166
- const details: SessionSearchToolDetails = {
167
- params,
168
- status,
169
- results,
170
- ...(scope.total === undefined
171
- ? {}
172
- : { scope: { matched: results.length, total: scope.total } }),
173
- };
122
+ const results = searchSessions(db, buildSearchParams(params, ctx));
123
+ const details: SessionSearchToolDetails = { params, status, results };
174
124
  return {
175
125
  content: [
176
126
  {
@@ -189,7 +139,6 @@ export function installSearch(pi: ExtensionAPI, deps: SearchInstallDeps): void {
189
139
  function buildSearchParams(
190
140
  params: SessionSearchToolParams,
191
141
  ctx: ExtensionContext,
192
- liveSessionIds?: string[] | undefined,
193
142
  ): SearchSessionsParams {
194
143
  const currentSessionId = ctx.sessionManager.getSessionId();
195
144
 
@@ -204,119 +153,15 @@ function buildSearchParams(
204
153
  kind: params.kind,
205
154
  sort: params.sort,
206
155
  limit: params.limit ?? DEFAULT_SESSION_SEARCH_LIMIT,
207
- includeSessionIds: liveSessionIds,
208
156
  relativeToSessionId: currentSessionId,
209
157
  };
210
158
  }
211
159
 
212
- interface StartingSearchResultAnnotation {
213
- state: "starting";
214
- }
215
-
216
- interface RelatedSubagentSearchResultAnnotation {
217
- state: SubagentState;
218
- depth: number;
219
- onActiveBranch: boolean;
220
- }
221
-
222
- type SearchResultAnnotation =
223
- | StartingSearchResultAnnotation
224
- | RelatedSubagentSearchResultAnnotation;
225
-
226
- interface ResolvedSearchScope {
227
- includeSessionIds: string[] | undefined;
228
- annotations: ReadonlyMap<string, SearchResultAnnotation>;
229
- total: number | undefined;
230
- }
231
-
232
- async function resolveSearchScope(
233
- params: SessionSearchToolParams,
234
- deps: SearchInstallDeps,
235
- ): Promise<ResolvedSearchScope> {
236
- if (params.relationScope) {
237
- if (!deps.roster) {
238
- throw new Error("Subagent relations are unavailable because subagents are not active.");
239
- }
240
- try {
241
- const roster = await deps.roster.resolve(params.relationScope);
242
- const entries = params.live
243
- ? roster.entries.filter((entry) => entry.managedLive)
244
- : roster.entries;
245
- const annotations = new Map<string, SearchResultAnnotation>();
246
- for (const entry of roster.entries) {
247
- annotations.set(entry.sessionId, {
248
- state: entry.state,
249
- depth: entry.depth,
250
- onActiveBranch: entry.onActiveBranch,
251
- });
252
- }
253
- return {
254
- includeSessionIds: entries.map((entry) => entry.sessionId),
255
- annotations,
256
- total: roster.total,
257
- };
258
- } catch (error) {
259
- throw new Error(`Subagent relation scope is unavailable: ${formatError(error)}`);
260
- }
261
- }
262
-
263
- if (!params.live) {
264
- return {
265
- includeSessionIds: undefined,
266
- annotations: new Map(),
267
- total: undefined,
268
- };
269
- }
270
-
271
- const liveSessionIds = await getLiveSessionIds(deps.messaging);
272
- return {
273
- includeSessionIds: liveSessionIds,
274
- annotations: getStartingSessionAnnotations(liveSessionIds, deps.index.path),
275
- total: undefined,
276
- };
277
- }
278
-
279
- function getStartingSessionAnnotations(
280
- sessionIds: readonly string[],
281
- indexPath: string,
282
- ): ReadonlyMap<string, SearchResultAnnotation> {
283
- return withSessionIndex(indexPath, { mode: "read", required: true }, ({ db }) => {
284
- const annotations = new Map<string, SearchResultAnnotation>();
285
- for (const sessionId of sessionIds) {
286
- const session = getSessionById(db, sessionId);
287
- if (!session) {
288
- continue;
289
- }
290
- try {
291
- if (isSessionStarting(SessionManager.open(session.sessionPath).getBranch())) {
292
- annotations.set(sessionId, { state: "starting" });
293
- }
294
- } catch {}
295
- }
296
- return annotations;
297
- });
298
- }
299
-
300
- async function getLiveSessionIds(messaging: MessagingHandle | undefined): Promise<string[]> {
301
- if (!messaging) {
302
- throw new Error(
303
- "Session messaging is not active; live session search requires session messaging.",
304
- );
305
- }
306
-
307
- try {
308
- return await messaging.listSessions();
309
- } catch (error) {
310
- throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
311
- }
312
- }
313
-
314
160
  function formatSearchResultsForModel(details: SessionSearchToolDetails): string {
315
161
  return JSON.stringify(
316
162
  {
317
163
  params: details.params,
318
164
  status: details.status,
319
- ...(details.scope ? { scope: details.scope } : {}),
320
165
  results: details.results.map(formatSearchResultForModel),
321
166
  },
322
167
  null,
@@ -34,7 +34,7 @@ export async function rebuildSessionIndex(options: ReindexOptions): Promise<Rein
34
34
  db.transaction(() => {
35
35
  dropIndexTables(db);
36
36
  initializeSchema(db);
37
- }).immediate();
37
+ });
38
38
 
39
39
  let sessionCount = 0;
40
40
  let chunkCount = 0;
@@ -44,7 +44,7 @@ export async function rebuildSessionIndex(options: ReindexOptions): Promise<Rein
44
44
  continue;
45
45
  }
46
46
 
47
- db.transaction(() => indexSession(db, extracted)).immediate();
47
+ db.transaction(() => indexSession(db, extracted));
48
48
  sessionCount += 1;
49
49
  chunkCount += extracted.chunks.length;
50
50
  }
@@ -53,7 +53,7 @@ export async function rebuildSessionIndex(options: ReindexOptions): Promise<Rein
53
53
  rebuildSessionLineageRelations(db);
54
54
  setMetadata(db, "indexed_at", new Date().toISOString());
55
55
  setMetadata(db, "session_source", "SessionManager.listAll()");
56
- }).immediate();
56
+ });
57
57
 
58
58
  return { sessionCount, chunkCount, indexPath };
59
59
  } finally {
@@ -11,8 +11,6 @@ import {
11
11
  } from "../shared/search-snippet.ts";
12
12
  import { formatSessionTitleOrShortId } from "../shared/session-ui.ts";
13
13
  import type {
14
- AnnotatedSearchResult,
15
- RelatedSubagentSearchResult,
16
14
  SessionSearchResult,
17
15
  SessionSearchToolDetails,
18
16
  SessionSearchToolParams,
@@ -51,11 +49,6 @@ export function renderSessionSearchResult(
51
49
  }
52
50
 
53
51
  const lines = formatSessionSearchContextLines(details.params, theme);
54
- if (details.scope) {
55
- lines.push(
56
- theme.fg("dim", `scope: ${details.scope.matched} matched • ${details.scope.total} total`),
57
- );
58
- }
59
52
  if (details.results.length === 0) {
60
53
  if (lines.length > 0) lines.push("");
61
54
  lines.push(theme.fg("warning", "No matching sessions found."));
@@ -85,9 +78,7 @@ function formatSessionSearchContextLines(
85
78
  filters.push(`files.touched: ${params.files.touched.join(", ")}`);
86
79
  if (params.files?.changed?.length)
87
80
  filters.push(`files.changed: ${params.files.changed.join(", ")}`);
88
- if (params.live) filters.push("live: true");
89
81
  if (params.kind) filters.push(`kind: ${params.kind}`);
90
- if (params.relationScope) filters.push(`relationScope: ${params.relationScope}`);
91
82
  if (params.sort) filters.push(`sort: ${params.sort}`);
92
83
  if (params.time?.after?.trim()) filters.push(`after: ${params.time.after.trim()}`);
93
84
  if (params.time?.before?.trim()) filters.push(`before: ${params.time.before.trim()}`);
@@ -114,9 +105,7 @@ function formatSessionSearchPanelResults(
114
105
  const lines = visibleResults.flatMap((result, index) => {
115
106
  const location = formatSearchResultLocation(result.cwd);
116
107
  const relation = result.relation ? ` ${theme.fg("dim", `[${result.relation}]`)}` : "";
117
- const annotation = formatSearchResultAnnotation(result);
118
- const state = annotation.length > 0 ? ` ${theme.fg("dim", `[${annotation.join(" • ")}]`)}` : "";
119
- const heading = `${index + 1}. ${theme.bold(formatSearchResultLabel(result))}${relation}${state}${location ? ` ${theme.fg("dim", `(${location})`)}` : ""}`;
108
+ const heading = `${index + 1}. ${theme.bold(formatSearchResultLabel(result))}${relation}${location ? ` ${theme.fg("dim", `(${location})`)}` : ""}`;
120
109
  const snippets = params?.query ? formatSearchSnippets(result).slice(0, 3) : [];
121
110
  return snippets.length > 0
122
111
  ? [heading, ...snippets.map((snippet) => theme.fg("dim", ` - ${snippet}`))]
@@ -130,26 +119,6 @@ function formatSessionSearchPanelResults(
130
119
  return lines;
131
120
  }
132
121
 
133
- function formatSearchResultAnnotation(result: SessionSearchResult): string[] {
134
- if (!isAnnotatedSearchResult(result)) {
135
- return [];
136
- }
137
- if (!isRelatedSubagentSearchResult(result)) {
138
- return [result.state];
139
- }
140
- return [result.state, `depth ${result.depth}`, ...(result.onActiveBranch ? [] : ["history"])];
141
- }
142
-
143
- function isAnnotatedSearchResult(result: SessionSearchResult): result is AnnotatedSearchResult {
144
- return "state" in result;
145
- }
146
-
147
- function isRelatedSubagentSearchResult(
148
- result: AnnotatedSearchResult,
149
- ): result is RelatedSubagentSearchResult {
150
- return "depth" in result;
151
- }
152
-
153
122
  function formatSearchResultLabel(result: SessionSearchResult): string {
154
123
  return formatSessionTitleOrShortId(result.sessionName, result.sessionId);
155
124
  }
@@ -4,7 +4,6 @@ import type {
4
4
  SessionIndexStatus,
5
5
  SessionKind,
6
6
  } from "../shared/session-index/index.ts";
7
- import type { SubagentState } from "../subagents/classify.ts";
8
7
 
9
8
  export interface SessionSearchToolParams {
10
9
  query?: string;
@@ -20,32 +19,13 @@ export interface SessionSearchToolParams {
20
19
  };
21
20
  sort?: SearchSort;
22
21
  limit?: number;
23
- live?: boolean;
24
22
  kind?: SessionKind;
25
- relationScope?: "branch" | "tree";
26
23
  }
27
24
 
28
- export interface SessionSearchScopeDetails {
29
- matched: number;
30
- total: number;
31
- }
32
-
33
- export interface StartingSearchResult extends SearchSessionResult {
34
- state: "starting";
35
- }
36
-
37
- export interface RelatedSubagentSearchResult extends SearchSessionResult {
38
- state: SubagentState;
39
- depth: number;
40
- onActiveBranch: boolean;
41
- }
42
-
43
- export type AnnotatedSearchResult = StartingSearchResult | RelatedSubagentSearchResult;
44
- export type SessionSearchResult = SearchSessionResult | AnnotatedSearchResult;
25
+ export type SessionSearchResult = SearchSessionResult;
45
26
 
46
27
  export interface SessionSearchToolDetails {
47
28
  params?: SessionSearchToolParams | undefined;
48
29
  results: SessionSearchResult[];
49
30
  status?: SessionIndexStatus | undefined;
50
- scope?: SessionSearchScopeDetails | undefined;
51
31
  }
@@ -7,13 +7,20 @@ export type ModelRuntimeProvider = (modelRegistry: ModelRegistry) => Promise<Mod
7
7
  // ModelRuntime, but ExtensionContext only exposes the ModelRegistry compatibility facade,
8
8
  // which keeps its runtime private. Until pi exposes the runtime to extensions, build a
9
9
  // fresh one per use and mirror the registered providers (e.g. pi-claude-bridge) so their
10
- // configs — including live streamSimple closures — carry over by reference.
10
+ // live stream closures carry over by reference. Extensions register either a native
11
+ // Provider or a legacy provider config, and the facade exposes each through its own
12
+ // accessor while listing both id sets together.
11
13
  export async function createSessionModelRuntime(
12
14
  modelRegistry: ModelRegistry,
13
15
  ): Promise<ModelRuntime> {
14
16
  const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false });
15
17
 
16
18
  for (const providerId of modelRegistry.getRegisteredProviderIds()) {
19
+ const native = modelRegistry.getRegisteredNativeProvider(providerId);
20
+ if (native) {
21
+ modelRuntime.registerNativeProvider(native);
22
+ continue;
23
+ }
17
24
  const config = modelRegistry.getRegisteredProviderConfig(providerId);
18
25
  if (config) {
19
26
  modelRuntime.registerProvider(providerId, config);
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
3
  import net from "node:net";
4
+ import { parseTypeBoxValue } from "../typebox.ts";
4
5
  import { readFrames, writeFrame } from "./framing.ts";
5
6
  import {
6
7
  BROKER_FRAME_SCHEMA,
@@ -176,10 +177,8 @@ export class SessionMessagingClient extends EventEmitter {
176
177
 
177
178
  private async readSocket(socket: net.Socket): Promise<void> {
178
179
  try {
179
- for await (const frame of readFrames(
180
- socket,
181
- BROKER_FRAME_SCHEMA,
182
- "Invalid session messaging broker frame",
180
+ for await (const frame of readFrames(socket, (value) =>
181
+ parseTypeBoxValue(BROKER_FRAME_SCHEMA, value, "Invalid session messaging broker frame"),
183
182
  )) {
184
183
  this.handleFrame(frame);
185
184
  }
@@ -1,7 +1,5 @@
1
1
  import type { Socket } from "node:net";
2
2
  import { createInterface } from "node:readline";
3
- import type { Static, TSchema } from "typebox";
4
- import { parseTypeBoxValue } from "../typebox.ts";
5
3
 
6
4
  const MAX_FRAME_BYTES = 256 * 1024;
7
5
 
@@ -9,11 +7,12 @@ export function writeFrame<T>(socket: Socket, frame: T): void {
9
7
  socket.write(`${JSON.stringify(frame)}\n`);
10
8
  }
11
9
 
12
- export async function* readFrames<T extends TSchema>(
10
+ // The parser is injected so the detached broker can run with no dependencies
11
+ // installed alongside it, while Pi-side callers keep using TypeBox schemas.
12
+ export async function* readFrames<T>(
13
13
  socket: Socket,
14
- schema: T,
15
- context: string,
16
- ): AsyncGenerator<Static<T>> {
14
+ parseFrame: (value: unknown) => T,
15
+ ): AsyncGenerator<T> {
17
16
  const reader = createInterface({ input: socket, crlfDelay: Number.POSITIVE_INFINITY });
18
17
  const lines: string[] = [];
19
18
  const waiters: Array<() => void> = [];
@@ -64,7 +63,7 @@ export async function* readFrames<T extends TSchema>(
64
63
  throw parseError instanceof Error ? parseError : new Error(String(parseError));
65
64
  }
66
65
 
67
- yield parseTypeBoxValue(schema, parsed, context);
66
+ yield parseFrame(parsed);
68
67
  }
69
68
  } finally {
70
69
  reader.close();
@@ -239,6 +239,7 @@ function buildSessionGraph(db: SessionIndexDatabase): SessionGraph {
239
239
  "Invalid session graph rows",
240
240
  );
241
241
 
242
+ const sessionIds = new Set(rows.map((row) => row.sessionId));
242
243
  const pathToId = new Map(rows.map((row) => [row.sessionPath, row.sessionId]));
243
244
  const nodes = new Map<string, SessionGraphNode>(
244
245
  rows.map((row) => [
@@ -246,8 +247,11 @@ function buildSessionGraph(db: SessionIndexDatabase): SessionGraph {
246
247
  {
247
248
  ...row,
248
249
  resolvedParentSessionId:
249
- row.parentSessionId ??
250
- (row.parentSessionPath ? pathToId.get(row.parentSessionPath) : undefined),
250
+ row.parentSessionId && sessionIds.has(row.parentSessionId)
251
+ ? row.parentSessionId
252
+ : row.parentSessionPath
253
+ ? pathToId.get(row.parentSessionPath)
254
+ : undefined,
251
255
  },
252
256
  ]),
253
257
  );
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { createRequire } from "node:module";
2
3
 
3
4
  const requireModule = createRequire(import.meta.url);
@@ -18,32 +19,30 @@ export interface SqliteStatement {
18
19
  run(...params: SqliteBindValue[]): SqliteRunResult;
19
20
  }
20
21
 
21
- export interface SqliteTransaction<Args extends unknown[], Result> {
22
- (...args: Args): Result;
23
- immediate(...args: Args): Result;
24
- }
25
-
26
- export interface SqliteDatabase {
22
+ // The surface this module consumes from a native driver. Transactions are
23
+ // implemented inline
24
+ interface SqliteDriver {
27
25
  prepare(sql: string): SqliteStatement;
28
26
  exec(sql: string): void;
29
- transaction<Args extends unknown[], Result>(
30
- fn: (...args: Args) => Result,
31
- ): SqliteTransaction<Args, Result>;
32
27
  close(): void;
33
28
  }
34
29
 
30
+ export interface SqliteDatabase extends SqliteDriver {
31
+ transaction<Result>(fn: () => Result): Result;
32
+ }
33
+
35
34
  interface SqliteConstructor {
36
- new (path: string, options?: Record<string, unknown>): SqliteDatabase;
35
+ new (path: string, options?: Record<string, unknown>): SqliteDriver;
37
36
  }
38
37
 
39
- // The driver is resolved at runtime so Bun never loads the native better-sqlite3
40
- // addon (unsupported) and Node never loads the bun:sqlite builtin. The require
41
- // result is untyped, so the cast to our structural contract is unavoidable here.
38
+ // Each runtime gets its own built-in SQLite, resolved at runtime because neither
39
+ // module is importable from the other. The require result is untyped, so the
40
+ // cast to our structural contract is unavoidable here.
42
41
  function loadDatabaseConstructor(): SqliteConstructor {
43
42
  if (isBun) {
44
43
  return (requireModule("bun:sqlite") as { Database: SqliteConstructor }).Database;
45
44
  }
46
- return requireModule("better-sqlite3") as SqliteConstructor;
45
+ return (requireModule("node:sqlite") as { DatabaseSync: SqliteConstructor }).DatabaseSync;
47
46
  }
48
47
 
49
48
  export function openSqlite(
@@ -52,13 +51,20 @@ export function openSqlite(
52
51
  ): SqliteDatabase {
53
52
  const Database = loadDatabaseConstructor();
54
53
  const readonly = options.readonly ?? false;
54
+
55
+ // node:sqlite has no fileMustExist equivalent and would otherwise create an
56
+ // empty database where the caller demanded an existing one.
57
+ if (!options.create && !existsSync(dbPath)) {
58
+ throw new Error(`SQLite database does not exist: ${dbPath}`);
59
+ }
60
+
55
61
  const db = isBun
56
62
  ? new Database(dbPath, {
57
63
  create: readonly ? false : options.create,
58
64
  readonly,
59
65
  readwrite: !readonly,
60
66
  })
61
- : new Database(dbPath, { fileMustExist: readonly || !options.create, readonly });
67
+ : new Database(dbPath, { readOnly: readonly });
62
68
 
63
69
  db.exec(`PRAGMA busy_timeout = ${options.timeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS}`);
64
70
  db.exec("PRAGMA foreign_keys = ON");
@@ -77,8 +83,9 @@ export function openSqlite(
77
83
  // Neither driver memoizes prepare(), and the write paths prepare the same
78
84
  // statements once per row. SQLite re-prepares cached statements transparently
79
85
  // when the schema changes, so reusing them across DDL is safe.
80
- function withStatementCache(db: SqliteDatabase): SqliteDatabase {
86
+ function withStatementCache(db: SqliteDriver): SqliteDatabase {
81
87
  const statements = new Map<string, SqliteStatement>();
88
+ let inTransaction = false;
82
89
 
83
90
  return {
84
91
  prepare(sql) {
@@ -94,8 +101,28 @@ function withStatementCache(db: SqliteDatabase): SqliteDatabase {
94
101
  exec(sql) {
95
102
  db.exec(sql);
96
103
  },
104
+ // Immediate rather than deferred: these callbacks read before they write, and
105
+ // a deferred transaction's read-to-write upgrade fails SQLITE_BUSY outright
106
+ // instead of waiting on busy_timeout.
97
107
  transaction(fn) {
98
- return db.transaction(fn);
108
+ if (inTransaction) {
109
+ throw new Error(
110
+ "Cannot nest transactions: the outer BEGIN IMMEDIATE already holds the write lock",
111
+ );
112
+ }
113
+
114
+ inTransaction = true;
115
+ db.exec("BEGIN IMMEDIATE");
116
+ try {
117
+ const result = fn();
118
+ db.exec("COMMIT");
119
+ return result;
120
+ } catch (error) {
121
+ db.exec("ROLLBACK");
122
+ throw error;
123
+ } finally {
124
+ inTransaction = false;
125
+ }
99
126
  },
100
127
  close() {
101
128
  statements.clear();
@@ -1,7 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
- import { SettingsManager } from "@earendil-works/pi-coding-agent";
4
+ import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
5
5
  import type { KeyId } from "@earendil-works/pi-tui";
6
6
  import { type Static, Type } from "typebox";
7
7
  import { isThinkingLevel } from "./thinking-levels.ts";
@@ -9,6 +9,7 @@ import { parseTypeBoxValue } from "./typebox.ts";
9
9
 
10
10
  export const DEFAULT_AUTO_TITLE_REFRESH_TURNS = 4;
11
11
  export const DEFAULT_AUTO_TITLE_TIMEOUT_SECONDS = 15;
12
+ export const DEFAULT_AUTO_TITLE_TOKEN_BUDGET = 64;
12
13
  export const DEFAULT_AUTO_TITLE_PROMPT = `Name this coding session (under 80 chars). Be specific to what is being discussed. Your exact output will be displayed to the user, so make sure that it contains ONLY the title itself and nothing else.`;
13
14
  const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
14
15
  messaging: Type.Optional(
@@ -51,9 +52,11 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
51
52
  enable: Type.Optional(Type.Boolean()),
52
53
  refreshTurns: Type.Optional(Type.Integer({ minimum: 1 })),
53
54
  timeoutSecs: Type.Optional(Type.Integer({ minimum: 1 })),
55
+ tokenBudget: Type.Optional(Type.Integer({ minimum: 1 })),
54
56
  model: Type.Optional(Type.String()),
55
57
  thinkingLevel: Type.Optional(Type.String()),
56
58
  prompt: Type.Optional(Type.String()),
59
+ persistRuns: Type.Optional(Type.Boolean()),
57
60
  }),
58
61
  ),
59
62
  ask: Type.Optional(
@@ -82,7 +85,9 @@ export interface AgentModelSettings {
82
85
  export interface AutoTitleSettings extends AgentModelSettings {
83
86
  refreshTurns: number;
84
87
  timeoutMs: number;
88
+ tokenBudget: number;
85
89
  prompt: string;
90
+ persistRuns: boolean;
86
91
  }
87
92
 
88
93
  export interface AskSettings extends AgentModelSettings {
@@ -123,7 +128,7 @@ export interface SessionSettings {
123
128
  type SessionFileSettings = Static<typeof SESSION_FILE_SETTINGS_SCHEMA>;
124
129
 
125
130
  export function getDefaultIndexDir(): string {
126
- return path.join(os.homedir(), ".pi", "agent", "pi-sessions");
131
+ return path.join(getAgentDir(), "pi-sessions");
127
132
  }
128
133
 
129
134
  export function getDefaultIndexPath(): string {
@@ -138,6 +143,10 @@ export function getDefaultHandoffRunsDir(): string {
138
143
  return path.join(getDefaultIndexDir(), "session-handoff");
139
144
  }
140
145
 
146
+ export function getDefaultAutoTitleRunsDir(): string {
147
+ return path.join(getDefaultIndexDir(), "session-auto-title");
148
+ }
149
+
141
150
  function expandHome(rawPath: string): string {
142
151
  if (rawPath === "~") {
143
152
  return os.homedir();
@@ -240,7 +249,9 @@ function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSetti
240
249
  ...resolveAgentModelSettings(fileSettings.autoTitle),
241
250
  refreshTurns: fileSettings.autoTitle?.refreshTurns ?? DEFAULT_AUTO_TITLE_REFRESH_TURNS,
242
251
  timeoutMs: (fileSettings.autoTitle?.timeoutSecs ?? DEFAULT_AUTO_TITLE_TIMEOUT_SECONDS) * 1000,
252
+ tokenBudget: fileSettings.autoTitle?.tokenBudget ?? DEFAULT_AUTO_TITLE_TOKEN_BUDGET,
243
253
  prompt: normalizeAutoTitlePrompt(fileSettings.autoTitle?.prompt),
254
+ persistRuns: fileSettings.autoTitle?.persistRuns ?? false,
244
255
  },
245
256
  ask: {
246
257
  ...resolveAgentModelSettings(fileSettings.ask),
@@ -81,9 +81,31 @@ export async function hasAttachedTmuxClients(
81
81
  return result.stdout.split("\n").some((line) => line.trim().length > 0);
82
82
  }
83
83
 
84
+ const pendingCreations = new Map<string, Promise<unknown>>();
85
+
86
+ // Creating a window is check-then-act: it asks whether the tmux session exists and then either
87
+ // creates it or adds to it. Sibling subagents launched in one turn share a session name and race,
88
+ // so all but the winner get `duplicate session`. Only the parent's process ever creates its own
89
+ // session name, so serializing here is enough to close the window.
84
90
  export async function createTmuxWindow(
85
91
  executor: TmuxExecutor,
86
92
  options: CreateTmuxWindowOptions,
93
+ ): Promise<TmuxWindow> {
94
+ const preceding = pendingCreations.get(options.tmuxSession) ?? Promise.resolve();
95
+ const creation = preceding.catch(() => {}).then(() => createWindow(executor, options));
96
+ pendingCreations.set(options.tmuxSession, creation);
97
+ try {
98
+ return await creation;
99
+ } finally {
100
+ if (pendingCreations.get(options.tmuxSession) === creation) {
101
+ pendingCreations.delete(options.tmuxSession);
102
+ }
103
+ }
104
+ }
105
+
106
+ async function createWindow(
107
+ executor: TmuxExecutor,
108
+ options: CreateTmuxWindowOptions,
87
109
  ): Promise<TmuxWindow> {
88
110
  const existing = (await listTmuxWindows(executor, options.tmuxSession)).find(
89
111
  (window) => window.piSessionId === options.piSessionId,