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.
- package/README.md +63 -9
- package/extensions/hosts/contract.ts +51 -0
- package/extensions/hosts/registry.ts +129 -0
- package/extensions/pi-sessions.ts +36 -10
- package/extensions/session-handoff/install.ts +4 -1
- package/extensions/session-handoff/launch/backend.ts +3 -10
- package/extensions/session-handoff/launch-options.ts +17 -2
- package/extensions/session-handoff/launch-target.ts +28 -16
- package/extensions/session-handoff/metadata.ts +2 -0
- package/extensions/session-handoff/query.ts +5 -4
- package/extensions/session-handoff/spawn.ts +2 -1
- package/extensions/session-handoff/tool-schema.ts +10 -2
- package/extensions/session-handoff/tool.ts +1 -0
- package/extensions/session-hooks/install.ts +14 -5
- package/extensions/session-index/install.ts +25 -4
- package/extensions/session-index/panel.ts +9 -0
- package/extensions/session-messaging/message-router.ts +91 -0
- package/extensions/session-messaging/pi/reachable-contract.ts +2 -1
- package/extensions/session-messaging/pi/reachable-tool.ts +24 -4
- package/extensions/session-messaging/pi/tools.ts +9 -1
- package/extensions/shared/session-index/access.ts +42 -5
- package/extensions/shared/session-index/common.ts +1 -0
- package/extensions/shared/session-index/recovery.ts +270 -0
- package/extensions/shared/session-index/schema.ts +9 -1
- package/extensions/shared/session-index/sqlite.ts +20 -10
- package/extensions/{session-search/hooks.ts → shared/session-index/sync.ts} +40 -16
- package/extensions/subagents/install.ts +13 -13
- package/extensions/subagents/settle.ts +1 -1
- package/extensions/subagents/wake.ts +32 -116
- package/package.json +5 -5
- 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, {
|
|
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: {
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
111
|
-
pi,
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
173
|
+
bootstrap.subagent?.childSessionId !== sessionId
|
|
174
174
|
) {
|
|
175
175
|
return undefined;
|
|
176
176
|
}
|