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
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
@@ -127,8 +127,10 @@ Slave runs only the IPC host, daemon supervisor and installed tools. Connections
127
127
  are authenticated, encrypted, initiated by Slave and restricted by per-Slave
128
128
  roots and capability grants.
129
129
 
130
- Linux with systemd is the first supported Slave target. Bootstrap uses a
131
- single-use URL issued by Master:
130
+ Arisa Slave supports Linux with systemd, macOS with launchd, and Windows with
131
+ Task Scheduler. On desktop systems, run bootstrap as the normal user so the
132
+ persistent background task retains access to that user's browser profile.
133
+ Bootstrap uses a single-use URL issued by Master:
132
134
 
133
135
  ```bash
134
136
  npm i -g arisa && arisa slave tcp://198.51.100.12:4719/arisa_secret_v1_<secret>
@@ -151,12 +153,31 @@ Automatic context compaction uses Pi's native implementation and can be tuned in
151
153
  "enabled": true,
152
154
  "reserveTokens": 120000,
153
155
  "keepRecentTokens": 20000
156
+ },
157
+ "sessionRotation": {
158
+ "enabled": true,
159
+ "compactAtPersistedBytes": 25165824,
160
+ "maxPersistedBytes": 33554432
161
+ },
162
+ "toolFanout": {
163
+ "enabled": true,
164
+ "maxConcurrent": 2,
165
+ "pressureConcurrent": 1,
166
+ "serializePercent": 60
167
+ },
168
+ "turnCoordinator": {
169
+ "enabled": true,
170
+ "backgroundQueueTtlMs": 600000,
171
+ "interactiveQueueTtlMs": 0,
172
+ "maxQueued": 100
154
173
  }
155
174
  }
156
175
  }
157
176
  ```
158
177
 
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.
178
+ 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.
179
+
180
+ 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
181
 
161
182
  ## Install globally
162
183
 
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.2.7",
3
+ "version": "5.2.19",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
8
8
  "arisa": "bin/arisa.js"
9
9
  },
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "bootstrap": "node src/index.js --bootstrap",
13
+ "test": "node --test"
14
+ },
10
15
  "keywords": [
11
16
  "telegram",
12
17
  "pi-agent",
@@ -29,7 +34,7 @@
29
34
  "author": "Martin Clasen",
30
35
  "license": "MIT",
31
36
  "engines": {
32
- "node": ">=22.8.0"
37
+ "node": ">=22.19.0"
33
38
  },
34
39
  "repository": {
35
40
  "type": "git",
@@ -39,14 +44,10 @@
39
44
  "url": "https://github.com/clasen/Arisa/issues"
40
45
  },
41
46
  "homepage": "https://arisa.sh",
47
+ "packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
42
48
  "dependencies": {
43
- "@earendil-works/pi-coding-agent": "0.80.6",
49
+ "@earendil-works/pi-coding-agent": "0.85.1",
44
50
  "@sinclair/typebox": "^0.34.41",
45
51
  "grammy": "^1.42.0"
46
- },
47
- "scripts": {
48
- "start": "node src/index.js",
49
- "bootstrap": "node src/index.js --bootstrap",
50
- "test": "node --test"
51
52
  }
52
- }
53
+ }
@@ -1,11 +1,13 @@
1
1
  allowBuilds:
2
2
  '@google/genai': false
3
+ esbuild: true
3
4
 
4
5
  minimumReleaseAgeExclude:
5
- - '@earendil-works/pi-agent-core@0.80.6'
6
- - '@earendil-works/pi-ai@0.80.6'
7
- - '@earendil-works/pi-coding-agent@0.80.6'
8
- - '@earendil-works/pi-tui@0.80.6'
6
+ - '@earendil-works/pi-agent-core@0.85.1'
7
+ - '@earendil-works/pi-ai@0.85.1'
8
+ - '@earendil-works/pi-coding-agent@0.85.1'
9
+ - '@earendil-works/pi-tui@0.85.1'
10
+ - '@earendil-works/chord@0.85.1'
9
11
 
10
12
  overrides:
11
13
  'node-fetch@2.7.0>whatwg-url': '13.0.0'
@@ -14,7 +16,7 @@ overrides:
14
16
  basic-ftp@<=5.2.2: '>=5.3.0'
15
17
  basic-ftp@<=5.3.0: '>=5.3.1'
16
18
  basic-ftp@=5.2.0: '>=5.2.1'
17
- brace-expansion@>=5.0.0 <5.0.6: '>=5.0.6'
19
+ brace-expansion@>=4.0.0 <5.0.9: '5.0.9'
18
20
  ip-address@<=10.1.0: '>=10.1.1'
19
21
  protobufjs@<7.5.5: '>=7.5.5'
20
22
  protobufjs@<=7.5.5: '>=7.5.6'
@@ -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,31 +267,77 @@ 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
- const { authStorage, modelRegistry } = createPiRuntime({
326
+ const modelRuntime = await createPiRuntime({
262
327
  provider: config.pi.provider,
263
328
  apiKey: config.pi.apiKey
264
329
  });
265
- const model = modelRegistry.find(config.pi.provider, config.pi.model);
330
+ const model = modelRuntime.getModel(config.pi.provider, config.pi.model);
266
331
  if (!model) {
267
332
  throw new Error(`Model not found: ${config.pi.provider}/${config.pi.model}`);
268
333
  }
269
- if (requiresProviderAuth(model) && !config.pi.apiKey && !hasProviderAuth(config.pi.provider, { authStorage, modelRegistry })) {
334
+ if (requiresProviderAuth(model) && !config.pi.apiKey && !hasProviderAuth(config.pi.provider, modelRuntime)) {
270
335
  throw new Error(`No auth found for ${config.pi.provider}. Provide a Pi API key in bootstrap, or authenticate with Pi login for this provider during bootstrap.`);
271
336
  }
272
337
 
273
338
  const settingsManager = createPiSettingsManager(config);
274
339
  const { session } = await createAgentSession({
275
- authStorage,
276
- modelRegistry,
340
+ modelRuntime,
277
341
  model,
278
342
  settingsManager,
279
343
  sessionManager: SessionManager.inMemory()
@@ -321,13 +385,13 @@ export class AgentManager {
321
385
  this.pendingNewSessions.add(sessionKey);
322
386
  }
323
387
 
324
- const { authStorage, modelRegistry } = createPiRuntime({
388
+ const modelRuntime = await createPiRuntime({
325
389
  provider: this.config.pi.provider,
326
390
  apiKey: this.config.pi.apiKey
327
391
  });
328
- const model = modelRegistry.find(this.config.pi.provider, effectiveModelId);
392
+ const model = modelRuntime.getModel(this.config.pi.provider, effectiveModelId);
329
393
  if (!model) throw new Error(`Model not found: ${this.config.pi.provider}/${effectiveModelId}`);
330
- if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
394
+ if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, modelRuntime)) {
331
395
  throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
332
396
  }
333
397
  const thinkingLevel = clampModelThinkingLevel(model, modelSelection.thinkingLevel);
@@ -353,7 +417,8 @@ export class AgentManager {
353
417
  initializeForumTopic: (...args) => telegramTarget.current.initializeForumTopic(...args),
354
418
  prepareRestartReceipt: (...args) => telegramTarget.current.prepareRestartReceipt(...args),
355
419
  cancelRestartReceipt: (...args) => telegramTarget.current.cancelRestartReceipt(...args),
356
- getTaskContext: (...args) => telegramTarget.current.getTaskContext?.(...args) || null
420
+ getTaskContext: (...args) => telegramTarget.current.getTaskContext?.(...args) || null,
421
+ getAgentTaskExecution: (...args) => telegramTarget.current.getAgentTaskExecution?.(...args) || null
357
422
  };
358
423
  const assertAccess = () => accessGuardTarget.current();
359
424
  const customTools = guardTools([
@@ -375,8 +440,7 @@ export class AgentManager {
375
440
  cwd: policy.workspaceDir,
376
441
  agentDir: arisaHomeDir,
377
442
  resourceLoader,
378
- authStorage,
379
- modelRegistry,
443
+ modelRuntime,
380
444
  model,
381
445
  thinkingLevel,
382
446
  tools: policy.tools,
@@ -385,8 +449,8 @@ export class AgentManager {
385
449
  settingsManager,
386
450
  sessionManager
387
451
  });
388
- const speedController = createModelSpeedController(session.agent.streamFn, speed);
389
- session.agent.streamFn = speedController.streamFn;
452
+ const speedController = createModelSpeedController(session.agent.streamFunction, speed);
453
+ session.agent.streamFunction = speedController.streamFn;
390
454
 
391
455
  if (!hasExistingSession) {
392
456
  this.logger?.log("agent", `created new session for chat ${sessionKey}`);
@@ -402,8 +466,11 @@ export class AgentManager {
402
466
  modelKey: effectiveModelKey,
403
467
  speedController,
404
468
  telegramTarget,
405
- accessGuardTarget
469
+ accessGuardTarget,
470
+ rotationCheckPromise: Promise.resolve(),
471
+ rotationRequest: null
406
472
  };
473
+ session.subscribe((event) => this.scheduleCompactionRotationCheck(sessionKey, ctx, event));
407
474
  this.sessions.set(sessionKey, ctx);
408
475
  if (isNewSession) this.sessionLifecycle.completeNewSession(sessionKey);
409
476
  return this.acquireSessionContext(sessionKey, ctx);
@@ -411,7 +478,7 @@ export class AgentManager {
411
478
 
412
479
  async getAvailableModels(chatId) {
413
480
  const { listProviderModels } = await import("./pi-runtime.js");
414
- const runtime = createPiRuntime({ provider: this.config.pi.provider, apiKey: this.config.pi.apiKey });
481
+ const runtime = await createPiRuntime({ provider: this.config.pi.provider, apiKey: this.config.pi.apiKey });
415
482
  return listProviderModels(this.config.pi.provider, runtime);
416
483
  }
417
484
 
@@ -424,9 +491,15 @@ export class AgentManager {
424
491
  }
425
492
 
426
493
  async close() {
494
+ this.turnCoordinator.close();
427
495
  await this.sessionLifecycle.closeAll();
428
496
  }
429
497
 
498
+ async runTurn(options, work) {
499
+ if (typeof work !== "function") throw new Error("Agent turn work is required");
500
+ return this.turnCoordinator.run(options, work);
501
+ }
502
+
430
503
  async runTool({ name, request, chatId, taskContext = null }) {
431
504
  await this.toolRegistry.load();
432
505
  this.logger?.log("agent", `run_tool ${name}`);
@@ -453,7 +526,8 @@ export class AgentManager {
453
526
  telegram,
454
527
  chatId,
455
528
  policy,
456
- logger: this.logger
529
+ logger: this.logger,
530
+ toolFanout: this.toolFanout
457
531
  });
458
532
  }
459
533
 
@@ -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
+ }
@@ -33,8 +33,8 @@ export function getPiAuthIssue(error) {
33
33
  return null;
34
34
  }
35
35
 
36
- export function getPiAuthStatus(config, chatId = null) {
37
- const runtime = createPiRuntime({
36
+ export async function getPiAuthStatus(config, chatId = null) {
37
+ const runtime = await createPiRuntime({
38
38
  provider: config.pi.provider,
39
39
  apiKey: config.pi.apiKey
40
40
  });
@@ -51,8 +51,8 @@ export function getPiAuthStatus(config, chatId = null) {
51
51
  };
52
52
  }
53
53
 
54
- export function buildPiAuthTelegramMessage({ config, chatId = null, issue = null, verified = false }) {
55
- const status = getPiAuthStatus(config, chatId);
54
+ export async function buildPiAuthTelegramMessage({ config, chatId = null, issue = null, verified = false }) {
55
+ const status = await getPiAuthStatus(config, chatId);
56
56
  let title = `Pi authentication status for ${status.provider}/${status.model}.`;
57
57
  if (issue) {
58
58
  title = `Pi authentication needs attention for ${status.provider}/${status.model}.`;
@@ -95,8 +95,8 @@ export function buildPiAuthTelegramMessage({ config, chatId = null, issue = null
95
95
  return lines.join("\n");
96
96
  }
97
97
 
98
- export function buildPiAuthRecoveryBlockedMessage({ config, chatId = null, issue = null, renewalActive = false }) {
99
- const status = getPiAuthStatus(config, chatId);
98
+ export async function buildPiAuthRecoveryBlockedMessage({ config, chatId = null, issue = null, renewalActive = false }) {
99
+ const status = await getPiAuthStatus(config, chatId);
100
100
  const lines = [
101
101
  `Pi authentication is not ready for ${status.provider}/${status.model}.`,
102
102
  "I did not send your message to the agent."
@@ -1,4 +1,4 @@
1
- export const MODEL_SPEEDS = Object.freeze([1, 1.5]);
1
+ export const MODEL_SPEEDS = Object.freeze([1, 1.5]);
2
2
 
3
3
  export function normalizeModelSpeed(speed) {
4
4
  const value = Number(speed);
@@ -17,6 +17,7 @@ export function modelSupportsSpeed(model) {
17
17
  || model.id === "gpt-5.5"
18
18
  || model.id === "gpt-5.6"
19
19
  || model.id.startsWith("gpt-5.6-")
20
+ || model.id === "gpt-6-astra"
20
21
  );
21
22
  }
22
23
 
@@ -40,6 +41,7 @@ export function createModelSpeedController(streamFn, initialSpeed) {
40
41
  speed = normalizeModelSpeed(nextSpeed);
41
42
  },
42
43
  streamFn(model, context, options) {
44
+ if (!modelSupportsSpeed(model)) return streamFn(model, context, options);
43
45
  const serviceTier = speedToServiceTier(speed);
44
46
  const onPayload = options?.onPayload;
45
47
  return streamFn(model, context, {