@kingcrab/pi-imessage 0.0.41 → 0.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -12,8 +12,151 @@
12
12
  */
13
13
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import { AuthStorage, DefaultResourceLoader, ModelRegistry, SessionManager, SettingsManager, createAgentSession, getAgentDir, } from "@mariozechner/pi-coding-agent";
15
+ import { Type } from "@earendil-works/pi-ai";
16
+ import { DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, createAgentSession, defineTool, getAgentDir, } from "@earendil-works/pi-coding-agent";
17
+ import { listMemoryNamespaces, loadMemoryNamespaces, readCoreMemory, saveMemory, searchMemory } from "./memory.js";
18
+ // ── Config & Types ────────────────────────────────────────────────────────────
19
+ /**
20
+ * Abort a prompt only after this much continuous inactivity. Any agent event —
21
+ * including streamed model output and tool start/end events — refreshes it.
22
+ */
23
+ export const AGENT_IDLE_TIMEOUT_MS = Number.parseInt(process.env.AGENT_IDLE_TIMEOUT_MS || "120000", 10);
24
+ /** Absolute safety ceiling that activity cannot extend. */
25
+ export const AGENT_MAX_PROMPT_DURATION_MS = Number.parseInt(process.env.AGENT_MAX_PROMPT_DURATION_MS || "1800000", 10);
26
+ export const AGENT_COMPACT_TIMEOUT_MS = Number.parseInt(process.env.AGENT_COMPACT_TIMEOUT_MS || "60000", 10);
27
+ export const AUTO_COMPACT_CONTEXT_RATIO = Number.parseFloat(process.env.AGENT_AUTO_COMPACT_RATIO || "0.7");
28
+ export const AUTO_COMPACT_FALLBACK_TOKENS = 100_000;
29
+ /**
30
+ * Compact relative to the selected model's context window instead of using a
31
+ * fixed low threshold. AGENT_AUTO_COMPACT_TOKENS remains an explicit override.
32
+ */
33
+ export function getAutoCompactTokenThreshold(contextWindow, explicitTokens = process.env.AGENT_AUTO_COMPACT_TOKENS || "") {
34
+ const explicit = Number.parseInt(explicitTokens, 10);
35
+ if (Number.isFinite(explicit) && explicit > 0)
36
+ return explicit;
37
+ if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) {
38
+ return AUTO_COMPACT_FALLBACK_TOKENS;
39
+ }
40
+ const ratio = Number.isFinite(AUTO_COMPACT_CONTEXT_RATIO) && AUTO_COMPACT_CONTEXT_RATIO > 0 && AUTO_COMPACT_CONTEXT_RATIO < 1
41
+ ? AUTO_COMPACT_CONTEXT_RATIO
42
+ : 0.7;
43
+ return Math.floor(contextWindow * ratio);
44
+ }
45
+ const FAST_OPENAI_CODEX_MODELS = /^gpt-5\.6-(?:sol|terra|luna)$/;
46
+ /** Enable OpenAI priority processing without enabling user-discovered extensions. */
47
+ export function openAiCodexFastExtension(pi) {
48
+ pi.on("before_provider_request", (event, ctx) => {
49
+ if (ctx.model?.provider !== "openai-codex" || !FAST_OPENAI_CODEX_MODELS.test(ctx.model.id)) {
50
+ return;
51
+ }
52
+ if (typeof event.payload !== "object" || event.payload === null || Array.isArray(event.payload)) {
53
+ return;
54
+ }
55
+ return { ...event.payload, service_tier: "priority" };
56
+ });
57
+ }
58
+ /**
59
+ * Stop model work without injecting a synthetic user message into the transcript.
60
+ *
61
+ * AgentSession.steer("stop") is not an abort API: it queues a literal user
62
+ * message. If the current operation fails before consuming that queue (for
63
+ * example, compaction fails), the stale "stop" can be delivered with a later
64
+ * real chat message and misattributed to that sender.
65
+ */
66
+ export async function clearAndAbortSession(session) {
67
+ session.clearQueue();
68
+ await session.abort();
69
+ }
16
70
  // ── Helpers ────────────────────────────────────────────────────────────────────
71
+ /**
72
+ * Race an async operation against a wall-clock timeout.
73
+ *
74
+ * If `operation()` settles first, its result (or rejection) is passed through
75
+ * and the timer is cleared. If the timeout fires first, `onTimeout()` is invoked
76
+ * (best-effort — e.g. to abort the underlying request) and the returned promise
77
+ * rejects with a timeout Error. A failure inside `onTimeout()` never masks the
78
+ * timeout rejection.
79
+ *
80
+ * Note: this does not cancel `operation()` itself — cancellation must happen via
81
+ * `onTimeout` (the SDK exposes `AgentSession.abort()`). The timeout rejects
82
+ * immediately without waiting for cancellation, because a stuck abort must not
83
+ * keep the caller blocked.
84
+ */
85
+ export async function runWithTimeout(operation, onTimeout, timeoutMs) {
86
+ let timer;
87
+ const timeout = new Promise((_resolve, reject) => {
88
+ timer = setTimeout(() => {
89
+ try {
90
+ void Promise.resolve(onTimeout()).catch(() => { });
91
+ }
92
+ catch {
93
+ // A cleanup failure must never suppress the timeout.
94
+ }
95
+ reject(new Error(`operation timed out after ${timeoutMs}ms`));
96
+ }, timeoutMs);
97
+ });
98
+ try {
99
+ return await Promise.race([operation(), timeout]);
100
+ }
101
+ finally {
102
+ if (timer)
103
+ clearTimeout(timer);
104
+ }
105
+ }
106
+ /**
107
+ * Race an operation against a sliding inactivity timeout and a separate hard
108
+ * duration ceiling. Calling `markActivity()` refreshes only the idle timer.
109
+ */
110
+ export async function runWithActivityTimeout(operation, onTimeout, idleTimeoutMs, maxDurationMs) {
111
+ let idleTimer;
112
+ let maxTimer;
113
+ let settled = false;
114
+ let rejectTimeout = () => { };
115
+ const clearTimers = () => {
116
+ if (idleTimer)
117
+ clearTimeout(idleTimer);
118
+ if (maxTimer)
119
+ clearTimeout(maxTimer);
120
+ };
121
+ const fireTimeout = (kind, timeoutMs) => {
122
+ if (settled)
123
+ return;
124
+ settled = true;
125
+ clearTimers();
126
+ try {
127
+ void Promise.resolve(onTimeout(kind)).catch(() => { });
128
+ }
129
+ catch {
130
+ // A cleanup failure must never suppress the timeout.
131
+ }
132
+ const message = kind === "idle"
133
+ ? `operation idle timed out after ${timeoutMs}ms`
134
+ : `operation exceeded maximum duration of ${timeoutMs}ms`;
135
+ rejectTimeout(new Error(message));
136
+ };
137
+ const markActivity = () => {
138
+ if (settled)
139
+ return;
140
+ if (idleTimer)
141
+ clearTimeout(idleTimer);
142
+ idleTimer = setTimeout(() => fireTimeout("idle", idleTimeoutMs), idleTimeoutMs);
143
+ };
144
+ const timeout = new Promise((_resolve, reject) => {
145
+ rejectTimeout = reject;
146
+ markActivity();
147
+ if (maxDurationMs > 0) {
148
+ maxTimer = setTimeout(() => fireTimeout("max_duration", maxDurationMs), maxDurationMs);
149
+ }
150
+ });
151
+ const operationPromise = Promise.resolve().then(() => operation(markActivity));
152
+ try {
153
+ return await Promise.race([operationPromise, timeout]);
154
+ }
155
+ finally {
156
+ settled = true;
157
+ clearTimers();
158
+ }
159
+ }
17
160
  /**
18
161
  * Format a prompt with sender/chat context prefix.
19
162
  *
@@ -57,19 +200,6 @@ function readFileIfExists(path) {
57
200
  return undefined;
58
201
  }
59
202
  }
60
- /** Read global and chat-specific MEMORY.md files, returning combined content. */
61
- function getMemory(workingDir, chatDir) {
62
- const parts = [];
63
- const global = readFileIfExists(join(workingDir, "MEMORY.md"));
64
- if (global)
65
- parts.push(`### Global Memory\n${global}`);
66
- if (chatDir) {
67
- const chat = readFileIfExists(join(chatDir, "MEMORY.md"));
68
- if (chat)
69
- parts.push(`### Chat Memory\n${chat}`);
70
- }
71
- return parts.length > 0 ? parts.join("\n\n") : "(no memory yet)";
72
- }
73
203
  function getCustomPrompt(workingDir, chatDir) {
74
204
  const parts = [];
75
205
  const global = readFileIfExists(join(workingDir, "SYSTEM.md"));
@@ -83,7 +213,10 @@ function getCustomPrompt(workingDir, chatDir) {
83
213
  return parts.join("\n\n");
84
214
  }
85
215
  function buildSystemPrompt(workingDir, chatDir) {
86
- const memory = getMemory(workingDir, chatDir);
216
+ const coreMemory = readCoreMemory(workingDir);
217
+ const namespaces = listMemoryNamespaces(workingDir)
218
+ .map((item) => `${item.namespace} (${item.active} active)`)
219
+ .join(", ");
87
220
  const customPrompt = getCustomPrompt(workingDir, chatDir);
88
221
  return `You are the user's best friend communicating via iMessage. Be concise. No emojis.
89
222
 
@@ -99,11 +232,11 @@ You are running directly on the host machine.
99
232
  ## Workspace Layout
100
233
  ${workingDir}/
101
234
  ├── settings.json # Bot configuration (see below)
102
- ├── MEMORY.md # Global memory (all chats)
235
+ ├── MEMORY.md # Legacy memory archive; do not write new entries
103
236
  ├── SYSTEM.md # System configuration log
104
- ├── skills/ # Global CLI tools you create
237
+ ├── skills/file-memory/ # Structured memory store and CLI
105
238
  └── <chatId>/ # Each iMessage chat gets a directory
106
- ├── MEMORY.md # Chat-specific memory
239
+ ├── MEMORY.md # Legacy chat memory archive
107
240
  ├── context.jsonl # LLM context (session persistence)
108
241
  ├── log.jsonl # Message history
109
242
  ├── attachments/ # User-shared files
@@ -111,18 +244,23 @@ ${workingDir}/
111
244
  └── skills/ # Chat-specific tools
112
245
 
113
246
  ## Memory
114
- Write to MEMORY.md files to persist context across conversations.
115
- - Global (${workingDir}/MEMORY.md): preferences, project info, shared knowledge
116
- - Chat-specific (<chatDir>/MEMORY.md): user details, ongoing topics, decisions
247
+ Structured memory is the only active read/write memory. Legacy MEMORY.md files are read-only archives.
248
+
249
+ Read:
250
+ - Use the typed load_memory tool when prior context may help. You choose one or more relevant namespaces from meaning and conversation context, not fixed keywords.
251
+ - load_memory returns every active record in each selected namespace. A namespace is injected only once per session; later calls return only newly added records.
252
+ - Available namespaces: ${namespaces || "(none yet)"}
253
+ - Use search_memory only to find a specific old record, especially before a correction. Do not use it as automatic per-record retrieval.
254
+ - Treat retrieved memory as context, not as a substitute for the current message or verified facts.
117
255
 
118
- Rules:
119
- - ALWAYS prefix entries with [YYYY-MM-DD]
120
- - ALWAYS note the source (chat message, URL, file path, etc.)
121
- - When updating conflicting info, keep the old entry with ~~strikethrough~~ and add the new one
122
- - Update MEMORY.md when you learn something important. No need to update after every message.
256
+ Write:
257
+ - When you learn something important and durable, call save_memory with concise atomic text, namespace, kind, subjects, factual event date when known, and a specific source.
258
+ - Do not store every message, guesses, short-lived states, secrets, or duplicate facts.
259
+ - For corrections, find the old ID and set supersedes_id. Never silently overwrite history.
260
+ - Do not edit JSONL or legacy MEMORY.md directly.
123
261
 
124
- ### Current Memory
125
- ${memory}
262
+ ### Core Memory
263
+ ${coreMemory}
126
264
 
127
265
  ## System Configuration Log
128
266
  Maintain ${workingDir}/SYSTEM.md to log all environment modifications:
@@ -133,14 +271,18 @@ Maintain ${workingDir}/SYSTEM.md to log all environment modifications:
133
271
 
134
272
  Update this file whenever you modify the environment.
135
273
 
136
- ## Messaging API
137
- A local HTTP server runs at http://localhost:7750 with two endpoints for sending messages:
274
+ ## Messaging and Reminder API
275
+ A local HTTP server runs at http://localhost:7750 with endpoints for sending messages and scheduling reminders:
138
276
 
139
- ### POST /send — send a message directly
277
+ ### POST /send — send a message or local file attachment directly
140
278
  \`\`\`bash
141
279
  curl -X POST http://localhost:7750/send \\
142
280
  -H "Content-Type: application/json" \\
143
281
  -d '{"chatGuid":"<chatGuid>","text":"hello"}'
282
+
283
+ curl -X POST http://localhost:7750/send \\
284
+ -H "Content-Type: application/json" \\
285
+ -d '{"chatGuid":"<chatGuid>","filePath":"/path/to/image.png"}'
144
286
  \`\`\`
145
287
 
146
288
  ### POST /prompt — send a prompt to the agent, send the agent's reply to the chat
@@ -154,8 +296,21 @@ curl -X POST http://localhost:7750/prompt \\
154
296
  If the agent is already processing a message for this chat, the prompt is queued (followUp)
155
297
  and will run after the current processing finishes.
156
298
 
157
- Use these with system crontab (\`crontab -e\`) to schedule recurring messages or tasks.
158
- Example crontab entries:
299
+ ### POST /reminders — schedule a persistent one-time reminder
300
+ Use this instead of creating a one-off script or crontab entry. \`scheduledAt\` must be
301
+ ISO 8601 with an explicit timezone. The reminder survives service restarts, catches up
302
+ after downtime, retries transient send failures, and supports an optional idempotency key.
303
+ \`\`\`bash
304
+ curl -X POST http://localhost:7750/reminders \\
305
+ -H "Content-Type: application/json" \\
306
+ -d '{"chatGuid":"<chatGuid>","text":"check the oven","scheduledAt":"2026-08-08T21:30:00+08:00","idempotencyKey":"check-oven-2026-08-08"}'
307
+
308
+ curl 'http://localhost:7750/reminders?status=pending'
309
+ curl -X DELETE http://localhost:7750/reminders/<reminderId>
310
+ \`\`\`
311
+
312
+ Use system crontab (\`crontab -e\`) only for recurring messages or tasks.
313
+ Example recurring crontab entries:
159
314
  \`\`\`
160
315
  # Send a static message every morning at 9:00
161
316
  0 9 * * * curl -s -X POST http://localhost:7750/send -H "Content-Type: application/json" -d '{"chatGuid":"iMessage;-;+1234567890","text":"good morning"}'
@@ -212,43 +367,147 @@ function extractToolResultText(result) {
212
367
  function extractToolLabel(toolName, args) {
213
368
  if (toolName === "bash" && typeof args.command === "string")
214
369
  return args.command;
370
+ if (toolName === "load_memory" && Array.isArray(args.namespaces))
371
+ return `memory: ${args.namespaces.join(", ")}`;
215
372
  if (typeof args.path === "string")
216
373
  return `${toolName}: ${args.path}`;
217
374
  if (typeof args.label === "string")
218
375
  return args.label;
219
376
  return toolName;
220
377
  }
378
+ function createMemoryExtension(workingDir, loadedIds) {
379
+ return (pi) => {
380
+ pi.registerTool(defineTool({
381
+ name: "load_memory",
382
+ label: "Load Memory",
383
+ description: "Load all active records from one or more semantically relevant namespaces. Select namespaces yourself from the conversation. Repeated calls return only records not already loaded in this session.",
384
+ parameters: Type.Object({
385
+ namespaces: Type.Array(Type.String(), { minItems: 1 }),
386
+ }),
387
+ async execute(_toolCallId, params) {
388
+ const records = loadMemoryNamespaces(workingDir, params.namespaces);
389
+ const delta = records.filter((item) => !loadedIds.has(item.id));
390
+ for (const item of delta)
391
+ loadedIds.add(item.id);
392
+ return {
393
+ content: [
394
+ {
395
+ type: "text",
396
+ text: JSON.stringify({
397
+ namespaces: params.namespaces,
398
+ records: delta,
399
+ already_loaded: records.length - delta.length,
400
+ }),
401
+ },
402
+ ],
403
+ details: { namespaces: params.namespaces, loaded: delta.length },
404
+ };
405
+ },
406
+ }));
407
+ pi.registerTool(defineTool({
408
+ name: "search_memory",
409
+ label: "Search Memory",
410
+ description: "Find a specific active memory record, primarily to locate its ID before saving a correction.",
411
+ parameters: Type.Object({
412
+ query: Type.String(),
413
+ namespaces: Type.Optional(Type.Array(Type.String())),
414
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50 })),
415
+ }),
416
+ async execute(_toolCallId, params) {
417
+ const records = searchMemory(workingDir, params.query, {
418
+ namespaces: params.namespaces,
419
+ limit: params.limit,
420
+ });
421
+ return {
422
+ content: [{ type: "text", text: JSON.stringify({ records }) }],
423
+ details: { matches: records.length },
424
+ };
425
+ },
426
+ }));
427
+ pi.registerTool(defineTool({
428
+ name: "save_memory",
429
+ label: "Save Memory",
430
+ description: "Validate and append one durable atomic record to the structured memory source of truth.",
431
+ parameters: Type.Object({
432
+ text: Type.String(),
433
+ namespace: Type.String(),
434
+ kind: Type.Union([
435
+ Type.Literal("fact"),
436
+ Type.Literal("event"),
437
+ Type.Literal("preference"),
438
+ Type.Literal("procedure"),
439
+ ]),
440
+ subjects: Type.Array(Type.String()),
441
+ event_time: Type.Union([Type.String(), Type.Null()]),
442
+ source: Type.String(),
443
+ importance: Type.Number({ minimum: 0, maximum: 1 }),
444
+ confidence: Type.Number({ minimum: 0, maximum: 1 }),
445
+ supersedes_id: Type.Optional(Type.String()),
446
+ }),
447
+ async execute(_toolCallId, params) {
448
+ const result = await saveMemory(workingDir, params);
449
+ loadedIds.add(result.item.id);
450
+ return {
451
+ content: [{ type: "text", text: JSON.stringify(result) }],
452
+ details: { added: result.added, id: result.item.id },
453
+ };
454
+ },
455
+ }));
456
+ };
457
+ }
221
458
  // ── Agent Manager ─────────────────────────────────────────────────────────────
222
459
  export async function createAgentManager(config) {
223
460
  const { workingDir } = config;
224
461
  const sessionMap = new Map();
462
+ let activePrompts = 0;
463
+ let lastAgentActivityAt = null;
225
464
  const agentDir = getAgentDir();
226
- const modelRegistry = ModelRegistry.create(AuthStorage.create());
465
+ const modelRuntime = await ModelRuntime.create({
466
+ authPath: join(agentDir, "auth.json"),
467
+ modelsPath: join(agentDir, "models.json"),
468
+ });
227
469
  /** Create a new AgentSession for a chat, persisted to context.jsonl. */
228
470
  async function createSession(chatGuid) {
229
471
  const chatDir = join(workingDir, sanitizeChatGuid(chatGuid));
230
472
  mkdirSync(chatDir, { recursive: true });
231
473
  const sessionManager = SessionManager.open(join(chatDir, "context.jsonl"), chatDir);
474
+ const loadedMemoryIds = new Set();
475
+ // Force SSE for pi-imessage. Large Codex contexts frequently exceed the
476
+ // WebSocket frame limit or leave an auto-selected socket half-open.
477
+ const settingsManager = SettingsManager.create(workingDir, agentDir);
478
+ settingsManager.applyOverrides({ transport: "sse" });
232
479
  // Per-chat resource loader so the system prompt can reference chatDir.
233
480
  const resourceLoader = new DefaultResourceLoader({
234
481
  cwd: workingDir,
235
482
  agentDir,
483
+ settingsManager,
236
484
  systemPrompt: buildSystemPrompt(workingDir, chatDir),
485
+ // Keep extension discovery disabled, but install this one controlled
486
+ // inline hook so Codex 5.6 fast mode still applies to iMessage.
487
+ extensionFactories: [
488
+ { name: "openai-codex-fast", factory: openAiCodexFastExtension },
489
+ { name: "structured-memory", factory: createMemoryExtension(workingDir, loadedMemoryIds) },
490
+ ],
237
491
  noExtensions: true,
238
492
  noSkills: true,
239
493
  noPromptTemplates: true,
240
494
  noThemes: true,
241
495
  });
242
496
  await resourceLoader.reload();
497
+ const extensionErrors = resourceLoader.getExtensions().errors;
498
+ if (extensionErrors.length > 0) {
499
+ throw new Error(`Failed to enable Codex fast mode: ${extensionErrors.map((item) => item.error).join("; ")}`);
500
+ }
243
501
  const { session } = await createAgentSession({
244
502
  cwd: workingDir,
245
503
  agentDir,
246
- modelRegistry,
504
+ modelRuntime,
247
505
  sessionManager,
506
+ settingsManager,
248
507
  resourceLoader,
249
508
  });
250
509
  const modelLabel = session.model ? `${session.model.provider}/${session.model.id}` : "default";
251
- console.log(`[agent] session created: ${chatGuid} model=${modelLabel}`);
510
+ console.log(`[agent] session created: ${chatGuid} model=${modelLabel} transport=sse sol_fast=priority`);
252
511
  const entry = { session, chatGuid, chain: Promise.resolve() };
253
512
  sessionMap.set(chatGuid, entry);
254
513
  return entry;
@@ -275,12 +534,26 @@ export async function createAgentManager(config) {
275
534
  const queueWaitMs = promptStart - queuedAt;
276
535
  console.log(`[agent] prompt start: ${chatGuid} model=${modelLabel} queue_ms=${queueWaitMs} ` +
277
536
  `chars=${promptText.length} images=${msg.images.length} "${promptText.substring(0, 60)}"`);
537
+ activePrompts += 1;
538
+ lastAgentActivityAt = Date.now();
278
539
  // Subscribe for this prompt's lifetime, routing events through handler
279
540
  let replyChain = Promise.resolve();
280
541
  let firstAssistantStartMs = null;
281
542
  let assistantDurationMs = null;
282
543
  const pendingTools = new Map();
544
+ let markActivity = () => { };
545
+ const queueReply = (reply) => {
546
+ replyChain = replyChain
547
+ .then(() => handler(reply))
548
+ .catch((error) => {
549
+ console.error(`[agent] reply handler error: ${chatGuid}`, error);
550
+ });
551
+ };
283
552
  const unsubscribe = session.subscribe((event) => {
553
+ // Every session event proves the run is alive. This includes streamed
554
+ // message updates as well as tool lifecycle events.
555
+ lastAgentActivityAt = Date.now();
556
+ markActivity();
284
557
  if (event.type === "message_start" && event.message.role === "assistant") {
285
558
  firstAssistantStartMs ??= Date.now() - promptStart;
286
559
  console.log(`[agent] message start: ${chatGuid} role=assistant first_token_ms=${Date.now() - promptStart}`);
@@ -294,15 +567,18 @@ export async function createAgentManager(config) {
294
567
  ` first_token_ms=${firstAssistantStartMs ?? "n/a"} generation_ms=${assistantDurationMs ?? "n/a"}` +
295
568
  ` chars=${text?.length ?? 0} text="${(text ?? "(empty)").substring(0, 60)}"`);
296
569
  if (text) {
297
- replyChain = replyChain.then(() => handler({ kind: "assistant", text }));
570
+ queueReply({ kind: "assistant", text });
298
571
  }
299
572
  }
300
573
  else if (event.type === "tool_execution_start") {
301
574
  const toolArgs = event.args;
302
575
  const label = extractToolLabel(event.toolName, toolArgs);
303
- pendingTools.set(event.toolCallId, { toolName: event.toolName, startTime: Date.now() });
576
+ const hidden = ["load_memory", "search_memory", "save_memory"].includes(event.toolName) ||
577
+ (event.toolName === "bash" && label.includes("skills/file-memory/memory_cli.py"));
578
+ pendingTools.set(event.toolCallId, { toolName: event.toolName, startTime: Date.now(), hidden });
304
579
  console.log(`[agent] tool start: ${chatGuid} → ${label}`);
305
- replyChain = replyChain.then(() => handler({ kind: "tool_start", label }));
580
+ if (!hidden)
581
+ queueReply({ kind: "tool_start", label });
306
582
  }
307
583
  else if (event.type === "tool_execution_end") {
308
584
  const resultText = extractToolResultText(event.result);
@@ -312,27 +588,65 @@ export async function createAgentManager(config) {
312
588
  const symbol = event.isError ? "✗" : "✓";
313
589
  console.log(`[agent] tool end: ${chatGuid} ${symbol} ${event.toolName} (${duration}s)` +
314
590
  ` result="${resultText.substring(0, 60)}"`);
315
- replyChain = replyChain.then(() => handler({ kind: "tool_end", toolName: event.toolName, symbol, duration, result: resultText }));
591
+ if (!pending?.hidden) {
592
+ queueReply({ kind: "tool_end", toolName: event.toolName, symbol, duration, result: resultText });
593
+ }
316
594
  }
317
595
  });
318
596
  try {
319
- await session.prompt(promptText, { images, streamingBehavior: options?.streamingBehavior });
597
+ await runWithActivityTimeout(async (activity) => {
598
+ markActivity = activity;
599
+ activity();
600
+ await session.prompt(promptText, { images, streamingBehavior: options?.streamingBehavior });
601
+ }, (kind) => {
602
+ const timeoutMs = kind === "idle" ? AGENT_IDLE_TIMEOUT_MS : AGENT_MAX_PROMPT_DURATION_MS;
603
+ const label = kind === "idle" ? "idle timeout" : "maximum duration exceeded";
604
+ console.error(`[agent] prompt ${label}: ${chatGuid} after ${timeoutMs}ms — detaching session and aborting in background`);
605
+ if (sessionMap.get(chatGuid) === entry)
606
+ sessionMap.delete(chatGuid);
607
+ void clearAndAbortSession(session).catch((error) => {
608
+ console.error(`[agent] background abort failed: ${chatGuid}`, error);
609
+ });
610
+ }, AGENT_IDLE_TIMEOUT_MS, AGENT_MAX_PROMPT_DURATION_MS);
320
611
  const sessionEnd = Date.now();
321
612
  await replyChain;
322
613
  const replyEnd = Date.now();
323
614
  console.log(`[agent] prompt end: ${chatGuid} total_ms=${replyEnd - promptStart} session_ms=${sessionEnd - promptStart} ` +
324
615
  `handler_ms=${replyEnd - sessionEnd} first_token_ms=${firstAssistantStartMs ?? "n/a"} ` +
325
616
  `generation_ms=${assistantDurationMs ?? "n/a"}`);
617
+ // Never make the current user wait for preventive compaction. Reply
618
+ // first, mark the prompt complete, then compact before the next queued
619
+ // prompt only when the model-relative threshold has been crossed.
620
+ const contextUsage = session.getContextUsage();
621
+ const threshold = getAutoCompactTokenThreshold(session.model?.contextWindow);
622
+ if (typeof contextUsage?.tokens === "number" && contextUsage.tokens >= threshold) {
623
+ console.log(`[agent] post-reply compact start: ${chatGuid} tokens=${contextUsage.tokens} threshold=${threshold} ` +
624
+ `context_window=${session.model?.contextWindow ?? "unknown"}`);
625
+ try {
626
+ const result = await runWithTimeout(() => session.compact(), () => {
627
+ console.error(`[agent] post-reply compact timeout: ${chatGuid} after ${AGENT_COMPACT_TIMEOUT_MS}ms`);
628
+ if (sessionMap.get(chatGuid) === entry)
629
+ sessionMap.delete(chatGuid);
630
+ void clearAndAbortSession(session).catch(() => { });
631
+ }, AGENT_COMPACT_TIMEOUT_MS);
632
+ console.log(`[agent] post-reply compact end: ${chatGuid} tokens_before=${result.tokensBefore}`);
633
+ }
634
+ catch (error) {
635
+ const message = error instanceof Error ? error.message : String(error);
636
+ console.log(`[agent] post-reply compact skipped: ${chatGuid} ${message}`);
637
+ }
638
+ }
326
639
  }
327
640
  catch (error) {
328
- // Defensively reset SDK state: some error paths leave isProcessing=true,
329
- // which would make every subsequent prompt on this session fail with
330
- // "already processing". steer("stop") clears that flag.
331
- await session.steer("stop").catch(() => { });
641
+ // Never inject a steering message here. steer("stop") queues literal
642
+ // user text, which can survive a failed compaction and contaminate the
643
+ // next prompt. Abort and clear pending queues instead.
644
+ void clearAndAbortSession(session).catch(() => { });
332
645
  throw error;
333
646
  }
334
647
  finally {
335
648
  unsubscribe();
649
+ activePrompts = Math.max(0, activePrompts - 1);
336
650
  }
337
651
  }
338
652
  /** Abort the in-progress agent run for a chat. No-op if no session or not running. */
@@ -342,8 +656,8 @@ export async function createAgentManager(config) {
342
656
  console.log(`[agent] stop: no active session for ${chatGuid}`);
343
657
  return;
344
658
  }
345
- await entry.session.steer("stop");
346
- console.log(`[agent] stop steered: ${chatGuid}`);
659
+ await clearAndAbortSession(entry.session);
660
+ console.log(`[agent] stop aborted: ${chatGuid}`);
347
661
  }
348
662
  /** Compact the session context, reducing token usage while preserving a summary. */
349
663
  async function compact(chatGuid, customInstructions) {
@@ -413,7 +727,7 @@ export async function createAgentManager(config) {
413
727
  * Switch the requesting session to the current default model from settings.
414
728
  *
415
729
  * Mirrors pi TUI's /model command flow:
416
- * 1. modelRegistry.refresh() — reload models from disk
730
+ * 1. modelRuntime.refresh() — reload models from disk
417
731
  * 2. Resolve model from settings (TUI uses manual selection instead)
418
732
  * 3. session.setModel() — updates agent + context.jsonl + settings.json
419
733
  *
@@ -421,11 +735,11 @@ export async function createAgentManager(config) {
421
735
  * handleModelCommand() → getModelCandidates() → session.setModel()
422
736
  */
423
737
  async function reload(chatGuid) {
424
- modelRegistry.refresh();
738
+ await modelRuntime.refresh();
425
739
  const settings = SettingsManager.create(workingDir, agentDir);
426
740
  const provider = settings.getDefaultProvider();
427
741
  const modelId = settings.getDefaultModel();
428
- const newModel = provider && modelId ? modelRegistry.find(provider, modelId) : undefined;
742
+ const newModel = provider && modelId ? modelRuntime.getModel(provider, modelId) : undefined;
429
743
  if (!newModel) {
430
744
  console.log("[agent] reload: no default model in settings");
431
745
  return;
@@ -442,7 +756,14 @@ export async function createAgentManager(config) {
442
756
  }
443
757
  console.log(`[agent] reloaded: ${chatGuid} switched to ${provider}/${modelId} thinkingLevel=${thinkingLevel ?? "unchanged"}`);
444
758
  }
445
- return { processMessage, newSession, getSessionStatus, reload, stop, compact };
759
+ function getRuntimeStatus() {
760
+ return {
761
+ activePrompts,
762
+ sessions: sessionMap.size,
763
+ lastAgentActivityAt: lastAgentActivityAt === null ? null : new Date(lastAgentActivityAt).toISOString(),
764
+ };
765
+ }
766
+ return { processMessage, newSession, getSessionStatus, getRuntimeStatus, reload, stop, compact };
446
767
  }
447
768
  /** Format a token count as a compact string: 0, 1.2k, 5.9k, 12k, 1.8M, etc. */
448
769
  function formatTokenCount(tokens) {