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.
- package/README.md +25 -20
- package/dist/session-messaging/broker/client-frame.js +62 -0
- package/dist/session-messaging/broker/process.js +2 -2
- package/dist/shared/session-broker/framing.js +4 -3
- package/extensions/pi-sessions.ts +14 -1
- package/extensions/session-auto-title/generate.ts +56 -66
- package/extensions/session-auto-title/install.ts +2 -0
- package/extensions/session-auto-title/runs.ts +50 -0
- package/extensions/session-handoff/kickoff.ts +4 -3
- package/extensions/session-handoff/metadata.ts +1 -1
- package/extensions/session-messaging/broker/client-frame.ts +113 -0
- package/extensions/session-messaging/broker/process.ts +12 -21
- package/extensions/session-messaging/pi/reachable-contract.ts +59 -0
- package/extensions/session-messaging/pi/reachable-presenter.ts +49 -0
- package/extensions/session-messaging/pi/reachable-tool.ts +190 -0
- package/extensions/session-messaging/pi/reachable-view-model.ts +59 -0
- package/extensions/session-messaging/pi/renderer.ts +1 -1
- package/extensions/session-messaging/pi/tools.ts +8 -8
- package/extensions/session-search/hooks.ts +45 -52
- package/extensions/session-search/install.ts +3 -158
- package/extensions/session-search/reindex.ts +3 -3
- package/extensions/session-search/renderer.ts +1 -32
- package/extensions/session-search/tool-contract.ts +1 -21
- package/extensions/shared/model-runtime.ts +8 -1
- package/extensions/shared/session-broker/client.ts +3 -4
- package/extensions/shared/session-broker/framing.ts +6 -7
- package/extensions/shared/session-index/lineage.ts +6 -2
- package/extensions/shared/session-index/sqlite.ts +44 -17
- package/extensions/shared/settings.ts +13 -2
- package/extensions/shared/tmux.ts +22 -0
- package/extensions/subagents/report-message-renderer.ts +2 -2
- package/package.json +17 -29
- package/dist/shared/session-broker/protocol.js +0 -163
- 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
|
-
|
|
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
|
-
})
|
|
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))
|
|
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
|
-
})
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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>):
|
|
35
|
+
new (path: string, options?: Record<string, unknown>): SqliteDriver;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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("
|
|
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, {
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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,
|