arisa 5.2.7 → 5.2.17

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 (45) hide show
  1. package/README.md +21 -2
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +79 -4
  4. package/src/core/agent/agent-session-lifecycle.js +29 -6
  5. package/src/core/agent/agent-turn-coordinator.js +143 -0
  6. package/src/core/agent/pi-capability-tools.js +7 -4
  7. package/src/core/agent/session-history-reader.js +168 -0
  8. package/src/core/agent/session-preload-migration.js +162 -0
  9. package/src/core/agent/session-rotation.js +29 -0
  10. package/src/core/agent/worker-tool-fanout.js +117 -0
  11. package/src/core/capabilities/capability-service.js +3 -2
  12. package/src/core/config/config-defaults.js +29 -0
  13. package/src/core/tasks/task-runner.js +8 -4
  14. package/src/core/tasks/task-store.js +47 -4
  15. package/src/core/tools/daemon-processes.js +7 -0
  16. package/src/core/tools/memory-pressure.js +2 -2
  17. package/src/core/tools/tool-registry.js +35 -7
  18. package/src/core/tools/weighted-resource-governor.js +7 -6
  19. package/src/official-tools.lock.json +121 -47
  20. package/src/runtime/doctor.js +20 -73
  21. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  22. package/src/runtime/process-inspection.js +78 -0
  23. package/src/runtime/tool-process-supervisor.js +35 -8
  24. package/src/runtime/tui.js +1 -1
  25. package/src/transport/telegram/bot.js +3 -1
  26. package/src/transport/telegram/chat-queue.js +6 -2
  27. package/src/transport/telegram/task-dispatcher.js +36 -11
  28. package/src/transport/telegram/telegram-prompt-controller.js +17 -4
  29. package/test/agent-turn-coordinator.test.js +45 -0
  30. package/test/context-and-task-bounds.test.js +2 -1
  31. package/test/daemon-runtime.test.js +2 -4
  32. package/test/doctor.test.js +19 -0
  33. package/test/memory-pressure.test.js +7 -2
  34. package/test/obsolete-daemon-reaper.test.js +61 -0
  35. package/test/official-tool-dependencies.test.js +7 -2
  36. package/test/pi-compaction.test.js +21 -0
  37. package/test/session-history-reader.test.js +84 -0
  38. package/test/session-preload-migration.test.js +120 -0
  39. package/test/session-rotation.test.js +110 -0
  40. package/test/task-store.test.js +32 -0
  41. package/test/telegram-prompt-controller.test.js +2 -1
  42. package/test/telegram-task-dispatcher.test.js +66 -5
  43. package/test/tool-registry-run.test.js +10 -1
  44. package/test/weighted-resource-governor.test.js +28 -0
  45. package/test/worker-tool-fanout.test.js +79 -0
package/README.md CHANGED
@@ -108,7 +108,7 @@ Per chat (`~/.arisa/chats/<chatId>/`):
108
108
  - chat-scoped daemon infrastructure lives in `state/tools/<tool>/daemon/`; persistent tool data stays beside it
109
109
  - ephemeral scratch lives under `tmp/`
110
110
 
111
- Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context.
111
+ Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context. The supervisor automatically removes registrations and daemon runtime directories that no longer match an installed daemon tool. A live process is terminated only when its command line matches the registered entry and daemon invocation; unverifiable PIDs are left untouched and reported for attention.
112
112
 
113
113
  Daemon tools may opt into the `arisa-daemon-v1` local protocol for immediate
114
114
  multiplexed jobs and incremental NDJSON events over a capability-protected local
@@ -151,12 +151,31 @@ Automatic context compaction uses Pi's native implementation and can be tuned in
151
151
  "enabled": true,
152
152
  "reserveTokens": 120000,
153
153
  "keepRecentTokens": 20000
154
+ },
155
+ "sessionRotation": {
156
+ "enabled": true,
157
+ "compactAtPersistedBytes": 25165824,
158
+ "maxPersistedBytes": 33554432
159
+ },
160
+ "toolFanout": {
161
+ "enabled": true,
162
+ "maxConcurrent": 2,
163
+ "pressureConcurrent": 1,
164
+ "serializePercent": 60
165
+ },
166
+ "turnCoordinator": {
167
+ "enabled": true,
168
+ "backgroundQueueTtlMs": 600000,
169
+ "interactiveQueueTtlMs": 0,
170
+ "maxQueued": 100
154
171
  }
155
172
  }
156
173
  }
157
174
  ```
158
175
 
159
- Pi compacts when the context exceeds the model's context window minus `reserveTokens`. The default keeps a large reserve so compaction occurs before Arisa Doctor's context warning on the default model. Set a smaller reserve when using models with substantially smaller context windows. Arisa does not add Telegram commands or compaction notifications.
176
+ Pi compacts when the context exceeds the model's context window minus `reserveTokens`. Arisa also requests compaction when persisted history exceeds `compactAtPersistedBytes`, then rotates to a fresh JSONL using the latest summary and retained active context. Before Pi loads a recent session above `maxPersistedBytes`, Arisa discovers the last valid active-branch compaction by streaming, atomically creates a compact child with `parentSession`, and leaves the historical JSONL intact. An unsafe oversized session is rejected rather than loaded into an OOM-prone worker. Same-turn `run_tool` fan-out is capped at two, becomes sequential when heap usage reaches `serializePercent`, and uses the heap circuit breaker before every admission. Across chats and topics, `turnCoordinator` admits only one Pi turn or headless poll at a time, prioritizes interactive messages and events over queued background work, and safely expires stale background admissions before execution. Set a smaller token reserve when using models with substantially smaller context windows. Arisa adds no Telegram commands or compaction notifications.
177
+
178
+ Browser and checkout tools report lifecycle states separately: profile connection is not a session share, a session share is not target validation, wallet enrollment is not checkout submission, and only explicit target or merchant evidence can confirm the final objective.
160
179
 
161
180
  ## Install globally
162
181
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.2.7",
3
+ "version": "5.2.17",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -14,6 +14,9 @@ import { createPiCapabilityTools } from "./pi-capability-tools.js";
14
14
  import { ToolResourceNoteStore } from "../tools/tool-resource-note-store.js";
15
15
  import { materializeToolOutput } from "../tools/tool-output-materializer.js";
16
16
  import { WorkerHeapCircuitBreaker } from "./worker-heap-circuit-breaker.js";
17
+ import { WorkerToolFanoutController } from "./worker-tool-fanout.js";
18
+ import { compactionRotationRequest, normalizeSessionRotationPolicy } from "./session-rotation.js";
19
+ import { AgentTurnCoordinator } from "./agent-turn-coordinator.js";
17
20
 
18
21
  const piValidationTimeoutMs = 60_000;
19
22
  const arisaToolNames = [
@@ -156,13 +159,23 @@ export class AgentManager {
156
159
  this.sessionLifecycle = new AgentSessionLifecycle({
157
160
  logger,
158
161
  summarizeContext: summarizeRetainedContext,
159
- cachePolicy: config.pi.sessionCache
162
+ cachePolicy: config.pi.sessionCache,
163
+ sessionRotationPolicy: config.pi.sessionRotation
160
164
  });
161
165
  this.heapCircuitBreaker = new WorkerHeapCircuitBreaker({
162
166
  lifecycle: this.sessionLifecycle,
163
167
  logger,
164
168
  config: config.pi.heapCircuitBreaker
165
169
  });
170
+ this.toolFanout = new WorkerToolFanoutController({
171
+ heapCircuitBreaker: this.heapCircuitBreaker,
172
+ logger,
173
+ config: config.pi.toolFanout
174
+ });
175
+ this.turnCoordinator = new AgentTurnCoordinator({
176
+ logger,
177
+ config: config.pi.turnCoordinator
178
+ });
166
179
  this.sessions = this.sessionLifecycle.sessions;
167
180
  this.pendingNewSessions = this.sessionLifecycle.pendingNewSessions;
168
181
  this.pendingSessionHandoffs = this.sessionLifecycle.pendingSessionHandoffs;
@@ -196,7 +209,10 @@ export class AgentManager {
196
209
  setConfig(config) {
197
210
  this.sessionLifecycle.resetConfigState();
198
211
  this.sessionLifecycle.setCachePolicy(config.pi.sessionCache);
212
+ this.sessionLifecycle.setSessionRotationPolicy(config.pi.sessionRotation);
199
213
  this.heapCircuitBreaker.setConfig(config.pi.heapCircuitBreaker);
214
+ this.toolFanout.setConfig(config.pi.toolFanout);
215
+ this.turnCoordinator.setConfig(config.pi.turnCoordinator);
200
216
  this.config = config;
201
217
  }
202
218
 
@@ -222,7 +238,9 @@ export class AgentManager {
222
238
  const diagnostic = await this.sessionLifecycle.getDiagnostic();
223
239
  return {
224
240
  ...diagnostic,
225
- heapCircuitBreaker: this.heapCircuitBreaker.getDiagnostic()
241
+ heapCircuitBreaker: this.heapCircuitBreaker.getDiagnostic(),
242
+ toolFanout: this.toolFanout.getDiagnostic(),
243
+ turnCoordinator: this.turnCoordinator.diagnostic()
226
244
  };
227
245
  }
228
246
 
@@ -249,13 +267,60 @@ export class AgentManager {
249
267
  return context;
250
268
  }
251
269
 
270
+ async compactPersistedSessionIfNeeded(sessionKey, context, persistedBytes) {
271
+ const policy = normalizeSessionRotationPolicy(this.config.pi.sessionRotation);
272
+ if (!policy.enabled
273
+ || persistedBytes <= policy.compactAtPersistedBytes
274
+ || context.rotationRequest
275
+ || typeof context.session?.compact !== "function") return;
276
+ try {
277
+ this.logger?.log("agent", `compacting ${Math.ceil(persistedBytes / 1024 / 1024)} MiB Pi session for chat ${sessionKey} before rotation`);
278
+ await context.session.compact();
279
+ await context.rotationCheckPromise;
280
+ } catch (error) {
281
+ this.logger?.error?.("agent", `persisted-size compaction failed for chat ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`);
282
+ }
283
+ }
284
+
252
285
  async releaseSessionContext(sessionKey, context) {
253
286
  if (this.sessions.get(String(sessionKey)) !== context) return;
287
+ await context.rotationCheckPromise;
254
288
  const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
289
+ if ((context.activeUsers || 0) <= 1) {
290
+ await this.compactPersistedSessionIfNeeded(sessionKey, context, persistedBytes);
291
+ }
255
292
  this.sessionLifecycle.releaseCached(sessionKey, persistedBytes);
293
+ if ((context.activeUsers || 0) === 0 && context.rotationRequest) {
294
+ const request = context.rotationRequest;
295
+ const parentSession = context.session.sessionFile || "";
296
+ this.logger?.log("agent", `rotating ${Math.ceil(request.persistedBytes / 1024 / 1024)} MiB Pi session for chat ${sessionKey} after compaction`);
297
+ this.sessionLifecycle.resetSession(sessionKey, {
298
+ handoff: request.handoff,
299
+ parentSession,
300
+ source: "compaction-rotation"
301
+ });
302
+ await this.sessionLifecycle.waitForClose(sessionKey);
303
+ return;
304
+ }
256
305
  await this.sessionLifecycle.enforceCachePolicy();
257
306
  }
258
307
 
308
+ scheduleCompactionRotationCheck(sessionKey, context, event) {
309
+ if (event?.type !== "compaction_end") return;
310
+ const previous = context.rotationCheckPromise || Promise.resolve();
311
+ context.rotationCheckPromise = previous
312
+ .catch(() => {})
313
+ .then(async () => {
314
+ if (this.sessions.get(String(sessionKey)) !== context) return;
315
+ const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
316
+ const request = compactionRotationRequest(event, persistedBytes, this.config.pi.sessionRotation);
317
+ if (request) context.rotationRequest = request;
318
+ })
319
+ .catch((error) => {
320
+ this.logger?.error?.("agent", `session rotation check failed for chat ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`);
321
+ });
322
+ }
323
+
259
324
  async validatePiAgent(config = this.config) {
260
325
  this.logger?.log("agent", "validating Pi session");
261
326
  const { authStorage, modelRegistry } = createPiRuntime({
@@ -402,8 +467,11 @@ export class AgentManager {
402
467
  modelKey: effectiveModelKey,
403
468
  speedController,
404
469
  telegramTarget,
405
- accessGuardTarget
470
+ accessGuardTarget,
471
+ rotationCheckPromise: Promise.resolve(),
472
+ rotationRequest: null
406
473
  };
474
+ session.subscribe((event) => this.scheduleCompactionRotationCheck(sessionKey, ctx, event));
407
475
  this.sessions.set(sessionKey, ctx);
408
476
  if (isNewSession) this.sessionLifecycle.completeNewSession(sessionKey);
409
477
  return this.acquireSessionContext(sessionKey, ctx);
@@ -424,9 +492,15 @@ export class AgentManager {
424
492
  }
425
493
 
426
494
  async close() {
495
+ this.turnCoordinator.close();
427
496
  await this.sessionLifecycle.closeAll();
428
497
  }
429
498
 
499
+ async runTurn(options, work) {
500
+ if (typeof work !== "function") throw new Error("Agent turn work is required");
501
+ return this.turnCoordinator.run(options, work);
502
+ }
503
+
430
504
  async runTool({ name, request, chatId, taskContext = null }) {
431
505
  await this.toolRegistry.load();
432
506
  this.logger?.log("agent", `run_tool ${name}`);
@@ -453,7 +527,8 @@ export class AgentManager {
453
527
  telegram,
454
528
  chatId,
455
529
  policy,
456
- logger: this.logger
530
+ logger: this.logger,
531
+ toolFanout: this.toolFanout
457
532
  });
458
533
  }
459
534
 
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { SessionManager } from "@earendil-works/pi-coding-agent";
3
3
  import { getChatPiSessionsDir, sessionStartOperationalNotesFile } from "../../platform/paths.js";
4
4
  import { arisaInstallDir } from "./runtime-context.js";
5
+ import { migrateRecentSessionBeforeLoad } from "./session-preload-migration.js";
5
6
 
6
7
  const operationalNoteMaxChars = 220;
7
8
 
@@ -26,7 +27,7 @@ export function loadSessionStartOperationalNotes() {
26
27
  }
27
28
  }
28
29
 
29
- function formatSessionStartOperationalNotes(notes) {
30
+ export function formatSessionStartOperationalNotes(notes) {
30
31
  if (!notes.length) return "";
31
32
  return [
32
33
  "Durable operating notes for this Arisa session:",
@@ -41,7 +42,7 @@ function closeAgentSession(session) {
41
42
  }
42
43
 
43
44
  export class AgentSessionLifecycle {
44
- constructor({ logger, summarizeContext, cachePolicy = {} }) {
45
+ constructor({ logger, summarizeContext, cachePolicy = {}, sessionRotationPolicy = {} }) {
45
46
  this.logger = logger;
46
47
  this.summarizeContext = summarizeContext;
47
48
  this.sessions = new Map();
@@ -49,6 +50,7 @@ export class AgentSessionLifecycle {
49
50
  this.pendingSessionHandoffs = new Map();
50
51
  this.sessionClosePromises = new Map();
51
52
  this.setCachePolicy(cachePolicy);
53
+ this.setSessionRotationPolicy(sessionRotationPolicy);
52
54
  }
53
55
 
54
56
  setCachePolicy(cachePolicy = {}) {
@@ -58,6 +60,10 @@ export class AgentSessionLifecycle {
58
60
  };
59
61
  }
60
62
 
63
+ setSessionRotationPolicy(policy = {}) {
64
+ this.sessionRotationPolicy = { ...policy };
65
+ }
66
+
61
67
  acquireCached(sessionKey, persistedBytes = 0) {
62
68
  const context = this.sessions.get(String(sessionKey));
63
69
  if (!context) return;
@@ -161,14 +167,19 @@ export class AgentSessionLifecycle {
161
167
  this.pendingSessionHandoffs.clear();
162
168
  }
163
169
 
164
- resetSession(chatId, { handoff = "", parentSession = "" } = {}) {
170
+ resetSession(chatId, { handoff = "", parentSession = "", source = "" } = {}) {
165
171
  const sessionKey = String(chatId);
166
172
  this.closeCached(sessionKey);
167
173
  this.pendingNewSessions.add(sessionKey);
168
174
  const text = String(handoff || "").trim();
169
175
  const parent = String(parentSession || "").trim();
176
+ const handoffSource = String(source || "").trim();
170
177
  if (text || parent) {
171
- this.pendingSessionHandoffs.set(sessionKey, { text, parentSession: parent });
178
+ this.pendingSessionHandoffs.set(sessionKey, {
179
+ text,
180
+ parentSession: parent,
181
+ ...(handoffSource ? { source: handoffSource } : {})
182
+ });
172
183
  } else {
173
184
  this.pendingSessionHandoffs.delete(sessionKey);
174
185
  }
@@ -177,6 +188,7 @@ export class AgentSessionLifecycle {
177
188
  createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
178
189
  const sessionKey = String(chatId);
179
190
  const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
191
+ const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
180
192
  if (this.pendingNewSessions.has(sessionKey)) {
181
193
  this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
182
194
  const handoff = this.pendingSessionHandoffs.get(sessionKey);
@@ -185,7 +197,6 @@ export class AgentSessionLifecycle {
185
197
  sessionDir,
186
198
  handoff?.parentSession ? { parentSession: handoff.parentSession } : undefined
187
199
  );
188
- const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
189
200
  if (operationalNotes) {
190
201
  sessionManager.appendCustomMessageEntry(
191
202
  "arisa-operational-notes",
@@ -199,11 +210,23 @@ export class AgentSessionLifecycle {
199
210
  "arisa-session-handoff",
200
211
  handoff.text,
201
212
  false,
202
- { source: "telegram-new" }
213
+ { source: handoff.source || "telegram-new" }
203
214
  );
204
215
  }
205
216
  return { sessionManager, isNewSession: true };
206
217
  }
218
+ const migration = migrateRecentSessionBeforeLoad({
219
+ sessionDir,
220
+ cwd: workspaceDir,
221
+ policy: this.sessionRotationPolicy,
222
+ operationalNotes
223
+ });
224
+ if (migration) {
225
+ this.logger?.log(
226
+ "agent",
227
+ `migrated oversized Pi session for chat ${sessionKey} before loading (${Math.ceil(migration.sourceBytes / 1024 / 1024)} MiB -> ${Math.ceil(migration.targetBytes / 1024 / 1024)} MiB)`
228
+ );
229
+ }
207
230
  this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
208
231
  return {
209
232
  sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir),
@@ -0,0 +1,143 @@
1
+ function normalizedPriority(value) {
2
+ if (value === "interactive") return 10;
3
+ if (value === "background") return 0;
4
+ const parsed = Number(value);
5
+ return Number.isFinite(parsed) ? Math.max(-100, Math.min(100, parsed)) : 0;
6
+ }
7
+
8
+ function positiveInteger(value, fallback, max) {
9
+ const parsed = Number.parseInt(String(value ?? ""), 10);
10
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.min(max, parsed) : fallback;
11
+ }
12
+
13
+ function queueExpiredError(label) {
14
+ const error = new Error(`${label || "Agent turn"} expired while waiting for exclusive execution.`);
15
+ error.code = "AGENT_TURN_QUEUE_EXPIRED";
16
+ error.retryable = true;
17
+ error.outcomeUncertain = false;
18
+ return error;
19
+ }
20
+
21
+ export class AgentTurnCoordinator {
22
+ constructor({ config = {}, logger = null, now = Date.now } = {}) {
23
+ this.logger = logger;
24
+ this.now = now;
25
+ this.queue = [];
26
+ this.active = null;
27
+ this.sequence = 0;
28
+ this.closed = false;
29
+ this.completed = 0;
30
+ this.expired = 0;
31
+ this.maxObservedQueue = 0;
32
+ this.totalWaitMs = 0;
33
+ this.setConfig(config);
34
+ }
35
+
36
+ setConfig(config = {}) {
37
+ this.config = {
38
+ enabled: config.enabled !== false,
39
+ backgroundQueueTtlMs: positiveInteger(config.backgroundQueueTtlMs, 10 * 60_000, 60 * 60_000),
40
+ interactiveQueueTtlMs: positiveInteger(config.interactiveQueueTtlMs, 0, 60 * 60_000),
41
+ maxQueued: Math.max(1, positiveInteger(config.maxQueued, 100, 1_000))
42
+ };
43
+ }
44
+
45
+ queueTtlMs(priority, override) {
46
+ if (override != null) return positiveInteger(override, 0, 60 * 60_000);
47
+ return priority === "interactive" ? this.config.interactiveQueueTtlMs : this.config.backgroundQueueTtlMs;
48
+ }
49
+
50
+ describeActive(entry) {
51
+ if (!entry) return null;
52
+ return { label: entry.label, priority: entry.priorityName, startedAt: entry.startedAt };
53
+ }
54
+
55
+ diagnostic() {
56
+ return {
57
+ enabled: this.config.enabled,
58
+ active: this.describeActive(this.active),
59
+ queued: this.queue.length,
60
+ maxObservedQueue: this.maxObservedQueue,
61
+ completed: this.completed,
62
+ expired: this.expired,
63
+ averageWaitMs: this.completed ? Math.round(this.totalWaitMs / this.completed) : 0
64
+ };
65
+ }
66
+
67
+ remove(entry) {
68
+ const index = this.queue.indexOf(entry);
69
+ if (index >= 0) this.queue.splice(index, 1);
70
+ }
71
+
72
+ next() {
73
+ if (this.active || this.closed || !this.queue.length) return;
74
+ this.queue.sort((left, right) => right.priority - left.priority || left.sequence - right.sequence);
75
+ const entry = this.queue.shift();
76
+ if (entry.timer) clearTimeout(entry.timer);
77
+ entry.startedAt = new Date(this.now()).toISOString();
78
+ this.active = entry;
79
+ const waitMs = Math.max(0, this.now() - entry.queuedAt);
80
+ this.totalWaitMs += waitMs;
81
+ if (waitMs > 0) this.logger?.log("agent", `${entry.label} waited ${waitMs}ms for exclusive agent execution`);
82
+ let released = false;
83
+ entry.resolve(() => {
84
+ if (released) return;
85
+ released = true;
86
+ if (this.active === entry) this.active = null;
87
+ this.completed += 1;
88
+ queueMicrotask(() => this.next());
89
+ });
90
+ }
91
+
92
+ acquire({ priority: priorityName = "background", label = "Agent turn", queueTtlMs } = {}) {
93
+ if (!this.config.enabled) return Promise.resolve(() => {});
94
+ if (this.closed) return Promise.reject(Object.assign(new Error("Agent turn coordinator is closed."), { retryable: true }));
95
+ if (this.queue.length >= this.config.maxQueued) {
96
+ return Promise.reject(Object.assign(new Error("Agent turn queue is full."), { code: "AGENT_TURN_QUEUE_FULL", retryable: true }));
97
+ }
98
+ const priority = normalizedPriority(priorityName);
99
+ const ttlMs = this.queueTtlMs(priorityName, queueTtlMs);
100
+ return new Promise((resolve, reject) => {
101
+ const entry = {
102
+ label: String(label || "Agent turn").slice(0, 160),
103
+ priorityName: priorityName === "interactive" ? "interactive" : "background",
104
+ priority,
105
+ sequence: this.sequence += 1,
106
+ queuedAt: this.now(),
107
+ startedAt: null,
108
+ resolve,
109
+ reject,
110
+ timer: null
111
+ };
112
+ if (ttlMs > 0) {
113
+ entry.timer = setTimeout(() => {
114
+ if (this.active === entry) return;
115
+ this.remove(entry);
116
+ this.expired += 1;
117
+ reject(queueExpiredError(entry.label));
118
+ }, ttlMs);
119
+ entry.timer.unref?.();
120
+ }
121
+ this.queue.push(entry);
122
+ this.maxObservedQueue = Math.max(this.maxObservedQueue, this.queue.length);
123
+ this.next();
124
+ });
125
+ }
126
+
127
+ async run(options, work) {
128
+ const release = await this.acquire(options);
129
+ try {
130
+ return await work();
131
+ } finally {
132
+ release();
133
+ }
134
+ }
135
+
136
+ close() {
137
+ this.closed = true;
138
+ for (const entry of this.queue.splice(0)) {
139
+ if (entry.timer) clearTimeout(entry.timer);
140
+ entry.reject(Object.assign(new Error("Agent turn coordinator closed before execution."), { retryable: true }));
141
+ }
142
+ }
143
+ }
@@ -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,
@@ -113,12 +114,14 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
113
114
  args: Type.Optional(Type.Record(Type.String(), Type.String())),
114
115
  deliver: Type.Optional(Type.Boolean())
115
116
  }),
116
- execute: async (_id, params) => jsonResult(await execute("run_tool", "tools.run", params))
117
+ execute: async (_id, params) => jsonResult(await runWithFanout(
118
+ () => execute("run_tool", "tools.run", params)
119
+ ))
117
120
  }),
118
121
  defineTool({
119
122
  name: "list_scheduled_tasks",
120
123
  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.",
124
+ 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
125
  parameters: Type.Object({
123
126
  status: Type.Optional(Type.String()),
124
127
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
@@ -135,7 +138,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
135
138
  defineTool({
136
139
  name: "cancel_all_scheduled_tasks",
137
140
  label: "Cancel all scheduled tasks",
138
- description: "Cancel all pending or running async tasks for the current Telegram chat.",
141
+ description: "Cancel all active async tasks, including authentication-blocked tasks, for the current Telegram chat.",
139
142
  parameters: Type.Object({}),
140
143
  execute: async () => jsonResult(await execute("cancel_all_scheduled_tasks", "tasks.cancelAll"))
141
144
  }),
@@ -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
+ }