arisa 5.2.7 → 5.2.19

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 (65) hide show
  1. package/README.md +25 -4
  2. package/package.json +10 -9
  3. package/pnpm-workspace.yaml +7 -5
  4. package/src/core/agent/agent-manager.js +92 -18
  5. package/src/core/agent/agent-session-lifecycle.js +29 -6
  6. package/src/core/agent/agent-turn-coordinator.js +143 -0
  7. package/src/core/agent/auth-flow.js +6 -6
  8. package/src/core/agent/model-speed.js +3 -1
  9. package/src/core/agent/pi-auth-login.js +28 -28
  10. package/src/core/agent/pi-capability-tools.js +8 -4
  11. package/src/core/agent/pi-runtime.js +14 -21
  12. package/src/core/agent/session-history-reader.js +168 -0
  13. package/src/core/agent/session-preload-migration.js +162 -0
  14. package/src/core/agent/session-rotation.js +29 -0
  15. package/src/core/agent/worker-tool-fanout.js +117 -0
  16. package/src/core/capabilities/capability-service.js +28 -2
  17. package/src/core/config/config-defaults.js +29 -0
  18. package/src/core/tasks/task-runner.js +8 -4
  19. package/src/core/tasks/task-store.js +48 -4
  20. package/src/core/tools/daemon-processes.js +7 -0
  21. package/src/core/tools/memory-pressure.js +2 -2
  22. package/src/core/tools/tool-registry.js +35 -7
  23. package/src/core/tools/weighted-resource-governor.js +7 -6
  24. package/src/official-tools.lock.json +129 -48
  25. package/src/runtime/bootstrap-cli.js +3 -3
  26. package/src/runtime/bootstrap-telegram.js +7 -7
  27. package/src/runtime/doctor.js +20 -73
  28. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  29. package/src/runtime/process-inspection.js +78 -0
  30. package/src/runtime/slave-cli.js +20 -10
  31. package/src/runtime/slave-service.js +299 -7
  32. package/src/runtime/tool-process-supervisor.js +35 -8
  33. package/src/runtime/tui.js +6 -7
  34. package/src/transport/telegram/bot.js +11 -5
  35. package/src/transport/telegram/chat-queue.js +6 -2
  36. package/src/transport/telegram/model-controls.js +1 -1
  37. package/src/transport/telegram/task-dispatcher.js +67 -15
  38. package/src/transport/telegram/telegram-auth-controller.js +7 -7
  39. package/src/transport/telegram/telegram-prompt-controller.js +17 -4
  40. package/src/transport/telegram/telegram-session-bridge.js +2 -1
  41. package/test/agent-turn-coordinator.test.js +48 -0
  42. package/test/auth-flow.test.js +2 -2
  43. package/test/capabilities-security.test.js +36 -0
  44. package/test/context-and-task-bounds.test.js +2 -1
  45. package/test/daemon-runtime.test.js +2 -4
  46. package/test/doctor.test.js +19 -0
  47. package/test/memory-pressure.test.js +7 -2
  48. package/test/model-selection.test.js +3 -2
  49. package/test/obsolete-daemon-reaper.test.js +61 -0
  50. package/test/official-tool-dependencies.test.js +7 -2
  51. package/test/official-tool-installer.test.js +13 -0
  52. package/test/pi-auth-login.test.js +78 -0
  53. package/test/pi-capability-tools.test.js +3 -0
  54. package/test/pi-compaction.test.js +21 -0
  55. package/test/pi-speed-integration.test.js +176 -0
  56. package/test/session-history-reader.test.js +84 -0
  57. package/test/session-preload-migration.test.js +120 -0
  58. package/test/session-rotation.test.js +110 -0
  59. package/test/slave-cli.test.js +221 -5
  60. package/test/task-store.test.js +34 -0
  61. package/test/telegram-prompt-controller.test.js +2 -1
  62. package/test/telegram-task-dispatcher.test.js +121 -5
  63. package/test/tool-registry-run.test.js +10 -1
  64. package/test/weighted-resource-governor.test.js +28 -0
  65. package/test/worker-tool-fanout.test.js +79 -0
@@ -1,13 +1,6 @@
1
- import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
- import { piAuthFile } from "../../platform/paths.js";
1
+ import { createPiRuntime, supportsProviderOAuth } from "./pi-runtime.js";
3
2
 
4
3
  export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, onProgress, onSelect } = {}) {
5
- const authStorage = AuthStorage.create(piAuthFile);
6
- const oauthProvider = authStorage.getOAuthProviders().find((item) => item.id === provider);
7
- if (!oauthProvider) {
8
- throw new Error(`No internal OAuth login flow is available for ${provider}.`);
9
- }
10
-
11
4
  let resolveManualCode;
12
5
  const manualCodePromise = new Promise((resolve) => {
13
6
  resolveManualCode = resolve;
@@ -15,7 +8,6 @@ export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, o
15
8
 
16
9
  const controller = {
17
10
  provider,
18
- oauthProvider,
19
11
  manualInputRequested: false,
20
12
  submitManualCode(value) {
21
13
  if (!resolveManualCode) return false;
@@ -31,25 +23,33 @@ export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, o
31
23
  promise: null
32
24
  };
33
25
 
34
- controller.promise = authStorage.login(provider, {
35
- onAuth: async (params) => {
36
- await onAuth?.({ ...params, controller });
37
- },
38
- onDeviceCode: async (params) => {
39
- await onDeviceCode?.({ ...params, controller });
40
- },
41
- onPrompt: async (params) => {
42
- if (!onPrompt) return "";
43
- return onPrompt({ ...params, controller });
44
- },
45
- onProgress: (message) => {
46
- onProgress?.(message);
47
- },
48
- onSelect: async (params) => {
49
- if (!onSelect) return params.options?.[0]?.id;
50
- return onSelect({ ...params, controller });
51
- },
52
- onManualCodeInput: () => controller.waitForManualCode()
26
+ controller.promise = createPiRuntime().then(async (runtime) => {
27
+ if (!supportsProviderOAuth(provider, runtime)) {
28
+ throw new Error(`No internal OAuth login flow is available for ${provider}.`);
29
+ }
30
+ let notifications = Promise.resolve();
31
+ let notificationError;
32
+ const credential = await runtime.login(provider, "oauth", {
33
+ notify(event) {
34
+ notifications = notifications.then(async () => {
35
+ if (event.type === "auth_url") await onAuth?.({ ...event, controller });
36
+ else if (event.type === "device_code") await onDeviceCode?.({ ...event, controller });
37
+ else await onProgress?.(event.message);
38
+ }).catch((error) => { notificationError = error; });
39
+ },
40
+ async prompt(params) {
41
+ await notifications;
42
+ if (notificationError) throw notificationError;
43
+ if (params.type === "select") {
44
+ return onSelect ? onSelect({ ...params, controller }) : params.options?.[0]?.id;
45
+ }
46
+ if (params.type === "manual_code") return controller.waitForManualCode();
47
+ return onPrompt ? onPrompt({ ...params, controller }) : "";
48
+ }
49
+ });
50
+ await notifications;
51
+ if (notificationError) throw notificationError;
52
+ return credential;
53
53
  }).finally(() => {
54
54
  controller.submitManualCode("");
55
55
  });
@@ -18,7 +18,7 @@ function nativeTools(policy) {
18
18
  }];
19
19
  }
20
20
 
21
- export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger }) {
21
+ export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger, toolFanout }) {
22
22
  if (!capabilityService?.execute) throw new Error("Pi capability tools require CapabilityService");
23
23
 
24
24
  const baseContext = {
@@ -38,6 +38,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
38
38
  }
39
39
  };
40
40
 
41
+ const runWithFanout = (work) => toolFanout?.run ? toolFanout.run(work) : work();
41
42
  const execute = (actorToolName, method, params = {}, context = {}) => capabilityService.execute({
42
43
  method,
43
44
  actorToolName,
@@ -46,6 +47,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
46
47
  context: {
47
48
  ...baseContext,
48
49
  taskContext: telegram.getTaskContext(),
50
+ agentTaskExecution: telegram.getAgentTaskExecution?.() || null,
49
51
  ...context
50
52
  }
51
53
  });
@@ -113,12 +115,14 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
113
115
  args: Type.Optional(Type.Record(Type.String(), Type.String())),
114
116
  deliver: Type.Optional(Type.Boolean())
115
117
  }),
116
- execute: async (_id, params) => jsonResult(await execute("run_tool", "tools.run", params))
118
+ execute: async (_id, params) => jsonResult(await runWithFanout(
119
+ () => execute("run_tool", "tools.run", params)
120
+ ))
117
121
  }),
118
122
  defineTool({
119
123
  name: "list_scheduled_tasks",
120
124
  label: "List scheduled tasks",
121
- description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending/running tasks, and accept an optional limit up to 100.",
125
+ description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending, running, and authentication-blocked tasks, and accept an optional limit up to 100.",
122
126
  parameters: Type.Object({
123
127
  status: Type.Optional(Type.String()),
124
128
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
@@ -135,7 +139,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
135
139
  defineTool({
136
140
  name: "cancel_all_scheduled_tasks",
137
141
  label: "Cancel all scheduled tasks",
138
- description: "Cancel all pending or running async tasks for the current Telegram chat.",
142
+ description: "Cancel all active async tasks, including authentication-blocked tasks, for the current Telegram chat.",
139
143
  parameters: Type.Object({}),
140
144
  execute: async () => jsonResult(await execute("cancel_all_scheduled_tasks", "tasks.cancelAll"))
141
145
  }),
@@ -1,34 +1,29 @@
1
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
1
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
2
  import { piAuthFile } from "../../platform/paths.js";
3
3
 
4
4
  function compareText(a, b) {
5
5
  return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
6
6
  }
7
7
 
8
- export function createPiRuntime({ provider, apiKey } = {}) {
9
- const authStorage = AuthStorage.create(piAuthFile);
8
+ export async function createPiRuntime({ provider, apiKey } = {}) {
9
+ const runtime = await ModelRuntime.create({ authPath: piAuthFile });
10
10
  if (provider && apiKey) {
11
- authStorage.setRuntimeApiKey(provider, apiKey);
11
+ await runtime.setRuntimeApiKey(provider, apiKey);
12
12
  }
13
- const modelRegistry = ModelRegistry.create(authStorage);
14
- const oauthProviders = authStorage.getOAuthProviders();
15
- return { authStorage, modelRegistry, oauthProviders };
13
+ return runtime;
16
14
  }
17
15
 
18
- export function hasProviderAuth(provider, { authStorage, modelRegistry }) {
19
- return modelRegistry.hasConfiguredAuth(provider) || authStorage.hasAuth(provider);
16
+ export function hasProviderAuth(provider, runtime) {
17
+ return runtime.getProviderAuthStatus(provider).configured;
20
18
  }
21
19
 
22
- export function supportsProviderOAuth(provider, { oauthProviders }) {
23
- return oauthProviders.some((item) => item.id === provider);
20
+ export function supportsProviderOAuth(provider, runtime) {
21
+ return Boolean(runtime.getProvider(provider)?.auth.oauth);
24
22
  }
25
23
 
26
- export function listPiProviders(runtime = createPiRuntime()) {
27
- const { modelRegistry, oauthProviders } = runtime;
28
- const allModels = modelRegistry.getAll();
29
- const oauthIds = new Set(oauthProviders.map((item) => item.id));
24
+ export function listPiProviders(runtime) {
30
25
  const counts = new Map();
31
- for (const model of allModels) {
26
+ for (const model of runtime.getModels()) {
32
27
  counts.set(model.provider, (counts.get(model.provider) || 0) + 1);
33
28
  }
34
29
 
@@ -36,7 +31,7 @@ export function listPiProviders(runtime = createPiRuntime()) {
36
31
  .map((provider) => ({
37
32
  provider,
38
33
  authConfigured: hasProviderAuth(provider, runtime),
39
- supportsOAuth: oauthIds.has(provider),
34
+ supportsOAuth: supportsProviderOAuth(provider, runtime),
40
35
  modelCount: counts.get(provider) || 0
41
36
  }))
42
37
  .sort((a, b) => {
@@ -46,10 +41,8 @@ export function listPiProviders(runtime = createPiRuntime()) {
46
41
  });
47
42
  }
48
43
 
49
- export function listProviderModels(provider, runtime = createPiRuntime()) {
50
- return runtime.modelRegistry
51
- .getAll()
52
- .filter((model) => model.provider === provider)
44
+ export function listProviderModels(provider, runtime) {
45
+ return [...runtime.getModels(provider)]
53
46
  .sort((a, b) => compareText(a.name || a.id, b.name || b.id));
54
47
  }
55
48
 
@@ -0,0 +1,168 @@
1
+ import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { StringDecoder } from "node:string_decoder";
4
+
5
+ const readBufferBytes = 1024 * 1024;
6
+ const supportedSessionVersion = 3;
7
+
8
+ function parseEntry(line) {
9
+ if (!line.trim()) return null;
10
+ try {
11
+ return JSON.parse(line);
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function readSessionHeader(filePath) {
18
+ const descriptor = openSync(filePath, "r");
19
+ try {
20
+ const buffer = Buffer.alloc(4096);
21
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
22
+ const line = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
23
+ const header = parseEntry(line);
24
+ return header?.type === "session" ? header : null;
25
+ } catch {
26
+ return null;
27
+ } finally {
28
+ closeSync(descriptor);
29
+ }
30
+ }
31
+
32
+ export function findMostRecentSessionFile(sessionDir, cwd) {
33
+ const expectedCwd = path.resolve(cwd);
34
+ try {
35
+ return readdirSync(sessionDir)
36
+ .filter((name) => name.endsWith(".jsonl"))
37
+ .map((name) => path.join(sessionDir, name))
38
+ .map((filePath) => ({ filePath, header: readSessionHeader(filePath) }))
39
+ .filter(({ header }) => header && typeof header.cwd === "string" && path.resolve(header.cwd) === expectedCwd)
40
+ .map(({ filePath }) => ({ filePath, mtimeMs: statSync(filePath).mtimeMs }))
41
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function streamSessionEntries(filePath, visit) {
48
+ const descriptor = openSync(filePath, "r");
49
+ try {
50
+ const decoder = new StringDecoder("utf8");
51
+ const buffer = Buffer.allocUnsafe(readBufferBytes);
52
+ let pending = "";
53
+ while (true) {
54
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, null);
55
+ if (!bytesRead) break;
56
+ pending += decoder.write(buffer.subarray(0, bytesRead));
57
+ let newline = pending.indexOf("\n");
58
+ while (newline !== -1) {
59
+ const entry = parseEntry(pending.slice(0, newline));
60
+ if (entry) visit(entry);
61
+ pending = pending.slice(newline + 1);
62
+ newline = pending.indexOf("\n");
63
+ }
64
+ }
65
+ pending += decoder.end();
66
+ const entry = parseEntry(pending);
67
+ if (entry) visit(entry);
68
+ } finally {
69
+ closeSync(descriptor);
70
+ }
71
+ }
72
+
73
+ function traceActivePath(entries, leafId) {
74
+ const reversed = [];
75
+ const seen = new Set();
76
+ let currentId = leafId;
77
+ while (currentId) {
78
+ if (seen.has(currentId)) return null;
79
+ seen.add(currentId);
80
+ const entry = entries.get(currentId);
81
+ if (!entry) return null;
82
+ reversed.push(entry);
83
+ currentId = entry.parentId || null;
84
+ }
85
+ return reversed.reverse();
86
+ }
87
+
88
+ function findLatestValidCompaction(path) {
89
+ const pathIndex = new Map(path.map((entry, index) => [entry.id, index]));
90
+ for (let index = path.length - 1; index >= 0; index -= 1) {
91
+ const entry = path[index];
92
+ if (entry.type !== "compaction" || !entry.hasSummary) continue;
93
+ if (!entry.firstKeptEntryId) return { entry, index, firstKeptIndex: index };
94
+ const firstKeptIndex = pathIndex.get(entry.firstKeptEntryId);
95
+ if (firstKeptIndex !== undefined && firstKeptIndex < index) {
96
+ return { entry, index, firstKeptIndex };
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function inspectSessionGraph(filePath) {
103
+ let header = null;
104
+ let firstEntry = true;
105
+ let invalidHeader = false;
106
+ let leafId = null;
107
+ let duplicateId = false;
108
+ const entries = new Map();
109
+ streamSessionEntries(filePath, (entry) => {
110
+ if (firstEntry) {
111
+ firstEntry = false;
112
+ header = entry.type === "session" ? entry : null;
113
+ invalidHeader = !header;
114
+ return;
115
+ }
116
+ if (invalidHeader) return;
117
+ if (!entry.id || entry.type === "session") return;
118
+ if (entries.has(entry.id)) duplicateId = true;
119
+ entries.set(entry.id, {
120
+ id: entry.id,
121
+ parentId: entry.parentId || null,
122
+ type: entry.type,
123
+ firstKeptEntryId: entry.type === "compaction" ? entry.firstKeptEntryId || null : null,
124
+ hasSummary: entry.type === "compaction" && Boolean(String(entry.summary || "").trim())
125
+ });
126
+ leafId = entry.id;
127
+ });
128
+ if (invalidHeader || !header || header.version !== supportedSessionVersion || duplicateId || !leafId) return null;
129
+ const path = traceActivePath(entries, leafId);
130
+ if (!path) return null;
131
+ const compaction = findLatestValidCompaction(path);
132
+ if (!compaction) return null;
133
+ return { header, path, compaction };
134
+ }
135
+
136
+ function loadMigrationPayload(filePath, path, compaction) {
137
+ const before = path.slice(compaction.firstKeptIndex, compaction.index);
138
+ const after = path.slice(compaction.index + 1);
139
+ const contextIds = [...before, ...after].map((entry) => entry.id);
140
+ const wanted = new Set(contextIds);
141
+ const loaded = new Map();
142
+ let summary = "";
143
+ streamSessionEntries(filePath, (entry) => {
144
+ if (entry.id === compaction.entry.id) summary = String(entry.summary || "").trim();
145
+ if (wanted.has(entry.id)) loaded.set(entry.id, entry);
146
+ });
147
+ if (!summary || loaded.size !== wanted.size) return null;
148
+ return {
149
+ summary,
150
+ contextEntries: contextIds.map((id) => loaded.get(id))
151
+ };
152
+ }
153
+
154
+ export function inspectSessionForPreloadMigration(filePath, maxPersistedBytes) {
155
+ const sourceBytes = statSync(filePath).size;
156
+ if (sourceBytes <= Math.max(1, Number(maxPersistedBytes) || 1)) return null;
157
+ const graph = inspectSessionGraph(filePath);
158
+ if (!graph) return null;
159
+ const payload = loadMigrationPayload(filePath, graph.path, graph.compaction);
160
+ if (!payload) return null;
161
+ return {
162
+ sourceFile: filePath,
163
+ sourceBytes,
164
+ header: graph.header,
165
+ compactionId: graph.compaction.entry.id,
166
+ ...payload
167
+ };
168
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ closeSync,
3
+ fsyncSync,
4
+ openSync,
5
+ renameSync,
6
+ statSync,
7
+ unlinkSync,
8
+ writeFileSync
9
+ } from "node:fs";
10
+ import path from "node:path";
11
+ import { randomUUID } from "node:crypto";
12
+ import {
13
+ findMostRecentSessionFile,
14
+ inspectSessionForPreloadMigration
15
+ } from "./session-history-reader.js";
16
+ import { normalizeSessionRotationPolicy } from "./session-rotation.js";
17
+
18
+ const contextEntryTypes = new Set([
19
+ "message",
20
+ "custom_message",
21
+ "branch_summary",
22
+ "thinking_level_change",
23
+ "model_change"
24
+ ]);
25
+
26
+ function createEntry(type, parentId, fields = {}) {
27
+ return {
28
+ type,
29
+ ...fields,
30
+ id: randomUUID(),
31
+ parentId,
32
+ timestamp: fields.timestamp || new Date().toISOString()
33
+ };
34
+ }
35
+
36
+ function appendDurably(filePath, entry) {
37
+ writeFileSync(filePath, `${JSON.stringify(entry)}\n`, { encoding: "utf8", flag: "a" });
38
+ }
39
+
40
+ function syncFile(filePath) {
41
+ const descriptor = openSync(filePath, "r");
42
+ try {
43
+ fsyncSync(descriptor);
44
+ } finally {
45
+ closeSync(descriptor);
46
+ }
47
+ }
48
+
49
+ function syncDirectory(directory) {
50
+ const descriptor = openSync(directory, "r");
51
+ try {
52
+ fsyncSync(descriptor);
53
+ } finally {
54
+ closeSync(descriptor);
55
+ }
56
+ }
57
+
58
+ function migratedEntry(source, parentId) {
59
+ const { id: _id, parentId: _parentId, ...fields } = source;
60
+ return createEntry(source.type, parentId, fields);
61
+ }
62
+
63
+ export function createPreloadMigrationChild({
64
+ sessionDir,
65
+ cwd,
66
+ migration,
67
+ operationalNotes = "",
68
+ now = new Date()
69
+ }) {
70
+ const timestamp = now.toISOString();
71
+ const sessionId = randomUUID();
72
+ const filenameTimestamp = timestamp.replace(/[:.]/g, "-");
73
+ const targetFile = path.join(sessionDir, `${filenameTimestamp}_${sessionId}.jsonl`);
74
+ const temporaryFile = path.join(sessionDir, `.session-migration-${sessionId}.tmp`);
75
+ const header = {
76
+ type: "session",
77
+ version: 3,
78
+ id: sessionId,
79
+ timestamp,
80
+ cwd,
81
+ parentSession: migration.sourceFile
82
+ };
83
+ let parentId = null;
84
+ let copiedEntries = 0;
85
+
86
+ try {
87
+ writeFileSync(temporaryFile, `${JSON.stringify(header)}\n`, { encoding: "utf8", flag: "wx" });
88
+ const notes = String(operationalNotes || "").trim();
89
+ if (notes) {
90
+ const entry = createEntry("custom_message", parentId, {
91
+ customType: "arisa-operational-notes",
92
+ content: notes,
93
+ display: false,
94
+ details: { source: "session-start" }
95
+ });
96
+ appendDurably(temporaryFile, entry);
97
+ parentId = entry.id;
98
+ }
99
+ const handoff = createEntry("custom_message", parentId, {
100
+ customType: "arisa-session-handoff",
101
+ content: [
102
+ "Automatic session migration before loading. Continue from this checkpoint:",
103
+ "",
104
+ migration.summary
105
+ ].join("\n"),
106
+ display: false,
107
+ details: { source: "preload-migration" }
108
+ });
109
+ appendDurably(temporaryFile, handoff);
110
+ parentId = handoff.id;
111
+
112
+ for (const sourceEntry of migration.contextEntries) {
113
+ if (!contextEntryTypes.has(sourceEntry.type)) continue;
114
+ const entry = migratedEntry(sourceEntry, parentId);
115
+ appendDurably(temporaryFile, entry);
116
+ parentId = entry.id;
117
+ copiedEntries += 1;
118
+ }
119
+
120
+ syncFile(temporaryFile);
121
+ renameSync(temporaryFile, targetFile);
122
+ syncDirectory(sessionDir);
123
+ return {
124
+ sourceFile: migration.sourceFile,
125
+ sourceBytes: migration.sourceBytes,
126
+ targetFile,
127
+ targetBytes: statSync(targetFile).size,
128
+ copiedEntries
129
+ };
130
+ } catch (error) {
131
+ try {
132
+ unlinkSync(temporaryFile);
133
+ } catch {}
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ export function migrateRecentSessionBeforeLoad({
139
+ sessionDir,
140
+ cwd,
141
+ policy,
142
+ operationalNotes = ""
143
+ }) {
144
+ const normalized = normalizeSessionRotationPolicy(policy);
145
+ if (!normalized.enabled) return null;
146
+ const sourceFile = findMostRecentSessionFile(sessionDir, cwd);
147
+ if (!sourceFile) return null;
148
+ const sourceBytes = statSync(sourceFile).size;
149
+ if (sourceBytes <= normalized.maxPersistedBytes) return null;
150
+ const migration = inspectSessionForPreloadMigration(sourceFile, normalized.maxPersistedBytes);
151
+ if (!migration) {
152
+ const error = new Error("Oversized Pi session could not be migrated safely before loading");
153
+ error.code = "PI_SESSION_PRELOAD_MIGRATION_UNAVAILABLE";
154
+ throw error;
155
+ }
156
+ return createPreloadMigrationChild({
157
+ sessionDir,
158
+ cwd,
159
+ migration,
160
+ operationalNotes
161
+ });
162
+ }
@@ -0,0 +1,29 @@
1
+ const defaultCompactAtPersistedBytes = 24 * 1024 * 1024;
2
+ const defaultMaxPersistedBytes = 32 * 1024 * 1024;
3
+
4
+ export function normalizeSessionRotationPolicy(policy = {}) {
5
+ const maxPersistedBytes = Math.max(1, Number(policy?.maxPersistedBytes) || defaultMaxPersistedBytes);
6
+ return {
7
+ enabled: policy?.enabled !== false,
8
+ compactAtPersistedBytes: Math.min(
9
+ maxPersistedBytes,
10
+ Math.max(1, Number(policy?.compactAtPersistedBytes) || defaultCompactAtPersistedBytes)
11
+ ),
12
+ maxPersistedBytes
13
+ };
14
+ }
15
+
16
+ export function compactionRotationRequest(event, persistedBytes, policy = {}) {
17
+ const normalized = normalizeSessionRotationPolicy(policy);
18
+ if (!normalized.enabled || event?.type !== "compaction_end" || event.aborted || event.errorMessage) return null;
19
+ const summary = String(event.result?.summary || "").trim();
20
+ if (!summary || Math.max(0, Number(persistedBytes) || 0) <= normalized.compactAtPersistedBytes) return null;
21
+ return {
22
+ handoff: [
23
+ "Automatic session rotation after compaction. Continue from this checkpoint:",
24
+ "",
25
+ summary
26
+ ].join("\n"),
27
+ persistedBytes: Math.max(0, Number(persistedBytes) || 0)
28
+ };
29
+ }
@@ -0,0 +1,117 @@
1
+ const defaults = Object.freeze({
2
+ enabled: true,
3
+ maxConcurrent: 2,
4
+ pressureConcurrent: 1,
5
+ serializePercent: 60
6
+ });
7
+
8
+ function boundedInteger(value, fallback, minimum, maximum) {
9
+ const number = Number(value);
10
+ return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, Math.floor(number))) : fallback;
11
+ }
12
+
13
+ function boundedPercent(value, fallback) {
14
+ const number = Number(value);
15
+ return Number.isFinite(number) ? Math.min(99, Math.max(1, number)) : fallback;
16
+ }
17
+
18
+ export function normalizeToolFanoutConfig(config = {}) {
19
+ const maxConcurrent = boundedInteger(config.maxConcurrent, defaults.maxConcurrent, 1, 16);
20
+ return {
21
+ enabled: config.enabled !== false,
22
+ maxConcurrent,
23
+ pressureConcurrent: Math.min(
24
+ maxConcurrent,
25
+ boundedInteger(config.pressureConcurrent, defaults.pressureConcurrent, 1, 16)
26
+ ),
27
+ serializePercent: boundedPercent(config.serializePercent, defaults.serializePercent)
28
+ };
29
+ }
30
+
31
+ export class WorkerToolFanoutController {
32
+ constructor({ heapCircuitBreaker, logger, config = {} }) {
33
+ this.heapCircuitBreaker = heapCircuitBreaker;
34
+ this.logger = logger;
35
+ this.active = 0;
36
+ this.queue = [];
37
+ this.draining = false;
38
+ this.metrics = {
39
+ peakActive: 0,
40
+ peakQueued: 0,
41
+ pressureSerializations: 0,
42
+ rejectedAdmissions: 0,
43
+ completed: 0
44
+ };
45
+ this.setConfig(config);
46
+ }
47
+
48
+ setConfig(config = {}) {
49
+ this.config = normalizeToolFanoutConfig(config);
50
+ this.drain();
51
+ }
52
+
53
+ capacity() {
54
+ if (!this.config.enabled) return Number.POSITIVE_INFINITY;
55
+ const pressure = this.heapCircuitBreaker.sample();
56
+ if (pressure.percent >= this.config.serializePercent) {
57
+ this.metrics.pressureSerializations += 1;
58
+ return this.config.pressureConcurrent;
59
+ }
60
+ return this.config.maxConcurrent;
61
+ }
62
+
63
+ async drain() {
64
+ if (this.draining) return;
65
+ this.draining = true;
66
+ try {
67
+ while (this.queue.length && this.active < this.capacity()) {
68
+ const job = this.queue.shift();
69
+ try {
70
+ await this.heapCircuitBreaker.admit();
71
+ } catch (error) {
72
+ this.metrics.rejectedAdmissions += 1;
73
+ job.reject(error);
74
+ continue;
75
+ }
76
+ this.active += 1;
77
+ this.metrics.peakActive = Math.max(this.metrics.peakActive, this.active);
78
+ job.resolve();
79
+ }
80
+ } finally {
81
+ this.draining = false;
82
+ }
83
+ }
84
+
85
+ acquire() {
86
+ if (!this.config.enabled) return Promise.resolve();
87
+ return new Promise((resolve, reject) => {
88
+ this.queue.push({ resolve, reject });
89
+ this.metrics.peakQueued = Math.max(this.metrics.peakQueued, this.queue.length);
90
+ this.drain();
91
+ });
92
+ }
93
+
94
+ release() {
95
+ if (this.config.enabled) this.active = Math.max(0, this.active - 1);
96
+ this.metrics.completed += 1;
97
+ this.drain();
98
+ }
99
+
100
+ async run(work) {
101
+ await this.acquire();
102
+ try {
103
+ return await work();
104
+ } finally {
105
+ this.release();
106
+ }
107
+ }
108
+
109
+ getDiagnostic() {
110
+ return {
111
+ ...this.config,
112
+ active: this.active,
113
+ queued: this.queue.length,
114
+ ...this.metrics
115
+ };
116
+ }
117
+ }