opencode-auto-resume 1.1.20 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +273 -268
  2. package/dist/index.js +4253 -200
  3. package/dist/v2/index.js +4010 -0
  4. package/package.json +7 -4
@@ -0,0 +1,4010 @@
1
+ // @bun
2
+ // src/v2/index.ts
3
+ import { readFileSync, mkdirSync, statSync, writeFileSync } from "fs";
4
+ import { dirname, join } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { readFile } from "fs/promises";
7
+ var define = (plugin) => plugin;
8
+ async function configMentionsTaskComplete(cfgPath) {
9
+ try {
10
+ const raw = await readFile(cfgPath, "utf8");
11
+ return /"[^"]*task-complete[^"]*"/i.test(raw);
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+ var CTX_WRAPUP_TRIGGER = "ctx-wrapup";
17
+ var DEFAULT_CHUNK_TIMEOUT_MS = 180000;
18
+ var DEFAULT_CHECK_INTERVAL_MS = 5000;
19
+ var DEFAULT_GRACE_PERIOD_MS = 3000;
20
+ var DEFAULT_STUCK_TOOL_AGE_MS = 60000;
21
+ var LONG_RUNNING_TOOL_STALE_TTL_MS = 10 * 60000;
22
+ var LONG_RUNNING_TOOLS = ["shell"];
23
+ var STALL_JITTER_MS = 60000;
24
+ var DEFAULT_MAX_RETRIES = 3;
25
+ var LOCAL_PROVIDERS = new Set([
26
+ "llama-server",
27
+ "ollama",
28
+ "llama.cpp",
29
+ "llama-cpp",
30
+ "vllm",
31
+ "text-generation-inference",
32
+ "tgi",
33
+ "lmstudio",
34
+ "koboldcpp",
35
+ "exllama",
36
+ "mlc-llm",
37
+ "ggml",
38
+ "llamacpp"
39
+ ]);
40
+ var FAST_PROFILE = {
41
+ chunkTimeoutMs: 180000,
42
+ gracePeriodMs: 3000,
43
+ maxRetries: 3,
44
+ lifetimeMaxContinues: 9
45
+ };
46
+ var SLOW_PROFILE = {
47
+ chunkTimeoutMs: 600000,
48
+ gracePeriodMs: 60000,
49
+ maxRetries: 5,
50
+ lifetimeMaxContinues: 20
51
+ };
52
+ var FAILURE_LOOP_MAX = 3;
53
+ var PROVIDER_RETRY_GRACE_MS = 120000;
54
+ var DEFAULT_BASE_BACKOFF_MS = 1000;
55
+ var DEFAULT_MAX_BACKOFF_MS = 8000;
56
+ var DEFAULT_LOOP_MAX_CONTINUES = 3;
57
+ var DEFAULT_LOOP_WINDOW_MS = 10 * 60000;
58
+ var DEFAULT_LIFETIME_MAX_CONTINUES = 9;
59
+ var DEFAULT_DEBUG = false;
60
+ var DEFAULT_ACTIVE_USER_WINDOW_MS = 5 * 60000;
61
+ var DEFAULT_DISCOVERY_DELAY_MS = 5000;
62
+ var DEFAULT_SESSION_DISCOVERY_INTERVAL_MS = 60000;
63
+ var DEFAULT_WARMUP_MS = 15000;
64
+ var DEFAULT_SHARE_WATCH_STATE = false;
65
+ var DEFAULT_MIN_ACTIVITY_GAP_MS = 1000;
66
+ var DEFAULT_TOOL_TEXT_CHECK_DELAY_MS = 3000;
67
+ var DEFAULT_SUBAGENT_WAIT_MS = 15000;
68
+ var SUBAGENT_STUCK_MS = 60000;
69
+ var DEFAULT_SUBAGENT_DEAD_MS = 60 * 60000;
70
+ var SUBAGENT_RECOVERY_PROMPT = "It looks like you may have stalled or timed out. Please retry the last operation or continue with the task.";
71
+ var DEFAULT_SILENT_DEAD_STREAM_MIN_TOKENS = 200;
72
+ var DEFAULT_CONTEXT_SATURATION_THRESHOLD = 0.85;
73
+ var CHECKED_TOOL_PART_IDS_MAX = 500;
74
+ var QUOTA_STATE_PREFIX = "resume-quota/";
75
+ var COUNTER_STATE_PREFIX = "resume-counters/";
76
+ var PAUSED_MARKER_FILE = `${process.env.HOME ?? ""}/.config/opencode/auto-resume.paused`;
77
+ var QUOTA_STATE_MAX_AGE_MS = 48 * 3600000;
78
+ var TODO_CACHE_TTL_MS = 3000;
79
+ var DEFAULT_STREAMING_FAILURE_ERROR_NAMES = [
80
+ "ProviderError",
81
+ "APIError",
82
+ "StreamError",
83
+ "ConnectionError",
84
+ "TimeoutError"
85
+ ];
86
+ var DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS = [
87
+ "streaming response failed",
88
+ "stream.*fail",
89
+ "connection.*reset",
90
+ "connection.*closed",
91
+ "aborted due to timeout"
92
+ ];
93
+ var DONE_CLAIM_PATTERN_SOURCES = [
94
+ "^task\\s+done[.!]*$",
95
+ "^done[.!]*$",
96
+ "^all\\s+done[.!]*$",
97
+ "^finished[.!]*$",
98
+ "^complete[.!]*$",
99
+ "^task\\s+complete[.!]*$",
100
+ "^task\\s+completed[.!]*$",
101
+ "^all\\s+tasks?\\s+complete[.!]*$",
102
+ "^all\\s+tasks?\\s+completed[.!]*$",
103
+ "^(?:i['\u2019]?m\\s+)?done\\s+with\\s+task",
104
+ "\\bdone\\s+with\\s+(?:the\\s+)?(?:task|work|implementation)",
105
+ "\\bfinished\\s+(?:the\\s+)?(?:task|work|implementation)",
106
+ "\\b(?:all|everything)\\s+(?:[\\w-]+\\s+)?(?:is\\s+)?(?:complete|done|finished)",
107
+ "\\bnothing\\s+(?:else\\s+)?(?:left|remaining|to do)"
108
+ ];
109
+ var DEFAULT_DONE_CLAIM_PATTERNS = [...DONE_CLAIM_PATTERN_SOURCES];
110
+ var DEFAULT_READY_TO_CONTINUE_PATTERNS = [
111
+ "ready to continue with task",
112
+ "continuing with task",
113
+ "continue with task",
114
+ "proceeding with task",
115
+ "ready to proceed with task",
116
+ "will continue with task",
117
+ "moving on to task"
118
+ ];
119
+ var THINKING_TOOL_RECOVERY_PROMPT = "I noticed you have a tool call generated in your thinking/reasoning. " + "Please execute it using the proper tool calling mechanism instead of keeping it in reasoning.";
120
+ var DONE_WITHOUT_DETAILS_PROMPT = "Your last response claimed the task is complete but contained no work description. This is not acceptable. " + "You MUST respond now with a full, detailed report of everything you did: " + "for each file you modified, state the full path and the exact changes; " + "list every command you ran to verify and its result; state the final outcome. " + "Do NOT reply with 'done', 'task completed', or any short acknowledgment \u2014 " + "your ONLY acceptable response right now is this detailed report. Write it now.";
121
+ var SHELL_OPEN_MAX_MS = 30 * 60000;
122
+ var OOC_ERROR_RE = /exceeds the available context size|context size \(\d+\)|too large to compact|too many tokens|prompt is too long/i;
123
+ var PERMANENT_ERROR_TYPES = new Set([
124
+ "provider.auth",
125
+ "provider.no-route",
126
+ "provider.content-filter"
127
+ ]);
128
+ var INDETERMINATE_ERROR_TYPES = new Set(["provider.unsupported-operation", "provider.unknown"]);
129
+ var INDETERMINATE_STRIKES = 2;
130
+ var PERMANENT_STATUS_CODES = new Set([401, 403]);
131
+ var PERMANENT_HISTORY_RE = /referenced (reasoning )?item [^\n]{0,80}not found|no tool output found for (function )?call/i;
132
+ var PERMANENT_GONE_RE = /model\b.{0,80}\b(deprecat\w*|discontinued|retired|removed|no longer (available|supported|offered))\b|\bno llm route for\b/i;
133
+ var PERMANENT_UNKNOWN_MODEL_RE = /\b(model[_ ]?not[_ ]?found|model_not_found|unknown model|no such model|invalid model|unsupported model)\b/i;
134
+ var RATE_LIMIT_RE = /rate.?limit|too many requests|\b429\b|quota exceeded|provider\.quota|provider\.free|insufficient.?quota|resource.?exhausted|overloaded|throttl|slow down|please wait|try again later|too many tokens.{0,120}(short period|wait|retr|throttl|slow|per (second|minute|hour|day)|rate|limit|quota)|free[ -]?limit|free usage limit reached|usage limit reached|connect a provider or subscribe/i;
135
+ var DEFAULT_RATE_LIMIT_COOLDOWNS_MS = [15, 30, 60, 120, 120, 120, 120, 120].map((m) => m * 60000);
136
+ var DEFAULT_SEND_TIMEOUT_MS = 30000;
137
+ var SEND_SLOW_MS = 1e4;
138
+ var SELF_ABORT_TTL_MS = 30000;
139
+ var ABORT_ERROR_TYPE_RE = /^(abort|aborted|cancel|cancelled|canceled|interrupted)$/i;
140
+ var ABORT_ERROR_MSG_RE = /step interrupted|interrupted by user|request cancelled|request canceled|execution interrupted/i;
141
+ var MAX_INTERRUPTS_PER_WINDOW = 2;
142
+ var INTERRUPT_WINDOW_MS = 10 * 60000;
143
+ var DEFAULT_INJECT_INTERVAL_MS = 15000;
144
+ var OWN_PROMPT_TIME_MS = 30000;
145
+ var MAX_IDLE_SESSIONS = 50;
146
+ var IDLE_CLEANUP_MS = 10 * 60000;
147
+ var COMPACTION_STALE_TTL_MS = 30 * 60000;
148
+ var PERMISSION_STALE_TTL_MS = 30 * 60000;
149
+ var NEVER_INTERRUPT_STALE_TTL_MS = 30 * 60000;
150
+ var TEXT_BUFFER_TRIM_LEN = 20000;
151
+ var TOOL_STATE_RUNNING = "running";
152
+ var NEVER_RETRY_RE = /blocked by cc safety net|do not retry|access to a sensitive path/i;
153
+ function toolErrorText(state) {
154
+ const e = state?.error ?? state?.output;
155
+ if (typeof e === "string")
156
+ return e;
157
+ if (e && typeof e === "object") {
158
+ const t = e;
159
+ const parts = [t.type, t.message].filter((x) => typeof x === "string" && x.length > 0);
160
+ if (parts.length > 0)
161
+ return parts.join(" ");
162
+ }
163
+ return "";
164
+ }
165
+ var TOOL_STATE_PENDING = "pending";
166
+ var TOOL_STATE_COMPLETED = "completed";
167
+ var AWAITING_USER_TOOLS = new Set(["question", "permission", "ask", "confirm"]);
168
+ var CONTINUE_PROMPT = "continue";
169
+ var SAFE_CONTINUE_PROMPT = `continue
170
+ Only continue work already in progress. Do not start anything new, ` + "and do not take destructive or irreversible actions without explicit user approval.";
171
+ var SAFE_EMPTY_IDLE_PROMPT = `continue
172
+ If work was already in progress, continue it and finish nothing else. ` + "Otherwise ask the user what to do next rather than starting something new. " + "Do not take destructive or irreversible actions without explicit user approval.";
173
+ var TASK_COMPLETE_ACK = "Task completion acknowledged. No further continuation will be sent. " + "End your turn now with a brief text reply \u2014 do not call task_complete or any other tool again " + "unless the user sends a new message.";
174
+ var TASK_COMPLETE_REPEAT_WARNING = "Task completion acknowledged already on your previous call \u2014 completion is recorded. " + "End your turn now with a brief text reply. Do not call task_complete again; " + "further repeat calls are rejected as errors.";
175
+ var TASK_COMPLETE_REPEAT_ERROR = "task_complete already acknowledged twice with no new user message or tool work since \u2014 " + "completion is recorded. End your turn with text and make no further tool calls.";
176
+ var UNKNOWN_TOOL_THRESHOLD = 2;
177
+ var TOOL_IDS_CACHE_MS = 5 * 60000;
178
+ var UNKNOWN_TOOL_LIST_LIMIT = 20;
179
+ var TASK_COMPLETE_DESCRIPTION = "Signal that all work is complete and stop automatic continuation prompts. Call this ONLY after finishing everything requested. Call exactly once per completed round of work \u2014 repeat calls with no new user message in between are rejected.";
180
+ var TOOL_TEXT_RECOVERY_PROMPT = "Your last message contained a raw tool call printed as text instead of being executed. " + "Please use the proper tool calling mechanism to execute it.";
181
+ var DONE_WITHOUT_WORK_PROMPT = "I need you to verify more carefully that you have actually completed all the required tasks. " + "Your response indicated you're done, but no work was detected. Please check your todo list " + "and complete any remaining work.";
182
+ var TOOL_LOOP_RECOVERY_PROMPT = "I notice you've been calling the same tool multiple times in a row without making progress. " + "Please step back and reassess your approach. Consider: " + "1) Are you stuck in a loop? 2) Do you need different information first? " + "3) Should you try a different tool or break the task into smaller steps? " + "Take a moment to think about what's blocking you and propose a different strategy.";
183
+ var TASK_TOOL_HINTS = ["task", "agent", "subagent", "dispatch"];
184
+ var TOOL_TEXT_PATTERNS = [
185
+ /<function\s*=/i,
186
+ /<function>/i,
187
+ /<\/function>/i,
188
+ /<parameter\s*=/i,
189
+ /<parameter>/i,
190
+ /<\/parameter>/i,
191
+ /<tool_call[\s>]/i,
192
+ /<\/tool_call>/i,
193
+ /<tool[\s_]name\s*=/i,
194
+ /<invoke\s+/i,
195
+ /<func(?:t|ti|tio|tion)?$/im,
196
+ /<par(?:a|am|ame|amet|amete|ameter)?$/im,
197
+ /<(?:edit|write|read|bash|grep|glob|search|replace|execute|run)\s*(?:\s[^>]*)?\s*(?:\/>|>)/i,
198
+ /{"type":\s*"function"/i,
199
+ /{"name":\s*"[a-zA-Z_]/i,
200
+ /\{\s*"type"\s*:?$/im,
201
+ /\{\s*"name"\s*:?$/im
202
+ ];
203
+ var TRUNCATED_XML_PATTERNS = [
204
+ { open: /<function[^>]*>/i, close: /<\/function>/i },
205
+ { open: /<parameter[^>]*>/i, close: /<\/parameter>/i },
206
+ { open: /<tool_call[^>]*>/i, close: /<\/tool_call>/i },
207
+ { open: /\{\s*"type"\s*:/i, close: /}/ },
208
+ { open: /\{\s*"name"\s*:/i, close: /}/ }
209
+ ];
210
+ var READY_TO_CONTINUE_PATTERNS = [
211
+ /ready to continue with task/i,
212
+ /continuing with task/i,
213
+ /continue with task/i,
214
+ /proceeding with task/i,
215
+ /ready to proceed with task/i,
216
+ /will continue with task/i,
217
+ /moving on to task/i
218
+ ];
219
+ var DONE_CLAIM_PATTERNS = DONE_CLAIM_PATTERN_SOURCES.map((s) => new RegExp(s, "im"));
220
+ var STREAMING_FAILURE_MESSAGE_PATTERNS = [
221
+ "stream.*fail",
222
+ "stream.*timeout",
223
+ "connection.*reset",
224
+ "connection.*closed",
225
+ "connection.*error",
226
+ "socket.*hang",
227
+ "econnreset",
228
+ "etimedout",
229
+ "rate.?limit",
230
+ "overloaded",
231
+ "server error",
232
+ "internal error"
233
+ ];
234
+ function stripCodeBlocks(text) {
235
+ return text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]+`/g, "");
236
+ }
237
+ function stripBoundaryMarkers(text) {
238
+ return text.replace(/(?:\s*@\d+@)+\s*$/, "");
239
+ }
240
+ function containsToolCallAsText(text) {
241
+ if (text.length <= 10)
242
+ return false;
243
+ const stripped = stripCodeBlocks(text);
244
+ if (TOOL_TEXT_PATTERNS.some((pat) => pat.test(stripped)))
245
+ return true;
246
+ for (const { open, close } of TRUNCATED_XML_PATTERNS) {
247
+ if (open.test(stripped) && !close.test(stripped))
248
+ return true;
249
+ }
250
+ return false;
251
+ }
252
+ function containsReadyToContinuePattern(text, patterns = READY_TO_CONTINUE_PATTERNS) {
253
+ const lines = text.split(`
254
+ `);
255
+ const lastLines = lines.slice(-3).join(`
256
+ `);
257
+ return patterns.some((pat) => pat.test(lastLines));
258
+ }
259
+ function containsDoneClaimPattern(text, patterns = DONE_CLAIM_PATTERNS) {
260
+ const lines = text.split(`
261
+ `);
262
+ const lastLines = lines.slice(-5).join(`
263
+ `);
264
+ return patterns.some((pat) => pat.test(lastLines));
265
+ }
266
+ var STOP_STATEMENT_PATTERNS = [
267
+ /no action needed/i,
268
+ /\bstop(ping|ped)?\s+(here|there|now)\b[.!]*$/i,
269
+ /\bnothing\b[^.;:!?\u2014\u2013\n]*\b(further|else|pending|left|remaining|outstanding|launched|queued|scheduled|to do)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
270
+ /\bno (further|additional|more) (action|work|steps?|runs?|anything)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
271
+ /\bnone (pending|left|remaining|outstanding)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
272
+ /\b(stopping|halt|halted|standing down|pausing|paused)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
273
+ /\b(that'?s|thats|this is) (all|it|everything)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
274
+ /\b(parked|not started|unstarted)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
275
+ /\bTASK COMPLETE\s*[.!]*\s*$/,
276
+ /\btask completed\s*[.!]*\s*$/i,
277
+ /\bstanding by\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
278
+ /\bready (?:when you have the|for your|for the) (?:next\s+)?(?:task|instruction)s?\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
279
+ /\bnothing in progress\b/i,
280
+ /\bwaiting\s+(?:on|for)\s+(?:you|the user|your|my|the next|further)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
281
+ /\bawaiting\s+(?:you|the user|your|further|the next)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
282
+ /\bblocked\b[^.;:!?\u2014\u2013\n]*(?:\bneed\b|\bawaiting\b|\bon\b)[^.;:!?\u2014\u2013\n]*[.!]*$/i,
283
+ /\b(?:on hold|holding off)\s+(?:pending|awaiting|waiting for)\s+(?:your|the user|further|the next)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
284
+ /\b(?:can'?t|cannot|unable to)\s+proceed\s+without\s+(?:your|the user)'?\s*(?:input|guidance|decision|approval|confirmation|direction)s?\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
285
+ /\b(?:i\s+)?need\s+(?:your|the user)'?\s*(?:input|guidance|decision|approval|confirmation|direction|clarification)s?\b[^.;:!?\u2014\u2013\n]*(?:\bto\s+continue\b|\bbefore\b)?[^.;:!?\u2014\u2013\n]*[.!]*$/i,
286
+ /\bstop\s+and\s+wait\s+for\s+your\s+approval\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
287
+ /\bask\s+me\s+to\s+add\s+them\s+to\s+the\s+chat\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
288
+ /\bFINISHED\s*[.!]*\s*$/,
289
+ /\bi\s+have\s+completed\s+(?:the\s+)?(?:task|work|implementation)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
290
+ /\bthe\s+(?:task|work)\s+is\s+(?:finished|complete|done)\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
291
+ /\bready\s+when\s+you'?re\s+ready\b[^.;:!?\u2014\u2013\n]*[.!]*$/i,
292
+ /\bready\s+and\s+waiting\b[^.;:!?\u2014\u2013\n]*[.!]*$/i
293
+ ];
294
+ function containsStopStatement(text) {
295
+ const lines = stripBoundaryMarkers(text).split(`
296
+ `);
297
+ const lastLines = lines.slice(-3).join(`
298
+ `);
299
+ const trimmedTail = lastLines.trim().replace(/[.!?\u2026]+$/, "");
300
+ const finalClause = trimmedTail.split(/[.;:!?\u2014\u2013]/).pop() ?? trimmedTail;
301
+ if (/,\s*(then|so|next|now|afterwards?|and then)\b/i.test(finalClause))
302
+ return false;
303
+ return STOP_STATEMENT_PATTERNS.some((pat) => pat.test(lastLines));
304
+ }
305
+ function turnEndsWithCompletionClaim(text) {
306
+ return endsWithCelebration(text) || containsStopStatement(text);
307
+ }
308
+ function containsWorkDescription(text) {
309
+ if (/`[^`\n]*\.[a-zA-Z0-9]{1,8}[^`\n]*`/.test(text))
310
+ return true;
311
+ if (/[\w\-~.][\w\-.~\/]*\/[\w\-.~]*\.[a-zA-Z]{1,8}\b/.test(text))
312
+ return true;
313
+ if (/^(changed|modified|deleted|created|updated|renamed|moved|files?\s+changed|verification|verified|tests?(?:\s+run|\s+passing|\s+pass)?|results?|outcome|commands?\s+(?:run|executed))/im.test(text))
314
+ return true;
315
+ return false;
316
+ }
317
+ function endsWithCelebration(text) {
318
+ const lastLine = stripBoundaryMarkers(text).split(`
319
+ `).filter((l) => l.trim().length > 0).pop() ?? "";
320
+ return lastLine.trim().replace(/[.!?]+$/, "").endsWith("\uD83C\uDF89");
321
+ }
322
+ function isOpenTodo(t) {
323
+ return t.status === "pending" || t.status === "in_progress";
324
+ }
325
+ function getOpenTodos(todos) {
326
+ if (!Array.isArray(todos))
327
+ return [];
328
+ return todos.filter(isOpenTodo);
329
+ }
330
+ function buildOpenTodosReminder(todos) {
331
+ if (!Array.isArray(todos))
332
+ return "continue";
333
+ const open = todos.filter(isOpenTodo);
334
+ if (open.length === 0)
335
+ return "continue";
336
+ const list = open.map((t, i) => `${i + 1}. [${t.status}] ${t.content}`).join(`
337
+ `);
338
+ const plural = open.length > 1 ? "s" : "";
339
+ const taskWord = open.length > 1 ? "tasks" : "task";
340
+ const thisWord = open.length > 1 ? "these" : "this";
341
+ return `You have ${open.length} unfinished task${plural}:
342
+ ${list}
343
+
344
+ Please continue working on ${thisWord} ${taskWord}.`;
345
+ }
346
+ function serverBaseUrls(cmdline) {
347
+ const out = [];
348
+ const push = (v) => {
349
+ if (typeof v !== "string" || !v)
350
+ return;
351
+ const url = v.startsWith("http") ? v : `http://127.0.0.1:${v.replace(/^:/, "")}`;
352
+ if (!out.includes(url))
353
+ out.push(url.replace(/\/$/, ""));
354
+ };
355
+ const env = process.env;
356
+ push(env.OPENCODE_SERVER_URL);
357
+ push(env.OPENCODE_URL);
358
+ push(env.OPENCODE_SERVER_PORT);
359
+ push(env.OPENCODE_PORT);
360
+ push(env.PORT);
361
+ try {
362
+ const raw = cmdline ?? readFileSync("/proc/self/cmdline", "utf8");
363
+ const argv = raw.split("\x00");
364
+ const portFlag = argv.indexOf("--port");
365
+ if (portFlag !== -1 && argv[portFlag + 1]) {
366
+ const hostFlag = argv.indexOf("--hostname");
367
+ const host = hostFlag !== -1 && argv[hostFlag + 1] ? argv[hostFlag + 1] : "127.0.0.1";
368
+ push(`http://${host}:${argv[portFlag + 1]}`);
369
+ }
370
+ } catch {}
371
+ return out;
372
+ }
373
+ function serverHeaders() {
374
+ const pw = process.env.OPENCODE_SERVER_PASSWORD || process.env.OPENCODE_PASSWORD;
375
+ if (!pw)
376
+ return {};
377
+ return { Authorization: `Basic ${Buffer.from(`opencode:${pw}`).toString("base64")}` };
378
+ }
379
+ function normalizeTodoList(input) {
380
+ if (input === null || typeof input !== "object")
381
+ return;
382
+ const todos = input.todos;
383
+ if (!Array.isArray(todos))
384
+ return;
385
+ const out = [];
386
+ for (const entry of todos) {
387
+ if (entry === null || typeof entry !== "object")
388
+ continue;
389
+ const e = entry;
390
+ if (typeof e.content !== "string" || typeof e.status !== "string")
391
+ continue;
392
+ const todo = { content: e.content, status: e.status, priority: "medium" };
393
+ if (typeof e.priority === "string")
394
+ todo.priority = e.priority;
395
+ out.push(todo);
396
+ }
397
+ return out.length > 0 ? out : undefined;
398
+ }
399
+ function todosFromMessages(messages) {
400
+ if (!Array.isArray(messages))
401
+ return;
402
+ const candidates = [];
403
+ let order = 0;
404
+ for (const message of messages) {
405
+ if (message === null || typeof message !== "object")
406
+ continue;
407
+ const content = message.content;
408
+ if (!Array.isArray(content))
409
+ continue;
410
+ for (const part of content) {
411
+ if (part === null || typeof part !== "object")
412
+ continue;
413
+ const p = part;
414
+ if (p.type !== "tool" || p.name !== "todowrite")
415
+ continue;
416
+ if (p.state?.status !== "completed")
417
+ continue;
418
+ const list = normalizeTodoList(p.state.input);
419
+ if (!list)
420
+ continue;
421
+ const t = p.state?.time ?? p.time ?? message.time ?? {};
422
+ const at = [t?.end, t?.start, t?.created].find((v) => typeof v === "number");
423
+ candidates.push({ list, at: typeof at === "number" ? at : null, order: order++ });
424
+ }
425
+ }
426
+ if (candidates.length === 0)
427
+ return;
428
+ candidates.sort((a, b) => {
429
+ if (a.at !== null && b.at !== null && a.at !== b.at)
430
+ return b.at - a.at;
431
+ if (a.at === null !== (b.at === null))
432
+ return a.at === null ? 1 : -1;
433
+ return a.order - b.order;
434
+ });
435
+ return candidates[0]?.list;
436
+ }
437
+ function containsActionIntent(text) {
438
+ if (text.length <= 15)
439
+ return false;
440
+ const cleaned = text.replace(/<[a-zA-Z/?][^>]*>/g, "").trim();
441
+ const lines = cleaned.split(`
442
+ `);
443
+ let lastLine = "";
444
+ for (let i = lines.length - 1;i >= 0; i--) {
445
+ if (lines[i].trim().length > 0) {
446
+ lastLine = lines[i].trim();
447
+ break;
448
+ }
449
+ }
450
+ return lastLine.endsWith(":") && lastLine.length > 5 && lastLine.length < 500;
451
+ }
452
+ function isUserHandoff(text) {
453
+ const lines = text.split(`
454
+ `);
455
+ let last = "";
456
+ for (let i = lines.length - 1;i >= 0; i--) {
457
+ const t = lines[i].trim();
458
+ if (t.length > 0) {
459
+ last = t;
460
+ break;
461
+ }
462
+ }
463
+ if (!last)
464
+ return false;
465
+ if (last.endsWith("?") || last.endsWith("\uFF1F"))
466
+ return true;
467
+ const patterns = [
468
+ /let\s+me\s+know/i,
469
+ /your\s+call/i,
470
+ /up\s+to\s+you/i,
471
+ /which\s+(would|do\s+you|option)/i,
472
+ /should\s+I/i,
473
+ /want\s+me\s+to/i,
474
+ /would\s+you\s+like/i,
475
+ /say\s+the\s+word/i,
476
+ /give\s+the\s+go-?ahead/i,
477
+ /if\s+you'?d\s+like/i
478
+ ];
479
+ return patterns.some((p) => p.test(last));
480
+ }
481
+ function backoffMs(attempt, base, max) {
482
+ return Math.min(base * Math.pow(2, attempt - 1), max);
483
+ }
484
+ function isSilenceReason(reason) {
485
+ return /^no activity for \d+s/i.test(reason);
486
+ }
487
+ function isProviderRetryFresh(w, now = Date.now()) {
488
+ return w.providerRetryAt > 0 && now - w.providerRetryAt < PROVIDER_RETRY_GRACE_MS;
489
+ }
490
+ function isStreamingFailure(message, patterns = STREAMING_FAILURE_MESSAGE_PATTERNS) {
491
+ const lower = message.toLowerCase();
492
+ if (!lower)
493
+ return false;
494
+ return patterns.some((pattern) => {
495
+ try {
496
+ return new RegExp(pattern, "i").test(lower);
497
+ } catch {
498
+ return lower.includes(pattern.toLowerCase());
499
+ }
500
+ });
501
+ }
502
+ function isStreamingFailureName(name, names) {
503
+ return names.some((candidate) => candidate.toLowerCase() === name.toLowerCase());
504
+ }
505
+ function detectPatternLoop(recentTools) {
506
+ if (recentTools.length < 6)
507
+ return false;
508
+ for (const patternLen of [1, 2, 3]) {
509
+ if (recentTools.length < patternLen * 3)
510
+ continue;
511
+ const pattern = recentTools.slice(-patternLen);
512
+ let matches = 0;
513
+ for (let i = recentTools.length - patternLen * 2;i >= 0; i -= patternLen) {
514
+ const slice = recentTools.slice(i, i + patternLen);
515
+ if (slice.length !== patternLen)
516
+ break;
517
+ if (!slice.every((t, idx) => t === pattern[idx]))
518
+ break;
519
+ matches++;
520
+ }
521
+ if (matches >= 2)
522
+ return true;
523
+ }
524
+ return false;
525
+ }
526
+ function levenshtein(a, b) {
527
+ const m = a.length;
528
+ const n = b.length;
529
+ if (m === 0)
530
+ return n;
531
+ if (n === 0)
532
+ return m;
533
+ let prev = new Array(n + 1);
534
+ let curr = new Array(n + 1);
535
+ for (let j = 0;j <= n; j++)
536
+ prev[j] = j;
537
+ for (let i = 1;i <= m; i++) {
538
+ curr[0] = i;
539
+ for (let j = 1;j <= n; j++) {
540
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
541
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
542
+ }
543
+ const tmp = prev;
544
+ prev = curr;
545
+ curr = tmp;
546
+ }
547
+ return prev[n];
548
+ }
549
+ var TOOL_NAME_ALIASES = {
550
+ bash: "shell"
551
+ };
552
+ function resolveToolSuggestion(wrongName, available) {
553
+ const alias = TOOL_NAME_ALIASES[wrongName.toLowerCase()];
554
+ if (alias && available.includes(alias))
555
+ return alias;
556
+ return suggestClosestTool(wrongName, available);
557
+ }
558
+ function suggestClosestTool(wrongName, available) {
559
+ const lower = wrongName.toLowerCase();
560
+ let best = null;
561
+ let bestDist = Infinity;
562
+ for (const id of available) {
563
+ const dist = levenshtein(lower, id.toLowerCase());
564
+ const threshold = Math.max(2, Math.floor(lower.length / 2));
565
+ if (dist < bestDist && dist <= threshold) {
566
+ bestDist = dist;
567
+ best = id;
568
+ }
569
+ }
570
+ return best;
571
+ }
572
+ function trackToolCall(w, toolName) {
573
+ const now = Date.now();
574
+ w.recentToolCalls = w.recentToolCalls.filter((c) => now - c.at < 120000);
575
+ w.recentToolCalls.push({ toolName, at: now });
576
+ const recentTools = w.recentToolCalls.slice(-12).map((c) => c.toolName);
577
+ if (recentTools.length < 6)
578
+ return false;
579
+ const lastTool = recentTools[recentTools.length - 1];
580
+ const consecutiveSame = recentTools.slice(-4).filter((t) => t === lastTool).length;
581
+ if (consecutiveSame >= 4)
582
+ return true;
583
+ return detectPatternLoop(recentTools);
584
+ }
585
+ function short(sid) {
586
+ return sid.length > 12 ? `\u2026${sid.slice(-8)}` : sid;
587
+ }
588
+ function sidOf(ev) {
589
+ const sid = ev.data?.sessionID;
590
+ return typeof sid === "string" ? sid : undefined;
591
+ }
592
+ function msgRole(m) {
593
+ const mm = m;
594
+ if (!mm || typeof mm !== "object")
595
+ return "";
596
+ const v = mm.role ?? mm.type ?? mm.info?.role;
597
+ return typeof v === "string" ? v : "";
598
+ }
599
+ function msgAt(m) {
600
+ const mm = m;
601
+ const t = mm?.time?.created ?? mm?.info?.time?.created;
602
+ return typeof t === "number" ? t : 0;
603
+ }
604
+ function messageTextOf(m) {
605
+ const mm = m;
606
+ if (!mm || typeof mm !== "object")
607
+ return;
608
+ if (Array.isArray(mm.content)) {
609
+ const parts = mm.content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text);
610
+ if (parts.length > 0)
611
+ return parts.join(`
612
+ `);
613
+ }
614
+ if (typeof mm.text === "string" && mm.text.length > 0)
615
+ return mm.text;
616
+ if (typeof mm.info?.content === "string" && mm.info.content.length > 0)
617
+ return mm.info.content;
618
+ return;
619
+ }
620
+ var OWN_PROMPT_SHAPES = [
621
+ /^continue\s*\u2014\s*stalled\s*\(/,
622
+ /^continue\s*\nOnly continue work already in progress\./,
623
+ /^continue\s*\nIf work was already in progress,/
624
+ ];
625
+ function looksLikeOwnPrompt(text) {
626
+ if (typeof text !== "string" || text.length === 0)
627
+ return false;
628
+ return OWN_PROMPT_SHAPES.some((re) => re.test(text));
629
+ }
630
+ function isTaskToolCall(ev) {
631
+ const tool = ev.data?.tool ?? ev.data?.toolName;
632
+ if (typeof tool === "string") {
633
+ const lower = tool.toLowerCase();
634
+ if (TASK_TOOL_HINTS.some((h) => lower.includes(h)))
635
+ return true;
636
+ }
637
+ const desc = ev.data?.input?.description ?? ev.data?.input?.subagent_type ?? ev.data?.input?.agent;
638
+ return typeof desc === "string";
639
+ }
640
+ var REGISTRY_KEY = `__auto_resume_singleton__:${process.pid}`;
641
+ function registryKeyFor(scope) {
642
+ return scope ? `${REGISTRY_KEY}:${scope}` : REGISTRY_KEY;
643
+ }
644
+ function singletonRegistry(scope) {
645
+ const g = globalThis;
646
+ const key = registryKeyFor(scope);
647
+ let reg = g[key];
648
+ if (!reg) {
649
+ reg = {};
650
+ g[key] = reg;
651
+ }
652
+ return reg;
653
+ }
654
+ var MODULE_INSTANCE = `${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
655
+ var HOST_BOOT = new Date(Date.now() - process.uptime() * 1000).toISOString();
656
+ var v2_default = define({
657
+ id: "auto-resume.v2",
658
+ setup: async (ctx) => {
659
+ const opts = ctx.options ?? {};
660
+ const registry = singletonRegistry(opts.registryScope);
661
+ const superseded = registry.live;
662
+ if (superseded) {
663
+ try {
664
+ superseded.dispose();
665
+ } catch {}
666
+ registry.live = undefined;
667
+ }
668
+ const registryScope = opts.registryScope;
669
+ const chunkTimeoutMs = opts.chunkTimeoutMs ?? DEFAULT_CHUNK_TIMEOUT_MS;
670
+ const checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
671
+ const gracePeriodMs = opts.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS;
672
+ const stuckToolAgeMs = opts.stuckToolAgeMs ?? DEFAULT_STUCK_TOOL_AGE_MS;
673
+ const neverInterruptTools = (opts.neverInterruptTools ?? ["compress"]).map((n) => String(n).toLowerCase());
674
+ const maxRetries = opts.maxRetries ?? opts.maxRecoveryRetries ?? DEFAULT_MAX_RETRIES;
675
+ const baseBackoff = opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
676
+ const maxBackoff = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
677
+ const loopMaxContinues = opts.loopMaxContinues ?? DEFAULT_LOOP_MAX_CONTINUES;
678
+ const baseLoopMaxContinues = loopMaxContinues;
679
+ const loopWindowMs = opts.loopWindowMs ?? DEFAULT_LOOP_WINDOW_MS;
680
+ const lifetimeMaxContinues = opts.lifetimeMaxContinues ?? DEFAULT_LIFETIME_MAX_CONTINUES;
681
+ const debug = opts.debug ?? DEFAULT_DEBUG;
682
+ const activeUserWindowMs = opts.activeUserWindowMs ?? DEFAULT_ACTIVE_USER_WINDOW_MS;
683
+ const injectIntervalMs = opts.injectIntervalMs ?? DEFAULT_INJECT_INTERVAL_MS;
684
+ const envVisible = process.env.AUTO_RESUME_VISIBLE_CONTINUE;
685
+ const visibleContinue = opts.visibleContinue ?? (envVisible === undefined || envVisible === "" ? true : /^(1|true|yes)$/i.test(envVisible));
686
+ const quietCorrections = opts.quietCorrections ?? true;
687
+ const richContinuePrompt = opts.richContinuePrompt ?? true;
688
+ const rateLimitCooldownsMs = Array.isArray(opts.rateLimitCooldownsMs) && opts.rateLimitCooldownsMs.length > 0 && opts.rateLimitCooldownsMs.every((n) => typeof n === "number" && n > 0) ? opts.rateLimitCooldownsMs : DEFAULT_RATE_LIMIT_COOLDOWNS_MS;
689
+ const sendTimeoutMs = opts.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
690
+ const subagentWaitMs = opts.subagentWaitMs ?? DEFAULT_SUBAGENT_WAIT_MS;
691
+ const subagentDeadMs = opts.subagentDeadMs ?? DEFAULT_SUBAGENT_DEAD_MS;
692
+ const toolTextCheckDelayMs = opts.toolTextCheckDelayMs ?? DEFAULT_TOOL_TEXT_CHECK_DELAY_MS;
693
+ const rawBusyStallStrategy = opts.busyStallStrategy ?? "continue";
694
+ const busyStallStrategy = rawBusyStallStrategy === "abort" || rawBusyStallStrategy === "off" ? rawBusyStallStrategy : "continue";
695
+ const resumeOnActionIntent = opts.resumeOnActionIntent !== false;
696
+ const warmupMs = opts.warmupMs ?? DEFAULT_WARMUP_MS;
697
+ const shareWatchState = opts.shareWatchState ?? DEFAULT_SHARE_WATCH_STATE;
698
+ const discoveryDelayMs = opts.discoveryDelayMs ?? DEFAULT_DISCOVERY_DELAY_MS;
699
+ const minActivityGapMs = opts.minActivityGapMs ?? DEFAULT_MIN_ACTIVITY_GAP_MS;
700
+ const contextSaturationThreshold = opts.contextSaturationThreshold ?? DEFAULT_CONTEXT_SATURATION_THRESHOLD;
701
+ const subagentNativeCompactionEnabled = opts.subagentNativeCompactionEnabled ?? false;
702
+ const silentDeadStreamMinTokens = opts.silentDeadStreamMinTokens ?? DEFAULT_SILENT_DEAD_STREAM_MIN_TOKENS;
703
+ const streamingFailureErrorNames = opts.streamingFailureErrorNames ?? DEFAULT_STREAMING_FAILURE_ERROR_NAMES;
704
+ const streamingFailureMessagePatterns = opts.streamingFailureMessagePatterns ?? DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS;
705
+ const doneWithoutDetailsPrompt = opts.doneWithoutDetailsPrompt ?? DONE_WITHOUT_DETAILS_PROMPT;
706
+ const doneWithoutWorkPrompt = opts.doneWithoutWorkPrompt ?? DONE_WITHOUT_WORK_PROMPT;
707
+ const thinkingToolRecoveryPrompt = opts.thinkingToolRecoveryPrompt ?? THINKING_TOOL_RECOVERY_PROMPT;
708
+ function compilePatterns(raw, fallback, flags) {
709
+ const sources = Array.isArray(raw) && raw.length > 0 ? raw : fallback;
710
+ const out = [];
711
+ for (const source of sources) {
712
+ try {
713
+ out.push(new RegExp(source, flags));
714
+ } catch {}
715
+ }
716
+ return out;
717
+ }
718
+ const doneClaimPatterns = compilePatterns(opts.doneClaimPatterns, DEFAULT_DONE_CLAIM_PATTERNS, "im");
719
+ const readyToContinuePatterns = compilePatterns(opts.readyToContinuePatterns, DEFAULT_READY_TO_CONTINUE_PATTERNS, "i");
720
+ const streamingFailureMessageRegexes = compilePatterns(streamingFailureMessagePatterns, DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS, "i");
721
+ const FEATURE_GATED_OPTIONS = [];
722
+ const RECOGNISED_OPTIONS = new Set([
723
+ "chunkTimeoutMs",
724
+ "checkIntervalMs",
725
+ "gracePeriodMs",
726
+ "maxRetries",
727
+ "maxRecoveryRetries",
728
+ "baseBackoffMs",
729
+ "maxBackoffMs",
730
+ "loopMaxContinues",
731
+ "loopWindowMs",
732
+ "lifetimeMaxContinues",
733
+ "debug",
734
+ "activeUserWindowMs",
735
+ "injectIntervalMs",
736
+ "visibleContinue",
737
+ "quietCorrections",
738
+ "richContinuePrompt",
739
+ "continuePrompt",
740
+ "toolTextRecoveryPrompt",
741
+ "actionIntentPrompt",
742
+ "doneWithoutWorkPrompt",
743
+ "busyStallStrategy",
744
+ "contextSaturationThreshold",
745
+ "subagentNativeCompactionEnabled",
746
+ "resumeOnActionIntent",
747
+ "discoveryDelayMs",
748
+ "warmupMs",
749
+ "minActivityGapMs",
750
+ "toolTextCheckDelayMs",
751
+ "subagentWaitMs",
752
+ "shareWatchState",
753
+ "registryScope",
754
+ "neverInterruptTools",
755
+ "subagentDeadMs",
756
+ "silentDeadStreamMinTokens",
757
+ "streamingFailureErrorNames",
758
+ "streamingFailureMessagePatterns",
759
+ "doneClaimPatterns",
760
+ "readyToContinuePatterns",
761
+ "doneWithoutDetailsPrompt",
762
+ "thinkingToolRecoveryPrompt",
763
+ "doneWithoutWorkPrompt",
764
+ "rateLimitCooldownsMs",
765
+ "sendTimeoutMs",
766
+ "stuckToolAgeMs",
767
+ "modelProfiles",
768
+ "defaultFast",
769
+ "defaultSlow"
770
+ ]);
771
+ const unknownOptions = Object.keys(opts).filter((key) => !RECOGNISED_OPTIONS.has(key));
772
+ if (unknownOptions.length > 0) {
773
+ log("warn", `ignoring unrecognised option(s): ${unknownOptions.join(", ")} \u2014 ` + `this v2 build does not read them. See the README on npm (opencode-auto-resume).`);
774
+ }
775
+ if (typeof ctx.app?.version !== "string") {
776
+ log("warn", "this looks like the opencode v1 build running on the wrong server: ctx.app.version is missing. The v2 bundle requires opencode v2 \u2014 update the plugin package (the npm build auto-selects per server) or load the build matching your server; see the README on npm (opencode-auto-resume).");
777
+ }
778
+ const gatedInUse = Object.keys(opts).filter((key) => FEATURE_GATED_OPTIONS.includes(key));
779
+ const dbg = (...args) => {
780
+ if (!debug)
781
+ return;
782
+ console.log("[auto-resume:debug]", ...args);
783
+ };
784
+ function log(level, msg) {
785
+ const line = `[auto-resume] ${msg}`;
786
+ if (level === "error")
787
+ console.error(line);
788
+ else if (level === "warn")
789
+ console.warn(line);
790
+ else
791
+ console.log(line);
792
+ }
793
+ const registryForState = singletonRegistry(registryScope);
794
+ let adoptedSessions = 0;
795
+ let sessions;
796
+ if (shareWatchState) {
797
+ if (!(registryForState.sessions instanceof Map)) {
798
+ registryForState.sessions = new Map;
799
+ }
800
+ sessions = registryForState.sessions;
801
+ adoptedSessions = sessions.size;
802
+ } else {
803
+ sessions = new Map;
804
+ }
805
+ const openShells = new Map;
806
+ function openShellCount(sid, now = Date.now()) {
807
+ let n = 0;
808
+ for (const [id, s] of openShells) {
809
+ if (now - s.startedAt > SHELL_OPEN_MAX_MS) {
810
+ openShells.delete(id);
811
+ continue;
812
+ }
813
+ if (s.sessionID === sid)
814
+ n++;
815
+ }
816
+ return n;
817
+ }
818
+ function forgetShells(sid) {
819
+ for (const [id, s] of openShells) {
820
+ if (s.sessionID === sid)
821
+ openShells.delete(id);
822
+ }
823
+ }
824
+ function ensureWatch(sid) {
825
+ let w = sessions.get(sid);
826
+ if (w) {
827
+ w.emptyIdleNudged ??= false;
828
+ w.workRetries ??= 0;
829
+ w.recoverGeneration ??= 0;
830
+ w.lifetimeContinues ??= 0;
831
+ w.inflightTools ??= new Map;
832
+ w.lastStuckToolCheck ??= 0;
833
+ return w;
834
+ }
835
+ if (!w) {
836
+ w = {
837
+ createdAt: Date.now(),
838
+ lastActivityAt: Date.now(),
839
+ status: "unknown",
840
+ userCancelled: false,
841
+ userCancelledAt: 0,
842
+ resumeAttempts: 0,
843
+ episodeRefires: 0,
844
+ workRetries: 0,
845
+ workSinceNudge: false,
846
+ lifetimeContinues: 0,
847
+ lastRetryAt: 0,
848
+ providerRetryAt: 0,
849
+ quotaNoticedAt: 0,
850
+ gaveUp: false,
851
+ lastQuotaNoticeAt: 0,
852
+ paused: false,
853
+ lastGenuineUserAt: 0,
854
+ consecFailureRecovers: 0,
855
+ failureLoopLatched: false,
856
+ aborting: false,
857
+ selfAbortAt: 0,
858
+ interruptsThisWindow: 0,
859
+ interruptWindowStart: 0,
860
+ lastInjectAt: 0,
861
+ lastProdText: "",
862
+ prodAssistantSnapshot: "",
863
+ completedSinceProd: 0,
864
+ succeededSinceProd: 0,
865
+ ownPromptTexts: [],
866
+ recovering: false,
867
+ oocLocked: false,
868
+ oocLockReason: null,
869
+ permLocked: false,
870
+ permLockReason: null,
871
+ indeterminateStrikes: 0,
872
+ selfRecovery: false,
873
+ textParts: new Map,
874
+ lastAssistantText: "",
875
+ lastAssistantMessageID: null,
876
+ toolTextAttempts: 0,
877
+ stuckToolAttempts: 0,
878
+ continueTimestamps: [],
879
+ doneClaimAttempts: 0,
880
+ doneClaimOpenTodosAttempts: 0,
881
+ todoNudgeAttempts: 0,
882
+ intentNudgeAttempts: 0,
883
+ completionSignaled: false,
884
+ completionSignaledAt: 0,
885
+ emptyIdleNudged: false,
886
+ todos: [],
887
+ todosFetchedAt: 0,
888
+ taskCompleteSignals: 0,
889
+ taskCompleteOverrides: 0,
890
+ orphanWatchStartAt: null,
891
+ orphanWatchArmedAt: null,
892
+ orphanRecoveryTried: false,
893
+ toolTextTimer: null,
894
+ recoverTimer: null,
895
+ recoverGeneration: 0,
896
+ lastSubagentCheckAt: 0,
897
+ pendingTools: 0,
898
+ toolsInFlightAtIdle: 0,
899
+ inflightTools: new Map,
900
+ rateLimitedAt: 0,
901
+ rateLimitAttempts: 0,
902
+ awaitingQuotaRetry: false,
903
+ unknownToolErrors: new Map,
904
+ unknownToolSuggestionSent: false,
905
+ checkedToolPartIDs: new Set,
906
+ lastUserMessageSeenAt: 0,
907
+ lastSilentReasoningCheck: 0,
908
+ lastStuckToolCheck: 0,
909
+ lastTokenTotal: 0,
910
+ contextWrapupAttempts: 0,
911
+ pendingRecoveryArmed: false,
912
+ permissionPending: false,
913
+ permissionPendingAt: null,
914
+ lastWasTaskTool: false,
915
+ idleSince: null,
916
+ compacting: false,
917
+ compactionStartedAt: null,
918
+ recentToolCalls: []
919
+ };
920
+ sessions.set(sid, w);
921
+ }
922
+ return w;
923
+ }
924
+ function resolveProfile(sid) {
925
+ const w = sessions.get(sid);
926
+ const model = w?.model ?? "";
927
+ const provider = model.split("/")[0]?.trim().toLowerCase() ?? "";
928
+ const isLocal = LOCAL_PROVIDERS.has(provider);
929
+ let base = isLocal ? { ...SLOW_PROFILE } : { ...FAST_PROFILE };
930
+ const defaultOverride = isLocal ? opts.defaultSlow : opts.defaultFast;
931
+ if (defaultOverride)
932
+ base = { ...base, ...defaultOverride };
933
+ const providerOverride = opts.modelProfiles?.[provider] ?? opts.modelProfiles?.[model.split("/")[0]?.trim() ?? ""];
934
+ if (providerOverride)
935
+ base = { ...base, ...providerOverride };
936
+ const chunkTimeoutMs = opts.chunkTimeoutMs ?? base.chunkTimeoutMs;
937
+ const gracePeriodMs = opts.gracePeriodMs ?? base.gracePeriodMs;
938
+ const maxRetries = opts.maxRetries ?? opts.maxRecoveryRetries ?? base.maxRetries;
939
+ const lifetimeMaxContinues = opts.lifetimeMaxContinues ?? base.lifetimeMaxContinues;
940
+ return { ...base, chunkTimeoutMs, gracePeriodMs, maxRetries, lifetimeMaxContinues };
941
+ }
942
+ let pausedCacheMtime = 0;
943
+ let pausedCacheSet = new Set;
944
+ function readPausedSet() {
945
+ try {
946
+ const st = statSync(PAUSED_MARKER_FILE);
947
+ if (st.mtimeMs === pausedCacheMtime)
948
+ return pausedCacheSet;
949
+ pausedCacheMtime = st.mtimeMs;
950
+ const raw = readFileSync(PAUSED_MARKER_FILE, "utf8");
951
+ const s = new Set;
952
+ for (const line of raw.split(/\r?\n/)) {
953
+ const t = line.trim();
954
+ if (t)
955
+ s.add(t);
956
+ }
957
+ pausedCacheSet = s;
958
+ return s;
959
+ } catch {
960
+ pausedCacheMtime = 0;
961
+ pausedCacheSet = new Set;
962
+ return pausedCacheSet;
963
+ }
964
+ }
965
+ function isPaused(sid) {
966
+ const s = readPausedSet();
967
+ return s.has("*") || s.has(sid);
968
+ }
969
+ function setPaused(sid, on) {
970
+ const s = readPausedSet();
971
+ if (on)
972
+ s.add(sid);
973
+ else
974
+ s.delete(sid);
975
+ try {
976
+ mkdirSync(dirname(PAUSED_MARKER_FILE), { recursive: true });
977
+ } catch {}
978
+ try {
979
+ writeFileSync(PAUSED_MARKER_FILE, [...s].join(`
980
+ `) + (s.size ? `
981
+ ` : ""), "utf8");
982
+ pausedCacheMtime = 0;
983
+ } catch (e) {
984
+ dbg(`${short(sid)} pause write failed: ${e instanceof Error ? e.message : String(e)}`);
985
+ }
986
+ }
987
+ function touch(sid) {
988
+ const w = ensureWatch(sid);
989
+ const now = Date.now();
990
+ if (now - w.lastActivityAt < minActivityGapMs)
991
+ return;
992
+ w.lastActivityAt = now;
993
+ }
994
+ function markBusy(sid) {
995
+ const w = ensureWatch(sid);
996
+ if (w.status !== "busy") {
997
+ dbg(`${short(sid)} idle/unknown -> busy`);
998
+ w.toolTextAttempts = 0;
999
+ w.stuckToolAttempts = 0;
1000
+ w.lastStuckToolCheck = 0;
1001
+ w.todoNudgeAttempts = 0;
1002
+ w.intentNudgeAttempts = 0;
1003
+ if (w.toolTextTimer) {
1004
+ clearTimeout(w.toolTextTimer);
1005
+ w.toolTextTimer = null;
1006
+ }
1007
+ w.orphanWatchStartAt = null;
1008
+ w.orphanWatchArmedAt = null;
1009
+ w.recentToolCalls = [];
1010
+ w.textParts.clear();
1011
+ w.lastAssistantText = "";
1012
+ }
1013
+ w.status = "busy";
1014
+ w.idleSince = null;
1015
+ w.lastActivityAt = Date.now();
1016
+ }
1017
+ function markIdle(sid) {
1018
+ const w = ensureWatch(sid);
1019
+ if (w.status !== "idle") {
1020
+ dbg(`${short(sid)} ${w.status} -> idle`);
1021
+ w.status = "idle";
1022
+ w.idleSince = Date.now();
1023
+ w.pendingTools = 0;
1024
+ w.orphanWatchStartAt = null;
1025
+ w.orphanWatchArmedAt = null;
1026
+ }
1027
+ clearStalePermissionFlag(sid, w);
1028
+ }
1029
+ function clearStalePermissionFlag(sid, w) {
1030
+ if (!w.permissionPending)
1031
+ return;
1032
+ if (w.permissionPendingAt && Date.now() - w.permissionPendingAt > PERMISSION_STALE_TTL_MS) {
1033
+ dbg(`${short(sid)} permission prompt silent >${PERMISSION_STALE_TTL_MS / 60000}m \u2014 clearing flag`);
1034
+ w.permissionPending = false;
1035
+ w.permissionPendingAt = null;
1036
+ }
1037
+ }
1038
+ function recordContinue(sid) {
1039
+ const w = sessions.get(sid);
1040
+ if (!w)
1041
+ return;
1042
+ const now = Date.now();
1043
+ w.continueTimestamps.push(now);
1044
+ w.lifetimeContinues = (w.lifetimeContinues ?? 0) + 1;
1045
+ const cutoff = now - loopWindowMs;
1046
+ while (w.continueTimestamps.length > 0 && w.continueTimestamps[0] < cutoff) {
1047
+ w.continueTimestamps.shift();
1048
+ }
1049
+ }
1050
+ function continuesInWindow(w) {
1051
+ const cutoff = Date.now() - loopWindowMs;
1052
+ while (w.continueTimestamps.length > 0 && w.continueTimestamps[0] < cutoff) {
1053
+ w.continueTimestamps.shift();
1054
+ }
1055
+ return w.continueTimestamps.length;
1056
+ }
1057
+ function lifetimeExhausted(sid, w) {
1058
+ const profile = resolveProfile(sid);
1059
+ const profileLifetimeMaxContinues = profile.lifetimeMaxContinues;
1060
+ if ((w.lifetimeContinues ?? 0) >= profileLifetimeMaxContinues) {
1061
+ if (!w.gaveUp) {
1062
+ log("warn", `${short(sid)} lifetime cap (${profileLifetimeMaxContinues} continues, no genuine user message) \u2014 standing down`);
1063
+ w.gaveUp = true;
1064
+ }
1065
+ return true;
1066
+ }
1067
+ return false;
1068
+ }
1069
+ function selfAbortActive(w) {
1070
+ return w.selfAbortAt > 0 && Date.now() - w.selfAbortAt < SELF_ABORT_TTL_MS;
1071
+ }
1072
+ function isAbortError(errType, errMsg) {
1073
+ return ABORT_ERROR_TYPE_RE.test(errType.trim()) || ABORT_ERROR_MSG_RE.test(errMsg);
1074
+ }
1075
+ function isRateLimitError(errType, errMsg) {
1076
+ return RATE_LIMIT_RE.test(`${errType} ${errMsg}`);
1077
+ }
1078
+ async function sendQuotaNotice(sid, text) {
1079
+ try {
1080
+ await sendBound("quota-notice", sid, () => ctx.session.synthetic({ sessionID: sid, text, description: "auto-resume: quota notice", resume: false }));
1081
+ } catch (e) {
1082
+ log("warn", `${short(sid)} quota notice failed: ${e instanceof Error ? e.message : String(e)}`);
1083
+ }
1084
+ }
1085
+ function serveQuotaRung(sid, w, rung, ladder) {
1086
+ w.rateLimitAttempts = rung + 1;
1087
+ w.rateLimitedAt = Date.now();
1088
+ w.awaitingQuotaRetry = false;
1089
+ w.pendingRecoveryArmed = true;
1090
+ w.quotaNoticedAt = 0;
1091
+ log("info", `${short(sid)} rate-limit cooldown served \u2014 retrying (attempt ${rung + 1}/${ladder.length}) mod=${MODULE_INSTANCE}`);
1092
+ syncQuotaState(sid, w);
1093
+ const cooldownMin = Math.max(1, Math.round(ladder[rung] / 60000));
1094
+ recover(sid, `rate-limit cooldown served (rung ${rung + 1}/${ladder.length}, ${cooldownMin}m)`);
1095
+ }
1096
+ function handleRateLimitFailure(sid, w, errType, errMsg) {
1097
+ if (isPaused(sid)) {
1098
+ dbg(`${short(sid)} paused \u2014 not arming quota ladder`);
1099
+ return;
1100
+ }
1101
+ const ladder = rateLimitCooldownsMs;
1102
+ const tag = "rate-limit";
1103
+ const n = w.rateLimitAttempts;
1104
+ markIdle(sid);
1105
+ if (n >= ladder.length) {
1106
+ w.awaitingQuotaRetry = false;
1107
+ log("warn", `${short(sid)} ${tag} budget exhausted (${ladder.length} attempts) \u2014 standing down until user turn`);
1108
+ if (!w.quotaNoticedAt && Date.now() - w.lastQuotaNoticeAt > 100) {
1109
+ w.quotaNoticedAt = Date.now();
1110
+ w.lastQuotaNoticeAt = Date.now();
1111
+ sendQuotaNotice(sid, `Rate limited \u2014 standing down until you return (budget exhausted after ${ladder.length} attempts).`);
1112
+ }
1113
+ return;
1114
+ }
1115
+ const now = Date.now();
1116
+ if (w.rateLimitedAt > 0 && now - w.rateLimitedAt >= ladder[n]) {
1117
+ serveQuotaRung(sid, w, n, ladder);
1118
+ return;
1119
+ }
1120
+ if (w.rateLimitedAt > 0 && now - w.rateLimitedAt < ladder[n]) {
1121
+ return;
1122
+ }
1123
+ if (w.rateLimitedAt === 0)
1124
+ w.rateLimitedAt = now;
1125
+ w.awaitingQuotaRetry = true;
1126
+ const waitMs = Math.max(0, ladder[n] - (now - w.rateLimitedAt));
1127
+ log("warn", `${short(sid)} rate limited (${errType || "error"}) \u2014 standing down, next attempt in ${Math.ceil(waitMs / 1000)}s (${n + 1}/${ladder.length})`);
1128
+ if (!w.quotaNoticedAt && Date.now() - w.lastQuotaNoticeAt > 100) {
1129
+ w.quotaNoticedAt = Date.now();
1130
+ w.lastQuotaNoticeAt = Date.now();
1131
+ const mins = Math.max(1, Math.round(waitMs / 60000));
1132
+ const when = mins >= 60 ? `~${Math.floor(mins / 60)}h ${mins % 60}m` : `~${mins}m`;
1133
+ sendQuotaNotice(sid, `Rate limited, retrying in ${when} (attempt ${n + 1}/${ladder.length}).`);
1134
+ }
1135
+ log("warn", `quota-armed sid=${sid} at=${now} rung=${n} ladder=${ladder.length}`);
1136
+ syncQuotaState(sid, w);
1137
+ }
1138
+ function interruptBudget(w) {
1139
+ if (w.interruptWindowStart === 0 || Date.now() - w.interruptWindowStart > INTERRUPT_WINDOW_MS) {
1140
+ w.interruptWindowStart = Date.now();
1141
+ w.interruptsThisWindow = 0;
1142
+ }
1143
+ return MAX_INTERRUPTS_PER_WINDOW - w.interruptsThisWindow;
1144
+ }
1145
+ function busySessions() {
1146
+ const out = [];
1147
+ for (const [sid, w] of sessions) {
1148
+ if (w.status === "busy" && !w.userCancelled)
1149
+ out.push(sid);
1150
+ }
1151
+ return out;
1152
+ }
1153
+ let sessionRowsCache = null;
1154
+ const SESSION_ROWS_TTL_MS = 1e4;
1155
+ async function listSessionsHttp() {
1156
+ const now = Date.now();
1157
+ if (sessionRowsCache && now - sessionRowsCache.at < SESSION_ROWS_TTL_MS)
1158
+ return sessionRowsCache.rows;
1159
+ for (const base of serverBaseUrls()) {
1160
+ try {
1161
+ const res = await fetch(`${base}/api/session?limit=500`, { headers: serverHeaders() });
1162
+ if (!res.ok)
1163
+ continue;
1164
+ const body = await res.json();
1165
+ const data = body?.data ?? body;
1166
+ if (Array.isArray(data)) {
1167
+ const rows = data.filter((r) => r !== null && typeof r === "object");
1168
+ sessionRowsCache = { at: now, rows };
1169
+ return rows;
1170
+ }
1171
+ } catch {}
1172
+ }
1173
+ return sessionRowsCache?.rows ?? [];
1174
+ }
1175
+ async function isSubAgentSession(sid) {
1176
+ const w = ensureWatch(sid);
1177
+ if (typeof w.isSubAgent === "boolean")
1178
+ return w.isSubAgent;
1179
+ let sub = false;
1180
+ try {
1181
+ const res = await ctx.client?.session.get({ path: { id: sid } });
1182
+ sub = !!res?.data?.parentID;
1183
+ } catch {
1184
+ sub = false;
1185
+ }
1186
+ if (!sub) {
1187
+ try {
1188
+ const rows = await listSessionsHttp();
1189
+ sub = rows.some((r) => r?.id === sid && typeof r?.parentID === "string");
1190
+ } catch {
1191
+ sub = false;
1192
+ }
1193
+ }
1194
+ w.isSubAgent = sub;
1195
+ return sub;
1196
+ }
1197
+ const RECENT_PROD_WINDOW_MS = 60000;
1198
+ async function recentOwnProdInLog(sid, text) {
1199
+ const pages = [];
1200
+ try {
1201
+ const message = ctx.client?.session?.message;
1202
+ if (typeof message?.list === "function") {
1203
+ const res = await message.list.call(message, { path: { id: sid } });
1204
+ const data = res?.data ?? res;
1205
+ if (Array.isArray(data))
1206
+ pages.push(...data);
1207
+ }
1208
+ } catch {}
1209
+ if (pages.length === 0) {
1210
+ for (const base of serverBaseUrls()) {
1211
+ try {
1212
+ const res = await fetch(`${base}/api/session/${encodeURIComponent(sid)}/message?limit=20`, { headers: serverHeaders() });
1213
+ if (!res.ok)
1214
+ continue;
1215
+ const body = (await res.json())?.data;
1216
+ if (Array.isArray(body)) {
1217
+ pages.push(...body);
1218
+ break;
1219
+ }
1220
+ } catch {}
1221
+ }
1222
+ }
1223
+ if (pages.length === 0)
1224
+ return false;
1225
+ const now = Date.now();
1226
+ for (const m of pages) {
1227
+ if (m === null || typeof m !== "object")
1228
+ continue;
1229
+ const msg = m;
1230
+ if (msg.role !== "user")
1231
+ continue;
1232
+ let t = "";
1233
+ if (typeof msg.text === "string") {
1234
+ t = msg.text;
1235
+ } else if (Array.isArray(msg.content)) {
1236
+ t = msg.content.filter((p) => !!p && typeof p === "object" && p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
1237
+ }
1238
+ if (t !== text)
1239
+ return false;
1240
+ const created = msg.time?.created;
1241
+ if (typeof created === "number" && now - created > RECENT_PROD_WINDOW_MS)
1242
+ return false;
1243
+ return true;
1244
+ }
1245
+ return false;
1246
+ }
1247
+ const injectLocks = new Map;
1248
+ async function withInjectLock(sid, fn) {
1249
+ const prev = injectLocks.get(sid) ?? Promise.resolve();
1250
+ let release;
1251
+ const current = new Promise((resolve) => {
1252
+ release = resolve;
1253
+ });
1254
+ const tail = prev.then(() => current);
1255
+ injectLocks.set(sid, tail);
1256
+ await prev;
1257
+ try {
1258
+ return await fn();
1259
+ } finally {
1260
+ release();
1261
+ if (injectLocks.get(sid) === tail)
1262
+ injectLocks.delete(sid);
1263
+ }
1264
+ }
1265
+ async function injectOnceLocked(sid, text, notification, allowDuringSelfAbort, checkDuplicate, forceSynthetic = false) {
1266
+ if (!running)
1267
+ return false;
1268
+ const w = ensureWatch(sid);
1269
+ if (await isSubAgentSession(sid)) {
1270
+ dbg(`${short(sid)} subagent session \u2014 injection refused (parent owns recovery)`);
1271
+ return false;
1272
+ }
1273
+ {
1274
+ const kind = lockKind(w);
1275
+ if (kind) {
1276
+ dbg(`${short(sid)} locked (${kind}) \u2014 injection refused on every path`);
1277
+ return false;
1278
+ }
1279
+ }
1280
+ if (!allowDuringSelfAbort && selfAbortActive(w)) {
1281
+ dbg(`${short(sid)} injection refused \u2014 inside our own abort window`);
1282
+ return false;
1283
+ }
1284
+ if (!allowDuringSelfAbort && w.status === "busy" && Date.now() - w.lastActivityAt < chunkTimeoutMs) {
1285
+ dbg(`${short(sid)} injection refused \u2014 session busy and live (${Math.round((Date.now() - w.lastActivityAt) / 1000)}s since last event)`);
1286
+ return false;
1287
+ }
1288
+ const since = Date.now() - w.lastInjectAt;
1289
+ if (!allowDuringSelfAbort && w.lastInjectAt > 0 && since < injectIntervalMs) {
1290
+ dbg(`${short(sid)} injection debounced \u2014 ${Math.round(since / 1000)}s since last (min ${injectIntervalMs / 1000}s)`);
1291
+ return false;
1292
+ }
1293
+ if (checkDuplicate && !allowDuringSelfAbort && w.lastProdText !== "" && text === w.lastProdText && w.pendingTools <= 0 && w.completedSinceProd === 0 && w.lastAssistantText === w.prodAssistantSnapshot) {
1294
+ dbg(`${short(sid)} duplicate continue suppressed \u2014 identical text, no progress since last prod`);
1295
+ return false;
1296
+ }
1297
+ w.lastInjectAt = Date.now();
1298
+ if (!allowDuringSelfAbort && await recentOwnProdInLog(sid, text)) {
1299
+ dbg(`${short(sid)} duplicate continue suppressed \u2014 identical prod already in session log`);
1300
+ return false;
1301
+ }
1302
+ const sent = await notifyAndPrompt(sid, text, notification, true, forceSynthetic);
1303
+ if (sent) {
1304
+ w.lastProdText = text;
1305
+ w.completedSinceProd = 0;
1306
+ w.succeededSinceProd = 0;
1307
+ w.prodAssistantSnapshot = w.lastAssistantText;
1308
+ w.ownPromptTexts.push(text);
1309
+ if (w.ownPromptTexts.length > 10)
1310
+ w.ownPromptTexts.shift();
1311
+ }
1312
+ return sent;
1313
+ }
1314
+ async function injectOnce(sid, text, notification, allowDuringSelfAbort = false, checkDuplicate = false, forceSynthetic = false) {
1315
+ const sendText = forceSynthetic ? `${text} Do not acknowledge this message and do not reply to it visibly; apply it silently and continue the task.` : text;
1316
+ return withInjectLock(sid, () => injectOnceLocked(sid, sendText, notification, allowDuringSelfAbort, checkDuplicate, forceSynthetic));
1317
+ }
1318
+ function cleanupIdleSessions() {
1319
+ const now = Date.now();
1320
+ const busy = new Set;
1321
+ for (const [sid, w] of sessions) {
1322
+ if (w.status === "busy")
1323
+ busy.add(sid);
1324
+ }
1325
+ let idleCount = 0;
1326
+ const toDelete = [];
1327
+ for (const [sid, w] of sessions) {
1328
+ if (w.status !== "busy") {
1329
+ idleCount++;
1330
+ if (w.idleSince && now - w.idleSince > IDLE_CLEANUP_MS)
1331
+ toDelete.push(sid);
1332
+ }
1333
+ }
1334
+ if (idleCount > MAX_IDLE_SESSIONS) {
1335
+ const entries = [];
1336
+ for (const [sid, w] of sessions) {
1337
+ if (w.status !== "busy" && w.idleSince)
1338
+ entries.push({ sid, since: w.idleSince });
1339
+ }
1340
+ entries.sort((a, b) => a.since - b.since);
1341
+ const excess = idleCount - MAX_IDLE_SESSIONS;
1342
+ for (let i = 0;i < excess && i < entries.length; i++) {
1343
+ if (!toDelete.includes(entries[i].sid))
1344
+ toDelete.push(entries[i].sid);
1345
+ }
1346
+ }
1347
+ let cleaned = 0;
1348
+ for (const sid of toDelete) {
1349
+ if (openShellCount(sid) > 0)
1350
+ continue;
1351
+ forgetShells(sid);
1352
+ sessions.delete(sid);
1353
+ cleaned++;
1354
+ }
1355
+ if (cleaned > 0)
1356
+ dbg(`cleaned ${cleaned} idle sessions, total=${sessions.size}`);
1357
+ }
1358
+ function appendText(sid, messageID, delta) {
1359
+ const w = ensureWatch(sid);
1360
+ if (delta && (w.consecFailureRecovers > 0 || w.failureLoopLatched)) {
1361
+ w.consecFailureRecovers = 0;
1362
+ if (w.failureLoopLatched) {
1363
+ w.failureLoopLatched = false;
1364
+ log("info", `${short(sid)} model output after failure loop \u2014 latch cleared`);
1365
+ }
1366
+ }
1367
+ const mid = messageID ?? "_anon";
1368
+ w.textParts.set(mid, (w.textParts.get(mid) ?? "") + delta);
1369
+ w.lastAssistantMessageID = mid;
1370
+ w.lastAssistantText = w.textParts.get(mid) ?? "";
1371
+ if (w.textParts.size > 2) {
1372
+ const oldest = w.textParts.keys().next().value;
1373
+ if (oldest !== undefined && oldest !== mid)
1374
+ w.textParts.delete(oldest);
1375
+ }
1376
+ if (w.lastAssistantText.length > TEXT_BUFFER_TRIM_LEN) {
1377
+ w.textParts.set(mid, w.lastAssistantText.slice(-TEXT_BUFFER_TRIM_LEN));
1378
+ w.lastAssistantText = w.textParts.get(mid) ?? "";
1379
+ }
1380
+ }
1381
+ async function sendBound(label, sid, send) {
1382
+ const started = Date.now();
1383
+ let timer;
1384
+ try {
1385
+ await Promise.race([
1386
+ send(),
1387
+ new Promise((_, reject) => {
1388
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${sendTimeoutMs}ms`)), sendTimeoutMs);
1389
+ })
1390
+ ]);
1391
+ } finally {
1392
+ if (timer !== undefined)
1393
+ clearTimeout(timer);
1394
+ }
1395
+ const elapsed = Date.now() - started;
1396
+ if (elapsed > SEND_SLOW_MS) {
1397
+ log("warn", `${short(sid)} ${label} took ${Math.round(elapsed / 1000)}s (over ${SEND_SLOW_MS / 1000}s slow threshold)`);
1398
+ }
1399
+ }
1400
+ async function notifyAndPrompt(sid, text, notification, resume = true, forceSynthetic = false) {
1401
+ ensureWatch(sid).selfRecovery = true;
1402
+ if (visibleContinue && !forceSynthetic) {
1403
+ try {
1404
+ await sendBound("prompt", sid, () => ctx.session.prompt({ sessionID: sid, text }));
1405
+ return true;
1406
+ } catch (err) {
1407
+ const msg = err instanceof Error ? err.message : String(err);
1408
+ log("warn", `${short(sid)} visible prompt failed: ${msg}`);
1409
+ if (await recentOwnProdInLog(sid, text)) {
1410
+ dbg(`${short(sid)} prompt threw but the text is already the newest user message \u2014 treating as delivered`);
1411
+ return true;
1412
+ }
1413
+ if (!running)
1414
+ return false;
1415
+ }
1416
+ }
1417
+ try {
1418
+ await sendBound("synthetic", sid, () => ctx.session.synthetic({
1419
+ sessionID: sid,
1420
+ text,
1421
+ description: `auto-resume: ${notification}`,
1422
+ resume
1423
+ }));
1424
+ return true;
1425
+ } catch (err) {
1426
+ const msg = err instanceof Error ? err.message : String(err);
1427
+ log("warn", `${short(sid)} synthetic failed: ${msg}`);
1428
+ if (!running)
1429
+ return false;
1430
+ if (await recentOwnProdInLog(sid, text)) {
1431
+ dbg(`${short(sid)} synthetic threw but the text is already the newest user message \u2014 treating as delivered`);
1432
+ return true;
1433
+ }
1434
+ try {
1435
+ await sendBound("prompt", sid, () => ctx.session.prompt({ sessionID: sid, text }));
1436
+ return true;
1437
+ } catch {
1438
+ log("error", `${short(sid)} all recovery attempts failed: ${msg}`);
1439
+ return false;
1440
+ }
1441
+ }
1442
+ }
1443
+ async function tryAbortAndResume(sid, w, force = false) {
1444
+ if (await isSubAgentSession(sid)) {
1445
+ dbg(`${short(sid)} subagent session \u2014 abort+resume refused`);
1446
+ return false;
1447
+ }
1448
+ if (w.userCancelled) {
1449
+ dbg(`${short(sid)} user cancelled \u2014 refusing abort+resume escalation`);
1450
+ return false;
1451
+ }
1452
+ if (!force && w.status === "busy") {
1453
+ dbg(`${short(sid)} busy \u2014 refusing abort+resume escalation`);
1454
+ return false;
1455
+ }
1456
+ if (w.aborting || selfAbortActive(w))
1457
+ return false;
1458
+ {
1459
+ const kind = lockKind(w);
1460
+ if (kind) {
1461
+ dbg(`${short(sid)} ${kind} locked \u2014 refusing abort+resume escalation`);
1462
+ return false;
1463
+ }
1464
+ }
1465
+ if (w.compacting) {
1466
+ dbg(`${short(sid)} mid-compaction \u2014 refusing abort+resume escalation`);
1467
+ return false;
1468
+ }
1469
+ if (interruptBudget(w) <= 0) {
1470
+ w.continueTimestamps = [];
1471
+ log("warn", `${short(sid)} interrupt budget exhausted (${MAX_INTERRUPTS_PER_WINDOW}/${INTERRUPT_WINDOW_MS / 60000}m) \u2014 skipping interrupt, loop guard reset`);
1472
+ return false;
1473
+ }
1474
+ w.aborting = true;
1475
+ w.selfAbortAt = Date.now();
1476
+ w.interruptsThisWindow++;
1477
+ log("warn", `${short(sid)} escalating: interrupt + fresh continue (${w.interruptsThisWindow}/${MAX_INTERRUPTS_PER_WINDOW} per ${INTERRUPT_WINDOW_MS / 60000}m)`);
1478
+ try {
1479
+ await ctx.session.interrupt({ sessionID: sid });
1480
+ } catch (err) {
1481
+ const msg = err instanceof Error ? err.message : String(err);
1482
+ log("warn", `${short(sid)} interrupt failed: ${msg}`);
1483
+ }
1484
+ await new Promise((r) => setTimeout(r, 2000));
1485
+ if (!running)
1486
+ return false;
1487
+ w.aborting = false;
1488
+ w.resumeAttempts = 0;
1489
+ syncCounters(sid, w);
1490
+ w.gaveUp = false;
1491
+ w.emptyIdleNudged = false;
1492
+ const ok = await injectOnce(sid, opts.continuePrompt ?? SAFE_CONTINUE_PROMPT, "abort+resume escalation", true);
1493
+ if (ok) {
1494
+ w.lastRetryAt = Date.now();
1495
+ log("info", `${short(sid)} resumed after abort`);
1496
+ }
1497
+ return ok;
1498
+ }
1499
+ function permanentFailureReason(errMsg, errType, status) {
1500
+ if (errType && PERMANENT_ERROR_TYPES.has(errType))
1501
+ return `type ${errType}`;
1502
+ if (typeof status === "number" && PERMANENT_STATUS_CODES.has(status))
1503
+ return `http ${status}`;
1504
+ if (PERMANENT_GONE_RE.test(errMsg))
1505
+ return errMsg.slice(0, 160);
1506
+ if (PERMANENT_HISTORY_RE.test(errMsg))
1507
+ return errMsg.slice(0, 160);
1508
+ if (PERMANENT_UNKNOWN_MODEL_RE.test(errMsg)) {
1509
+ if (status === 404)
1510
+ return null;
1511
+ return errMsg.slice(0, 160);
1512
+ }
1513
+ return null;
1514
+ }
1515
+ function maybeLockPermanent(sid, errMsg, errType, status) {
1516
+ const w = ensureWatch(sid);
1517
+ if (w.permLocked)
1518
+ return true;
1519
+ let why = permanentFailureReason(errMsg, errType, status);
1520
+ if (why === null && errType && INDETERMINATE_ERROR_TYPES.has(errType)) {
1521
+ w.indeterminateStrikes++;
1522
+ if (w.indeterminateStrikes < INDETERMINATE_STRIKES) {
1523
+ log("info", `${short(sid)} indeterminate failure (${errType}) ${w.indeterminateStrikes}/${INDETERMINATE_STRIKES} \u2014 one recovery attempt before latching mod=${MODULE_INSTANCE}`);
1524
+ return false;
1525
+ }
1526
+ why = `type ${errType} (${INDETERMINATE_STRIKES} strikes)`;
1527
+ }
1528
+ if (why === null)
1529
+ return false;
1530
+ w.permLocked = true;
1531
+ w.permLockReason = why;
1532
+ log("warn", `${short(sid)} permanent failure latched (${why}) \u2014 recovery locked out until a genuine turn: ${errMsg.slice(0, 120)} mod=${MODULE_INSTANCE}`);
1533
+ return true;
1534
+ }
1535
+ function lockKind(w) {
1536
+ if (w.permLocked)
1537
+ return "permanent";
1538
+ if (w.oocLocked)
1539
+ return "ooc";
1540
+ return null;
1541
+ }
1542
+ function lockReason(w) {
1543
+ const kind = lockKind(w);
1544
+ if (kind === "permanent")
1545
+ return w.permLockReason?.slice(0, 80) ?? "n/a";
1546
+ if (kind === "ooc")
1547
+ return w.oocLockReason?.slice(0, 80) ?? "n/a";
1548
+ return "n/a";
1549
+ }
1550
+ function clearLocks(w, sid) {
1551
+ if (w.oocLocked) {
1552
+ w.oocLocked = false;
1553
+ w.oocLockReason = null;
1554
+ log("info", `${short(sid)} genuine execution \u2014 clearing OOC lock mod=${MODULE_INSTANCE}`);
1555
+ }
1556
+ w.indeterminateStrikes = 0;
1557
+ if (w.permLocked) {
1558
+ w.permLocked = false;
1559
+ w.permLockReason = null;
1560
+ log("info", `${short(sid)} genuine execution \u2014 clearing permanent-failure lock mod=${MODULE_INSTANCE}`);
1561
+ }
1562
+ w.consecFailureRecovers = 0;
1563
+ if (w.failureLoopLatched) {
1564
+ w.failureLoopLatched = false;
1565
+ log("info", `${short(sid)} genuine execution \u2014 clearing failure-loop latch mod=${MODULE_INSTANCE}`);
1566
+ }
1567
+ }
1568
+ function maybeLockOoc(sid, errMsg, errType = "") {
1569
+ const w = ensureWatch(sid);
1570
+ if (w.oocLocked)
1571
+ return;
1572
+ if (isRateLimitError(errType, errMsg))
1573
+ return;
1574
+ if (!OOC_ERROR_RE.test(errMsg))
1575
+ return;
1576
+ w.oocLocked = true;
1577
+ w.oocLockReason = errMsg.slice(0, 200);
1578
+ log("warn", `${short(sid)} OOC error latched \u2014 recovery locked out until a genuine turn: ${errMsg.slice(0, 120)} mod=${MODULE_INSTANCE}`);
1579
+ }
1580
+ async function buildStallContinueText(sid, reason, attempt, providerFault = false, prospectiveRetries) {
1581
+ const w = ensureWatch(sid);
1582
+ if (opts.continuePrompt !== undefined && opts.continuePrompt !== "")
1583
+ return opts.continuePrompt;
1584
+ if (!richContinuePrompt)
1585
+ return CONTINUE_PROMPT;
1586
+ let suffix = "Todo list unavailable.";
1587
+ try {
1588
+ const todos = await readTodos(sid);
1589
+ const open = todos.filter((t) => t.status !== "completed" && t.status !== "cancelled");
1590
+ if (open.length > 0) {
1591
+ const lines = open.slice(0, 5).map((t) => `\u2022 [${t.status}] ${String(t.content).slice(0, 120)}`);
1592
+ if (open.length > 5)
1593
+ lines.push(`\u2022 \u2026and ${open.length - 5} more`);
1594
+ suffix = `Remaining todos:
1595
+ ${lines.join(`
1596
+ `)}`;
1597
+ } else {
1598
+ suffix = "No open todos recorded.";
1599
+ }
1600
+ } catch {}
1601
+ const now = new Date().toLocaleString("en-US", {
1602
+ month: "short",
1603
+ day: "numeric",
1604
+ hour: "numeric",
1605
+ minute: "2-digit",
1606
+ timeZoneName: "short"
1607
+ });
1608
+ const elapsed = w.lastActivityAt ? Math.round((Date.now() - w.lastActivityAt) / 1000) : 0;
1609
+ const hms = (n) => n < 60 ? `${n}s` : n < 3600 ? `${Math.floor(n / 60)}m ${n % 60}s` : `${Math.floor(n / 3600)}h ${Math.floor(n % 3600 / 60)}m`;
1610
+ const isRateLimitReason = /rate-limit/i.test(reason);
1611
+ const isSilence = isSilenceReason(reason);
1612
+ const shown = providerFault ? attempt : attempt;
1613
+ const retries = prospectiveRetries ?? w.workRetries;
1614
+ const head = [
1615
+ isSilence ? null : reason,
1616
+ isRateLimitReason ? null : `attempt ${Math.min(shown, maxRetries)}/${maxRetries} (retry #${retries})`,
1617
+ isSilence ? `idle ${hms(elapsed)}` : null,
1618
+ now
1619
+ ].filter((x) => typeof x === "string" && x.length > 0).join("; ");
1620
+ return (`continue \u2014 stalled (${head}).
1621
+ Only continue work already in progress. ` + `Do not start anything new, and do not take destructive or irreversible actions without explicit user approval.
1622
+ ${suffix}`).slice(0, 2000);
1623
+ }
1624
+ async function recover(sid, reason) {
1625
+ const w = ensureWatch(sid);
1626
+ if (isPaused(sid)) {
1627
+ dbg(`${short(sid)} paused \u2014 skipping recover (${reason})`);
1628
+ return;
1629
+ }
1630
+ if (w.recovering || w.aborting || w.gaveUp || w.userCancelled || w.permissionPending)
1631
+ return;
1632
+ if (w.failureLoopLatched) {
1633
+ dbg(`${short(sid)} failure loop latched \u2014 standing down until a genuine turn or fresh model output`);
1634
+ return;
1635
+ }
1636
+ if (reason.startsWith("session.step.failed") || reason.startsWith("session.execution.failed")) {
1637
+ w.consecFailureRecovers++;
1638
+ if (w.consecFailureRecovers > FAILURE_LOOP_MAX) {
1639
+ w.failureLoopLatched = true;
1640
+ log("warn", `${short(sid)} ${w.consecFailureRecovers} consecutive failure recoveries with no model output \u2014 backend likely down, standing down until a genuine turn mod=${MODULE_INSTANCE}`);
1641
+ return;
1642
+ }
1643
+ }
1644
+ {
1645
+ const kind = lockKind(w);
1646
+ if (kind) {
1647
+ dbg(`${short(sid)} ${kind} locked \u2014 refusing recovery (reason: ${lockReason(w)})`);
1648
+ return;
1649
+ }
1650
+ }
1651
+ if (w.compacting) {
1652
+ dbg(`${short(sid)} mid-compaction \u2014 refusing recovery (reason: ${reason})`);
1653
+ return;
1654
+ }
1655
+ if (selfAbortActive(w)) {
1656
+ dbg(`${short(sid)} self-abort in flight \u2014 refusing recovery (reason: ${reason})`);
1657
+ return;
1658
+ }
1659
+ if (continuesInWindow(w) >= baseLoopMaxContinues) {
1660
+ log("warn", `${short(sid)} hallucination loop (${baseLoopMaxContinues} continues/${loopWindowMs / 1000}s) \u2014 abort+resume`);
1661
+ await tryAbortAndResume(sid, w);
1662
+ w.continueTimestamps = [];
1663
+ return;
1664
+ }
1665
+ if (w.lastProdText !== "" && w.lastAssistantText !== w.prodAssistantSnapshot) {
1666
+ w.workSinceNudge = true;
1667
+ log("info", `${short(sid)} text growth since last prod \u2014 restarting the stall ladder`);
1668
+ if (w.resumeAttempts > 0)
1669
+ w.episodeRefires++;
1670
+ w.resumeAttempts = 0;
1671
+ if (w.completionSignaled) {
1672
+ w.completionSignaled = false;
1673
+ w.completionSignaledAt = 0;
1674
+ dbg(`${short(sid)} model output after TASK COMPLETE \u2014 re-opening latched completion`);
1675
+ }
1676
+ } else if (isSilenceReason(reason) && w.succeededSinceProd > 0) {
1677
+ w.workSinceNudge = true;
1678
+ log("info", `${short(sid)} progress since last prod \u2014 restarting the stall ladder`);
1679
+ if (w.resumeAttempts > 0)
1680
+ w.episodeRefires++;
1681
+ w.resumeAttempts = 0;
1682
+ }
1683
+ syncCounters(sid, w);
1684
+ if (lifetimeExhausted(sid, w))
1685
+ return;
1686
+ const profile = resolveProfile(sid);
1687
+ const profileMaxRetries = profile.maxRetries;
1688
+ const profileLifetimeMaxContinues = profile.lifetimeMaxContinues;
1689
+ const providerFault = isProviderRetryFresh(w) && (reason.startsWith("session.step.failed") || reason.startsWith("session.execution.failed"));
1690
+ if (!providerFault && w.resumeAttempts >= profileMaxRetries) {
1691
+ log("warn", `${short(sid)} giving up after ${profileMaxRetries} attempts (${reason}) mod=${MODULE_INSTANCE}`);
1692
+ w.gaveUp = true;
1693
+ return;
1694
+ }
1695
+ w.recovering = true;
1696
+ const attempt = providerFault ? Math.max(1, w.resumeAttempts) : w.resumeAttempts + 1;
1697
+ const delay = backoffMs(attempt, baseBackoff, maxBackoff);
1698
+ if (providerFault) {
1699
+ log("info", `${short(sid)} provider-fault stall (${reason}) \u2014 retry scheduled ${Math.round((Date.now() - w.providerRetryAt) / 1000)}s ago, holding the ladder at ${attempt}/${profileMaxRetries}`);
1700
+ }
1701
+ log("info", `${short(sid)} stall detected (${reason}) \u2014 resume attempt ${attempt}/${profileMaxRetries} in ${delay}ms` + ` (work #${w.workRetries} this session)`);
1702
+ if (w.recoverTimer)
1703
+ clearTimeout(w.recoverTimer);
1704
+ const gen = ++w.recoverGeneration;
1705
+ const armedAt = Date.now();
1706
+ let delivered = false;
1707
+ w.recoverTimer = setTimeout(async () => {
1708
+ w.recoverTimer = null;
1709
+ if (gen !== w.recoverGeneration)
1710
+ return;
1711
+ let rungFloor = -1;
1712
+ if (!running)
1713
+ return;
1714
+ try {
1715
+ if (w.userCancelled || w.gaveUp)
1716
+ return;
1717
+ const history = await loadMessages(sid);
1718
+ noteInboundUserMessage(sid, w, history);
1719
+ if (w.lastGenuineUserAt >= armedAt) {
1720
+ dbg(`${short(sid)} genuine user message after arm \u2014 parking recovery`);
1721
+ return;
1722
+ }
1723
+ if (w.status === "idle" && !w.pendingRecoveryArmed)
1724
+ return;
1725
+ if (w.compacting) {
1726
+ dbg(`${short(sid)} mid-compaction at inject time \u2014 not interrupting`);
1727
+ return;
1728
+ }
1729
+ if (w.status === "busy" && Date.now() - w.lastActivityAt < chunkTimeoutMs) {
1730
+ dbg(`${short(sid)} still busy and live at inject time (${Math.round((Date.now() - w.lastActivityAt) / 1000)}s since last event) \u2014 not interrupting`);
1731
+ return;
1732
+ }
1733
+ if (await parentWaitingOnSubagents(sid, new Set(await getActiveSessions()))) {
1734
+ dbg(`${short(sid)} waiting on subagents at inject time \u2014 not interrupting`);
1735
+ return;
1736
+ }
1737
+ if (isWaitingOnUser(sid, w, history)) {
1738
+ dbg(`${short(sid)} waiting on user input at inject time \u2014 not interrupting`);
1739
+ return;
1740
+ }
1741
+ rungFloor = w.workRetries;
1742
+ const prospectiveRetries = w.workRetries + 1;
1743
+ w.workRetries = prospectiveRetries;
1744
+ const progressReportThreshold = Math.max(7, Math.floor(profileLifetimeMaxContinues * 0.7));
1745
+ const isProgressReport = prospectiveRetries >= progressReportThreshold;
1746
+ const stallText = isProgressReport ? `PROGRESS REPORT REQUIRED (attempt ${Math.min(attempt, profileMaxRetries)}/${profileMaxRetries}, retry #${prospectiveRetries}/${profileLifetimeMaxContinues}): this session has been nudged multiple times. Please report exactly what you have completed and what remains. If work is done, call task_complete. If blocked, say what you need. If still working, say what step you are on. Do not start new work \u2014 only continue the current task.` : await buildStallContinueText(sid, reason, attempt, providerFault, prospectiveRetries);
1747
+ const ok = await injectOnce(sid, stallText, isProgressReport ? "stalled \u2014 progress report" : "stalled \u2014 retrying", false, true);
1748
+ w.pendingRecoveryArmed = false;
1749
+ if (ok) {
1750
+ delivered = true;
1751
+ w.workSinceNudge = false;
1752
+ if (!providerFault && attempt > w.resumeAttempts)
1753
+ w.resumeAttempts = attempt;
1754
+ recordContinue(sid);
1755
+ syncCounters(sid, w);
1756
+ w.lastRetryAt = Date.now();
1757
+ touch(sid);
1758
+ markBusy(sid);
1759
+ } else if (attempt >= maxRetries) {
1760
+ await tryAbortAndResume(sid, w);
1761
+ }
1762
+ } finally {
1763
+ if (!delivered && rungFloor >= 0 && w.workRetries === rungFloor + 1)
1764
+ w.workRetries = rungFloor;
1765
+ syncCounters(sid, w);
1766
+ w.recovering = false;
1767
+ if (!delivered) {
1768
+ dbg(`${short(sid)} attempt ${attempt}/${maxRetries} not delivered \u2014 rung not consumed (${reason})`);
1769
+ }
1770
+ }
1771
+ }, delay);
1772
+ }
1773
+ async function readTodos(sid) {
1774
+ const w = ensureWatch(sid);
1775
+ const now = Date.now();
1776
+ if (w.todosFetchedAt && now - w.todosFetchedAt < TODO_CACHE_TTL_MS)
1777
+ return w.todos;
1778
+ const fromMessages = await todosFromMessageSources(sid);
1779
+ if (fromMessages) {
1780
+ w.todos = fromMessages;
1781
+ w.todosFetchedAt = now;
1782
+ return w.todos;
1783
+ }
1784
+ if (!ctx.storage)
1785
+ return w.todos;
1786
+ try {
1787
+ const raw = await ctx.storage.get(`todos/${sid}`);
1788
+ const record = raw;
1789
+ const list = Array.isArray(record?.todos) ? record.todos : [];
1790
+ w.todos = list.filter((t) => !!t && typeof t === "object" && typeof t.content === "string" && typeof t.status === "string");
1791
+ w.todosFetchedAt = now;
1792
+ return w.todos;
1793
+ } catch (e) {
1794
+ dbg(`${short(sid)} todo read failed:`, e instanceof Error ? e.message : String(e));
1795
+ return w.todos;
1796
+ }
1797
+ }
1798
+ async function todosFromMessageSources(sid) {
1799
+ try {
1800
+ const message = ctx.client?.session?.message;
1801
+ if (typeof message?.list === "function") {
1802
+ const res = await message.list.call(message, { path: { id: sid } });
1803
+ const got = todosFromMessages(res?.data ?? res);
1804
+ if (got) {
1805
+ dbg(`${short(sid)} todos via ctx.client.session.message.list (${got.length})`);
1806
+ return got;
1807
+ }
1808
+ }
1809
+ } catch (e) {
1810
+ dbg(`${short(sid)} client message.list failed:`, e instanceof Error ? e.message : String(e));
1811
+ }
1812
+ for (const base of serverBaseUrls()) {
1813
+ try {
1814
+ const res = await fetch(`${base}/api/session/${encodeURIComponent(sid)}/message?limit=200`, {
1815
+ headers: serverHeaders()
1816
+ });
1817
+ if (!res.ok) {
1818
+ dbg(`${short(sid)} HTTP message read ${res.status} from ${base}`);
1819
+ continue;
1820
+ }
1821
+ const body = await res.json();
1822
+ const got = todosFromMessages(body?.data ?? body);
1823
+ if (got) {
1824
+ dbg(`${short(sid)} todos via HTTP ${base} (${got.length})`);
1825
+ return got;
1826
+ }
1827
+ } catch (e) {
1828
+ dbg(`${short(sid)} HTTP message read failed:`, e instanceof Error ? e.message : String(e));
1829
+ }
1830
+ }
1831
+ return;
1832
+ }
1833
+ function schedulePatternPass(sid) {
1834
+ const w = ensureWatch(sid);
1835
+ if (w.toolTextTimer)
1836
+ clearTimeout(w.toolTextTimer);
1837
+ w.toolTextTimer = setTimeout(() => {
1838
+ w.toolTextTimer = null;
1839
+ if (w.status !== "idle") {
1840
+ dbg(`${short(sid)} pattern pass skipped \u2014 session is ${w.status}, not idle`);
1841
+ return;
1842
+ }
1843
+ if (w.userCancelled)
1844
+ return;
1845
+ inspectOnIdle(sid, "pattern");
1846
+ }, toolTextCheckDelayMs);
1847
+ dbg(`${short(sid)} pattern pass armed for +${toolTextCheckDelayMs}ms`);
1848
+ }
1849
+ async function targetedRecovery(sid, kind, prompt, budgetKey) {
1850
+ const w = ensureWatch(sid);
1851
+ if (w.recovering || w.userCancelled || w.permissionPending)
1852
+ return;
1853
+ {
1854
+ const locked = lockKind(w);
1855
+ if (locked) {
1856
+ dbg(`${short(sid)} locked (${locked}) \u2014 skipping ${kind} nudge`);
1857
+ return;
1858
+ }
1859
+ }
1860
+ if (w.compacting) {
1861
+ dbg(`${short(sid)} mid-compaction \u2014 skipping ${kind} nudge`);
1862
+ return;
1863
+ }
1864
+ const busyShells = openShellCount(sid);
1865
+ if (busyShells > 0) {
1866
+ dbg(`${short(sid)} ${busyShells} shell(s) still running \u2014 skipping ${kind} nudge`);
1867
+ return;
1868
+ }
1869
+ if (selfAbortActive(w)) {
1870
+ dbg(`${short(sid)} self-abort in flight \u2014 skipping ${kind} nudge`);
1871
+ return;
1872
+ }
1873
+ if (continuesInWindow(w) >= baseLoopMaxContinues) {
1874
+ log("warn", `${short(sid)} loop guard before ${kind} nudge \u2014 abort+resume`);
1875
+ await tryAbortAndResume(sid, w);
1876
+ w.continueTimestamps = [];
1877
+ return;
1878
+ }
1879
+ if (lifetimeExhausted(sid, w))
1880
+ return;
1881
+ recordContinue(sid);
1882
+ syncCounters(sid, w);
1883
+ if (w[budgetKey] >= maxRetries) {
1884
+ dbg(`${short(sid)} ${kind} budget exhausted`);
1885
+ return;
1886
+ }
1887
+ const attemptNum = w[budgetKey] + 1;
1888
+ log("info", `${short(sid)} ${kind} detected \u2014 sending targeted prompt (${attemptNum}/${maxRetries})`);
1889
+ w.recovering = true;
1890
+ const ok = await injectOnce(sid, prompt, "recovering: " + kind, false, false, quietCorrections);
1891
+ w.recovering = false;
1892
+ if (ok) {
1893
+ w[budgetKey] = attemptNum;
1894
+ touch(sid);
1895
+ markBusy(sid);
1896
+ } else {
1897
+ dbg(`${short(sid)} ${kind} nudge not delivered \u2014 attempt ${attemptNum} not counted`);
1898
+ }
1899
+ }
1900
+ async function loadMessages(sid) {
1901
+ try {
1902
+ const res = await ctx.session.context({ sessionID: sid });
1903
+ const arr = Array.isArray(res) ? res : res?.messages ?? [];
1904
+ if (arr.length > 1) {
1905
+ const at = (m) => {
1906
+ const mm = m;
1907
+ const t = mm?.time?.created ?? mm?.info?.time?.created;
1908
+ return typeof t === "number" ? t : 0;
1909
+ };
1910
+ return [...arr].sort((a, b) => at(a) - at(b));
1911
+ }
1912
+ return arr;
1913
+ } catch (e) {
1914
+ dbg(`${short(sid)} session.context() failed:`, e instanceof Error ? e.message : String(e));
1915
+ return [];
1916
+ }
1917
+ }
1918
+ function lastAssistantTextFrom(messages) {
1919
+ for (let i = messages.length - 1;i >= 0; i--) {
1920
+ const msg = messages[i];
1921
+ if (!msg || msgRole(msg) !== "assistant")
1922
+ continue;
1923
+ if (Array.isArray(msg.content)) {
1924
+ const text = msg.content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("");
1925
+ if (text)
1926
+ return text;
1927
+ continue;
1928
+ }
1929
+ const flat = typeof msg.text === "string" && msg.text || typeof msg.info?.content === "string" && msg.info.content || "";
1930
+ if (flat)
1931
+ return flat;
1932
+ }
1933
+ return "";
1934
+ }
1935
+ function lastAssistantReasoning(messages) {
1936
+ for (let i = messages.length - 1;i >= 0; i--) {
1937
+ const msg = messages[i];
1938
+ if (!msg || msgRole(msg) !== "assistant" || !Array.isArray(msg.content))
1939
+ continue;
1940
+ const reasoning = msg.content.filter((part) => part?.type === "reasoning" && typeof part.text === "string").map((part) => part.text).join("");
1941
+ if (reasoning)
1942
+ return reasoning;
1943
+ }
1944
+ return "";
1945
+ }
1946
+ function lastSilentDeadStream(messages) {
1947
+ for (let i = messages.length - 1;i >= 0; i--) {
1948
+ const msg = messages[i];
1949
+ if (msgRole(msg) !== "assistant")
1950
+ continue;
1951
+ const finish = typeof msg.finish === "string" ? msg.finish : undefined;
1952
+ if (!finish)
1953
+ continue;
1954
+ const parts = Array.isArray(msg.content) ? msg.content : [];
1955
+ const hasText = parts.some((p) => p?.type === "text" && typeof p.text === "string" && p.text.length > 0);
1956
+ if (hasText)
1957
+ return null;
1958
+ const hasToolCalls = parts.some((p) => {
1959
+ const t = p?.type ?? "";
1960
+ return t === "tool_use" || t === "tool" || t === "tool_call" || t.startsWith("tool");
1961
+ });
1962
+ if (hasToolCalls)
1963
+ return null;
1964
+ return { finish, outputTokens: posNum(msg.tokens?.output) };
1965
+ }
1966
+ return null;
1967
+ }
1968
+ async function recoverSilentDeadStream(sid, messages) {
1969
+ const dead = lastSilentDeadStream(messages);
1970
+ if (!dead)
1971
+ return false;
1972
+ if (dead.outputTokens < silentDeadStreamMinTokens) {
1973
+ dbg(`${short(sid)} last finished message has no text, but only ${dead.outputTokens} output tokens (floor ${silentDeadStreamMinTokens}) \u2014 not treating it as a dead stream`);
1974
+ return false;
1975
+ }
1976
+ const w = ensureWatch(sid);
1977
+ if (w.toolsInFlightAtIdle > 0) {
1978
+ dbg(`${short(sid)} silent dead stream, but ${w.toolsInFlightAtIdle} tool(s) still in flight \u2014 working, not stalled`);
1979
+ return true;
1980
+ }
1981
+ if (w.status === "busy") {
1982
+ dbg(`${short(sid)} silent dead stream, but the session is busy again \u2014 genuine restart, leaving it alone`);
1983
+ return true;
1984
+ }
1985
+ if ((await getActiveSessions()).includes(sid)) {
1986
+ dbg(`${short(sid)} silent dead stream, but the server still lists the session \u2014 possibly a provider retry; deferring, not burying`);
1987
+ return false;
1988
+ }
1989
+ w.pendingRecoveryArmed = true;
1990
+ log("info", `${short(sid)} silent dead stream: finish=${dead.finish}, ${dead.outputTokens} output tokens, no text parts; resuming`);
1991
+ await recover(sid, `Silent dead stream (${dead.finish})`);
1992
+ return true;
1993
+ }
1994
+ async function nudgeEmptyIdleTurn(sid, w, messages) {
1995
+ if (w.emptyIdleNudged)
1996
+ return false;
1997
+ if (w.userCancelled || w.gaveUp || w.permissionPending)
1998
+ return false;
1999
+ if (w.completionSignaled)
2000
+ return false;
2001
+ if (w.failureLoopLatched) {
2002
+ dbg(`${short(sid)} failure loop latched \u2014 standing down until a genuine turn`);
2003
+ return false;
2004
+ }
2005
+ if (w.recovering || w.resumeAttempts > 0)
2006
+ return false;
2007
+ if (hasAssistantReplySinceUser(messages))
2008
+ return false;
2009
+ if (isWaitingOnUser(sid, w, messages)) {
2010
+ dbg(`${short(sid)} empty idle turn is waiting on the user \u2014 not nudging`);
2011
+ return false;
2012
+ }
2013
+ {
2014
+ const kind = lockKind(w);
2015
+ if (kind) {
2016
+ dbg(`${short(sid)} ${kind} locked \u2014 refusing empty-idle nudge (reason: ${lockReason(w)})`);
2017
+ return false;
2018
+ }
2019
+ }
2020
+ if (w.compacting) {
2021
+ dbg(`${short(sid)} mid-compaction \u2014 refusing empty-idle nudge`);
2022
+ return false;
2023
+ }
2024
+ if (selfAbortActive(w)) {
2025
+ dbg(`${short(sid)} self-abort in flight \u2014 refusing empty-idle nudge`);
2026
+ return false;
2027
+ }
2028
+ if (continuesInWindow(w) >= baseLoopMaxContinues) {
2029
+ log("warn", `${short(sid)} hallucination loop (${baseLoopMaxContinues} continues/${loopWindowMs / 1000}s) \u2014 abort+resume`);
2030
+ await tryAbortAndResume(sid, w);
2031
+ w.continueTimestamps = [];
2032
+ return true;
2033
+ }
2034
+ w.emptyIdleNudged = true;
2035
+ if (lifetimeExhausted(sid, w))
2036
+ return true;
2037
+ recordContinue(sid);
2038
+ syncCounters(sid, w);
2039
+ log("info", `${short(sid)} idle with no reply, no question, no completion claim \u2014 instant nudge (single-shot this episode)`);
2040
+ const wasSelfRecovery = w.selfRecovery;
2041
+ const sent = await notifyAndPrompt(sid, opts.continuePrompt ?? SAFE_EMPTY_IDLE_PROMPT, "empty-idle nudge");
2042
+ if (!wasSelfRecovery)
2043
+ w.selfRecovery = false;
2044
+ return sent;
2045
+ }
2046
+ function hasAssistantReplySinceUser(messages) {
2047
+ for (let i = messages.length - 1;i >= 0; i--) {
2048
+ const m = messages[i];
2049
+ if (!m || typeof m !== "object")
2050
+ continue;
2051
+ const role = msgRole(m);
2052
+ if (role === "user")
2053
+ return false;
2054
+ if (role === "assistant")
2055
+ return true;
2056
+ }
2057
+ return false;
2058
+ }
2059
+ function hasPendingUserInput(messages) {
2060
+ const newest = messages[messages.length - 1];
2061
+ if (newest?.type !== "assistant")
2062
+ return false;
2063
+ for (const part of newest.content ?? []) {
2064
+ const t = part?.type ?? "";
2065
+ if (!(t === "tool_use" || t === "tool" || t === "tool_call" || t.startsWith("tool")))
2066
+ continue;
2067
+ const status = part?.state?.status;
2068
+ if (status === TOOL_STATE_RUNNING || status === TOOL_STATE_PENDING)
2069
+ return true;
2070
+ if (status !== TOOL_STATE_COMPLETED && AWAITING_USER_TOOLS.has(part?.name ?? ""))
2071
+ return true;
2072
+ }
2073
+ return false;
2074
+ }
2075
+ function isWaitingOnUser(sid, w, messages) {
2076
+ noteInboundUserMessage(sid, w, messages);
2077
+ const newest = messages[messages.length - 1];
2078
+ if (newest?.type !== "assistant")
2079
+ return false;
2080
+ for (const part of newest.content ?? []) {
2081
+ const t = part?.type ?? "";
2082
+ if (!(t === "tool_use" || t === "tool" || t === "tool_call" || t.startsWith("tool")))
2083
+ continue;
2084
+ if (!AWAITING_USER_TOOLS.has(part?.name ?? ""))
2085
+ continue;
2086
+ if (part?.state?.status !== TOOL_STATE_COMPLETED)
2087
+ return true;
2088
+ }
2089
+ return false;
2090
+ }
2091
+ function lastUserMessageAt(messages) {
2092
+ for (let i = messages.length - 1;i >= 0; i--) {
2093
+ const m = messages[i];
2094
+ const isUser = msgRole(m) === "user";
2095
+ if (!isUser)
2096
+ continue;
2097
+ const ts = m.time?.created ?? m.info?.time?.created;
2098
+ return typeof ts === "number" ? ts : undefined;
2099
+ }
2100
+ return;
2101
+ }
2102
+ async function shouldStandDownForUser(sid, messages, activeUserWindowMs) {
2103
+ try {
2104
+ if (messages.length === 0)
2105
+ return false;
2106
+ if (hasPendingUserInput(messages))
2107
+ return true;
2108
+ const lastUserAt = lastUserMessageAt(messages);
2109
+ if (lastUserAt !== undefined && Date.now() - lastUserAt < activeUserWindowMs)
2110
+ return true;
2111
+ return false;
2112
+ } catch {
2113
+ return false;
2114
+ }
2115
+ }
2116
+ function rememberOwnPrompts(w, messages) {
2117
+ const start = Math.max(0, messages.length - 40);
2118
+ for (let i = start;i < messages.length; i++) {
2119
+ const m = messages[i];
2120
+ if (msgRole(m) !== "user")
2121
+ continue;
2122
+ const text = messageTextOf(m);
2123
+ if (!looksLikeOwnPrompt(text))
2124
+ continue;
2125
+ if (!w.ownPromptTexts.includes(text)) {
2126
+ w.ownPromptTexts.push(text);
2127
+ if (w.ownPromptTexts.length > 10)
2128
+ w.ownPromptTexts.shift();
2129
+ }
2130
+ const at = msgAt(m);
2131
+ if (at > 0 && at > w.lastInjectAt)
2132
+ w.lastInjectAt = at;
2133
+ }
2134
+ }
2135
+ function noteInboundUserMessage(sid, w, messages) {
2136
+ rememberOwnPrompts(w, messages);
2137
+ let latest = null;
2138
+ for (let i = messages.length - 1;i >= 0; i--) {
2139
+ const m = messages[i];
2140
+ const isUser = msgRole(m) === "user";
2141
+ if (!isUser)
2142
+ continue;
2143
+ const text = messageTextOf(m);
2144
+ latest = { id: typeof m.id === "string" ? m.id : undefined, at: m.time?.created ?? m.info?.time?.created, text };
2145
+ break;
2146
+ }
2147
+ if (!latest)
2148
+ return;
2149
+ const isNew = latest.id ? latest.id !== w.lastUserMessageID : typeof latest.at === "number" && latest.at > w.lastUserMessageSeenAt;
2150
+ if (!isNew)
2151
+ return;
2152
+ w.lastUserMessageID = latest.id;
2153
+ if (typeof latest.at === "number")
2154
+ w.lastUserMessageSeenAt = latest.at;
2155
+ const ownByText = typeof latest.text === "string" && latest.text.length > 0 && w.ownPromptTexts.includes(latest.text);
2156
+ const ownByShape = looksLikeOwnPrompt(latest.text);
2157
+ const ownByTime = typeof latest.at === "number" && w.lastInjectAt > 0 && latest.at >= w.lastInjectAt && latest.at - w.lastInjectAt < OWN_PROMPT_TIME_MS;
2158
+ const ownByTimeOnly = ownByTime && !(typeof latest.text === "string" && latest.text.length > 0);
2159
+ if (ownByText || ownByShape || ownByTimeOnly) {
2160
+ dbg(`${short(sid)} newest user message is our own prompt \u2014 not re-arming budgets`);
2161
+ return;
2162
+ }
2163
+ if (w.doneClaimAttempts > 0 || w.doneClaimOpenTodosAttempts > 0) {
2164
+ dbg(`${short(sid)} new user message \u2014 re-arming the done-claim budgets`);
2165
+ }
2166
+ w.doneClaimAttempts = 0;
2167
+ w.doneClaimOpenTodosAttempts = 0;
2168
+ if (w.userCancelled && typeof latest.at === "number" && latest.at >= w.userCancelledAt) {
2169
+ dbg(`${short(sid)} new user message after interrupt \u2014 re-arming recovery`);
2170
+ w.userCancelled = false;
2171
+ }
2172
+ w.taskCompleteSignals = 0;
2173
+ if (w.unknownToolErrors.size > 0 || w.unknownToolSuggestionSent) {
2174
+ dbg(`${short(sid)} new user message \u2014 re-arming the unknown-tool budget`);
2175
+ }
2176
+ w.unknownToolErrors.clear();
2177
+ w.unknownToolSuggestionSent = false;
2178
+ w.stuckToolAttempts = 0;
2179
+ w.lastGenuineUserAt = typeof latest.at === "number" ? Math.min(latest.at, Date.now()) : Date.now();
2180
+ touch(sid);
2181
+ if (w.completionSignaled) {
2182
+ w.completionSignaled = false;
2183
+ w.completionSignaledAt = 0;
2184
+ dbg(`${short(sid)} genuine new user message \u2014 re-opening latched completion`);
2185
+ }
2186
+ if (w.resumeAttempts > 0) {
2187
+ dbg(`${short(sid)} new user message \u2014 restarting the stall ladder`);
2188
+ }
2189
+ w.resumeAttempts = 0;
2190
+ w.providerRetryAt = 0;
2191
+ w.episodeRefires = 0;
2192
+ w.lifetimeContinues = 0;
2193
+ w.workRetries = 0;
2194
+ if (ctx.storage?.remove) {
2195
+ ctx.storage.remove(`${COUNTER_STATE_PREFIX}${sid}`).catch(() => {});
2196
+ }
2197
+ w.gaveUp = false;
2198
+ w.emptyIdleNudged = false;
2199
+ }
2200
+ let cachedToolIds = null;
2201
+ let cachedToolIdsAt = 0;
2202
+ async function getAvailableToolIds() {
2203
+ if (cachedToolIds && Date.now() - cachedToolIdsAt < TOOL_IDS_CACHE_MS)
2204
+ return cachedToolIds;
2205
+ if (!ctx.tool?.list)
2206
+ return cachedToolIds ?? [];
2207
+ try {
2208
+ const listed = await ctx.tool.list();
2209
+ const ids = listed.map((t) => typeof t?.id === "string" && t.id ? t.id : typeof t?.name === "string" ? t.name : "").filter((n) => n.length > 0);
2210
+ cachedToolIds = ids;
2211
+ cachedToolIdsAt = Date.now();
2212
+ return ids;
2213
+ } catch (e) {
2214
+ log("warn", `failed to list tools: ${e instanceof Error ? e.message : String(e)}`);
2215
+ return cachedToolIds ?? [];
2216
+ }
2217
+ }
2218
+ function scanToolParts(messages, lastUserAt, cutoff) {
2219
+ const stuck = [];
2220
+ let trailing = null;
2221
+ let anythingInFlight = false;
2222
+ for (const m of messages) {
2223
+ if (!m || msgRole(m) !== "assistant" || !Array.isArray(m.content))
2224
+ continue;
2225
+ for (const part of m.content) {
2226
+ if (part?.type !== "tool") {
2227
+ trailing = null;
2228
+ continue;
2229
+ }
2230
+ const st = part?.state?.status;
2231
+ const name = typeof part?.name === "string" && part.name ? part.name : "(unnamed)";
2232
+ const created = typeof part?.time?.created === "number" ? part.time.created : typeof m?.time?.created === "number" ? m.time.created : 0;
2233
+ if (st === "error") {
2234
+ const rawErr = part?.state && typeof part.state === "object" ? part.state.error : undefined;
2235
+ const eType = rawErr && typeof rawErr === "object" && typeof rawErr.type === "string" ? rawErr.type : "";
2236
+ const eMsg = toolErrorText(part?.state);
2237
+ if (isAbortError(eType, eMsg)) {
2238
+ trailing = null;
2239
+ continue;
2240
+ }
2241
+ if (NEVER_RETRY_RE.test(eMsg)) {
2242
+ trailing = null;
2243
+ continue;
2244
+ }
2245
+ trailing = { name, err: eMsg.slice(0, 160), created };
2246
+ continue;
2247
+ }
2248
+ if (st === "completed") {
2249
+ trailing = null;
2250
+ continue;
2251
+ }
2252
+ if (created <= lastUserAt)
2253
+ continue;
2254
+ anythingInFlight = true;
2255
+ if (created >= cutoff)
2256
+ continue;
2257
+ if (!stuck.includes(name))
2258
+ stuck.push(name);
2259
+ }
2260
+ }
2261
+ return { stuck, trailing, anythingInFlight };
2262
+ }
2263
+ function buildStuckNudgePrompt(stuck) {
2264
+ const names = stuck.slice(0, 3).join('", "');
2265
+ return `A "${names}" call never returned a result and the turn went idle waiting on it. ` + `Proceed without it: continue the task from where you left off and do not retry the same call.`;
2266
+ }
2267
+ async function checkForStuckToolCalls(sid, w) {
2268
+ if (w.userCancelled || w.completionSignaled)
2269
+ return false;
2270
+ {
2271
+ const locked = lockKind(w);
2272
+ if (locked) {
2273
+ dbg(`${short(sid)} locked (${locked}) \u2014 skipping stuck-tool pass`);
2274
+ return false;
2275
+ }
2276
+ }
2277
+ const messages = await loadMessages(sid);
2278
+ let lastUserAt = 0;
2279
+ for (const m of messages) {
2280
+ if (msgRole(m) === "user")
2281
+ lastUserAt = Math.max(lastUserAt, msgAt(m));
2282
+ }
2283
+ const cutoff = Date.now() - stuckToolAgeMs;
2284
+ const { stuck, trailing, anythingInFlight } = scanToolParts(messages, lastUserAt, cutoff);
2285
+ if (trailing && !anythingInFlight && trailing.created > lastUserAt) {
2286
+ const available = await getAvailableToolIds();
2287
+ if (available.length > 0 && available.includes(trailing.name)) {
2288
+ const prompt = `The "${trailing.name}" call failed${trailing.err ? ` with: ${trailing.err}` : ""}. ` + `Retry it now \u2014 reissue the same call.`;
2289
+ log("warn", `${short(sid)} "${trailing.name}" failed at end of turn, retrying promptly (attempt ${(w.stuckToolAttempts ?? 0) + 1}/${maxRetries})`);
2290
+ return targetedRecovery(sid, "stuck-tool", prompt, "stuckToolAttempts").then(() => true);
2291
+ }
2292
+ }
2293
+ if (stuck.length === 0)
2294
+ return false;
2295
+ const prompt = buildStuckNudgePrompt(stuck);
2296
+ const names = stuck.slice(0, 3).join('", "');
2297
+ log("warn", `${short(sid)} tool call(s) "${names}" stuck unfinished \u2014 nudging to proceed without it`);
2298
+ return targetedRecovery(sid, "stuck-tool", prompt, "stuckToolAttempts").then(() => true);
2299
+ }
2300
+ async function checkForSilentReasoningTurn(sid, w) {
2301
+ if (w.userCancelled || w.completionSignaled)
2302
+ return false;
2303
+ {
2304
+ const locked = lockKind(w);
2305
+ if (locked) {
2306
+ dbg(`${short(sid)} locked (${locked}) \u2014 skipping silent-reasoning pass`);
2307
+ return false;
2308
+ }
2309
+ }
2310
+ const messages = await loadMessages(sid);
2311
+ let lastUserAt = 0;
2312
+ for (const m of messages) {
2313
+ if (msgRole(m) === "user")
2314
+ lastUserAt = Math.max(lastUserAt, msgAt(m));
2315
+ }
2316
+ let newestPostUserAt = 0;
2317
+ let sawReasoning = false;
2318
+ for (const m of messages) {
2319
+ if (!m || msgRole(m) !== "assistant" || !Array.isArray(m.content))
2320
+ continue;
2321
+ const at = msgAt(m);
2322
+ if (at <= lastUserAt)
2323
+ continue;
2324
+ if (at > newestPostUserAt)
2325
+ newestPostUserAt = at;
2326
+ for (const part of m.content) {
2327
+ const t = part?.type ?? "";
2328
+ if (t === "text" && typeof part?.text === "string" && part.text.length > 0)
2329
+ return false;
2330
+ if (t === "tool_use" || t === "tool" || t === "tool_call" || t.startsWith("tool"))
2331
+ return false;
2332
+ if (t === "reasoning" && typeof part?.text === "string" && part.text.length > 0)
2333
+ sawReasoning = true;
2334
+ }
2335
+ }
2336
+ if (!sawReasoning)
2337
+ return false;
2338
+ if (Date.now() - newestPostUserAt < chunkTimeoutMs)
2339
+ return false;
2340
+ const prompt = `Your last turn produced reasoning but no visible output and no tool calls. ` + `Continue the task: produce the answer or the next tool call.`;
2341
+ log("warn", `${short(sid)} reasoning-only turn with no output \u2014 nudging to continue`);
2342
+ return targetedRecovery(sid, "silent-reasoning", prompt, "stuckToolAttempts").then(() => true);
2343
+ }
2344
+ async function checkForUnknownToolCalls(sid, w) {
2345
+ if (w.userCancelled || w.completionSignaled)
2346
+ return false;
2347
+ {
2348
+ const locked = lockKind(w);
2349
+ if (locked) {
2350
+ dbg(`${short(sid)} locked (${locked}) \u2014 skipping unknown-tool pass`);
2351
+ return false;
2352
+ }
2353
+ }
2354
+ try {
2355
+ const messages = await loadMessages(sid);
2356
+ noteInboundUserMessage(sid, w, messages);
2357
+ if (containsStopStatement(w.lastAssistantText || lastAssistantTextFrom(messages)))
2358
+ return false;
2359
+ if (w.unknownToolSuggestionSent)
2360
+ return false;
2361
+ const available = await getAvailableToolIds();
2362
+ if (available.length === 0)
2363
+ return false;
2364
+ for (const msg of messages) {
2365
+ const content = msg?.content;
2366
+ if (!Array.isArray(content))
2367
+ continue;
2368
+ for (const part of content) {
2369
+ if (part?.type !== "tool")
2370
+ continue;
2371
+ const partId = typeof part.id === "string" ? part.id : "";
2372
+ if (!partId || w.checkedToolPartIDs.has(partId))
2373
+ continue;
2374
+ w.checkedToolPartIDs.add(partId);
2375
+ if (w.checkedToolPartIDs.size > CHECKED_TOOL_PART_IDS_MAX) {
2376
+ const oldest = w.checkedToolPartIDs.values().next().value;
2377
+ if (typeof oldest === "string")
2378
+ w.checkedToolPartIDs.delete(oldest);
2379
+ }
2380
+ const state = part.state;
2381
+ if (state?.status !== "error")
2382
+ continue;
2383
+ const toolName = typeof part.name === "string" ? part.name : "";
2384
+ if (!toolName || available.includes(toolName))
2385
+ continue;
2386
+ const count = (w.unknownToolErrors.get(toolName) ?? 0) + 1;
2387
+ w.unknownToolErrors.set(toolName, count);
2388
+ if (count < UNKNOWN_TOOL_THRESHOLD)
2389
+ continue;
2390
+ const suggestion = resolveToolSuggestion(toolName, available);
2391
+ const toolList = available.slice(0, UNKNOWN_TOOL_LIST_LIMIT).join(", ");
2392
+ const prompt = suggestion ? `You tried to use the tool "${toolName}" ${count} times, but it does not exist. ` + `The closest matching tool is "${suggestion}". ` + `Please use "${suggestion}" instead and adjust your arguments accordingly. ` + `Available tools include: ${toolList}.` : `You tried to use the tool "${toolName}" ${count} times, but it does not exist. ` + `Please check the available tools and use the correct one. ` + `Available tools include: ${toolList}.`;
2393
+ w.unknownToolSuggestionSent = true;
2394
+ log("warn", `${short(sid)} unknown tool "${toolName}" called ${count}x, suggesting "${suggestion ?? "(none)"}" mod=${MODULE_INSTANCE}`);
2395
+ await injectOnce(sid, prompt, "unknown-tool", false, false, quietCorrections);
2396
+ return true;
2397
+ }
2398
+ }
2399
+ return false;
2400
+ } catch (e) {
2401
+ log("warn", `${short(sid)} unknown-tool check failed: ${e instanceof Error ? e.message : String(e)}`);
2402
+ return false;
2403
+ }
2404
+ }
2405
+ async function registerTaskCompleteFallback() {
2406
+ if (!ctx.tool?.transform) {
2407
+ dbg("tool registry unavailable \u2014 task_complete not offered");
2408
+ return;
2409
+ }
2410
+ let provided = false;
2411
+ try {
2412
+ const listed = await ctx.tool.list?.() ?? [];
2413
+ const arr = Array.isArray(listed) ? listed : [];
2414
+ provided = arr.some((t) => {
2415
+ const n = String(t?.id ?? t?.name ?? t ?? "").toLowerCase();
2416
+ return n === "task_complete" || n.endsWith("/task_complete");
2417
+ });
2418
+ } catch (e) {
2419
+ dbg("tool list failed \u2014 checking the config instead:", e instanceof Error ? e.message : String(e));
2420
+ }
2421
+ if (!provided) {
2422
+ const cfgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "opencode.jsonc");
2423
+ provided = await configMentionsTaskComplete(cfgPath);
2424
+ if (provided)
2425
+ log("info", "task-complete plugin configured in opencode.jsonc \u2014 fallback stays out");
2426
+ }
2427
+ if (provided)
2428
+ return;
2429
+ try {
2430
+ await ctx.tool.transform((editor) => {
2431
+ editor.add({
2432
+ name: "task_complete",
2433
+ description: TASK_COMPLETE_DESCRIPTION,
2434
+ options: { codemode: false },
2435
+ input: { type: "object", properties: {}, additionalProperties: false },
2436
+ execute: async (_input, toolCtx) => {
2437
+ const sid = toolCtx?.sessionID;
2438
+ if (!sid)
2439
+ return { content: TASK_COMPLETE_ACK };
2440
+ const w = ensureWatch(sid);
2441
+ if (!await isSubAgentSession(sid)) {
2442
+ const open = getOpenTodos(await readTodos(sid));
2443
+ if (open.length > 0 && w.taskCompleteOverrides < maxRetries) {
2444
+ w.taskCompleteOverrides++;
2445
+ w.taskCompleteSignals = 0;
2446
+ const reminder = buildOpenTodosReminder(open);
2447
+ const blockMsg = `Mark any finished todos complete and do not redo completed work.
2448
+ ${reminder}`;
2449
+ log("info", `${short(sid)} task_complete blocked: ${open.length} open todos remain (override ${w.taskCompleteOverrides}/${maxRetries})`);
2450
+ let awaitingInput = false;
2451
+ try {
2452
+ awaitingInput = hasPendingUserInput(await loadMessages(sid));
2453
+ } catch (e) {
2454
+ dbg(`${short(sid)} task_complete block: awaiting-input check failed:`, e instanceof Error ? e.message : String(e));
2455
+ }
2456
+ if (awaitingInput) {
2457
+ log("info", `${short(sid)} task_complete blocked but user input pending \u2014 skipping visible nudge`);
2458
+ } else {
2459
+ await injectOnce(sid, blockMsg, "task-complete-blocked");
2460
+ }
2461
+ return { content: blockMsg };
2462
+ }
2463
+ }
2464
+ w.completionSignaled = true;
2465
+ w.completionSignaledAt = Date.now();
2466
+ log("info", `${short(sid)} task_complete called, ${await isSubAgentSession(sid) ? "subagent" : "agent"} done`);
2467
+ w.taskCompleteSignals++;
2468
+ if (w.taskCompleteSignals === 2)
2469
+ return { content: TASK_COMPLETE_REPEAT_WARNING };
2470
+ if (w.taskCompleteSignals > 2)
2471
+ throw new Error(TASK_COMPLETE_REPEAT_ERROR);
2472
+ return { content: TASK_COMPLETE_ACK };
2473
+ }
2474
+ });
2475
+ });
2476
+ log("info", "registered the task_complete tool (fallback \u2014 the task-complete plugin is not installed)");
2477
+ } catch (e) {
2478
+ const msg = e instanceof Error ? e.message : String(e);
2479
+ log("warn", `task_complete registration failed (${msg}) \u2014 continuing without it`);
2480
+ }
2481
+ }
2482
+ async function observeTaskCompleteTool() {
2483
+ if (!ctx.tool?.hook) {
2484
+ dbg("tool hook unavailable \u2014 task_complete stand-down not observed");
2485
+ return;
2486
+ }
2487
+ try {
2488
+ ctx.tool.hook("execute.after", async (event) => {
2489
+ if (event?.tool !== "task_complete")
2490
+ return;
2491
+ if (event?.status !== "completed")
2492
+ return;
2493
+ const sid = event?.sessionID;
2494
+ if (!sid)
2495
+ return;
2496
+ const w = ensureWatch(sid);
2497
+ const result = event?.result;
2498
+ if (result?.metadata?.completed === false) {
2499
+ const content = typeof result?.content === "string" ? result.content : "";
2500
+ if (!content)
2501
+ return;
2502
+ let awaitingInput = false;
2503
+ try {
2504
+ awaitingInput = hasPendingUserInput(await loadMessages(sid));
2505
+ } catch (e) {
2506
+ dbg(`${short(sid)} task_complete block: awaiting-input check failed:`, e instanceof Error ? e.message : String(e));
2507
+ }
2508
+ if (awaitingInput) {
2509
+ log("info", `${short(sid)} task_complete blocked but user input pending \u2014 skipping visible nudge`);
2510
+ return;
2511
+ }
2512
+ await injectOnce(sid, content, "task-complete-blocked");
2513
+ return;
2514
+ }
2515
+ w.completionSignaled = true;
2516
+ w.completionSignaledAt = Date.now();
2517
+ w.taskCompleteSignals = 0;
2518
+ w.taskCompleteOverrides = 0;
2519
+ log("info", `${short(sid)} task_complete called, ${await isSubAgentSession(sid) ? "subagent" : "agent"} done`);
2520
+ });
2521
+ } catch (e) {
2522
+ const msg = e instanceof Error ? e.message : String(e);
2523
+ log("warn", `task_complete observer failed (${msg}) \u2014 continuing without the stand-down`);
2524
+ }
2525
+ }
2526
+ async function inspectOnIdle(sid, phase = "structural") {
2527
+ const w = ensureWatch(sid);
2528
+ const messages = await loadMessages(sid);
2529
+ noteInboundUserMessage(sid, w, messages);
2530
+ if (await recoverSilentDeadStream(sid, messages))
2531
+ return;
2532
+ if (await checkForStuckToolCalls(sid, w))
2533
+ return;
2534
+ if (await checkForSilentReasoningTurn(sid, w))
2535
+ return;
2536
+ let text = w.lastAssistantText;
2537
+ if (!text)
2538
+ text = lastAssistantTextFrom(messages);
2539
+ if (!text) {
2540
+ await nudgeEmptyIdleTurn(sid, w, messages);
2541
+ return;
2542
+ }
2543
+ if (isUserHandoff(text)) {
2544
+ dbg(`${short(sid)} idle turn ends with a user hand-off \u2014 skipping targeted recovery`);
2545
+ return;
2546
+ }
2547
+ if (await shouldStandDownForUser(sid, messages, activeUserWindowMs)) {
2548
+ {
2549
+ const tailText = w.lastAssistantText || lastAssistantTextFrom(messages);
2550
+ if (tailText && turnEndsWithCompletionClaim(tailText)) {
2551
+ const open = getOpenTodos(await readTodos(sid));
2552
+ if (open.length === 0 && !w.completionSignaled) {
2553
+ w.completionSignaled = true;
2554
+ w.completionSignaledAt = Date.now();
2555
+ log("info", `${short(sid)} turn ends with a completion claim inside the activity window \u2014 latching completion, standing down`);
2556
+ }
2557
+ } else {
2558
+ dbg(`${short(sid)} stand-down evaluation: tail is not a completion claim (score the tail, not the turn)`);
2559
+ }
2560
+ }
2561
+ dbg(`${short(sid)} user has pending input or was recently active \u2014 standing down`);
2562
+ {
2563
+ const lastUser = lastUserMessageAt(messages);
2564
+ const waitMs = (lastUser === undefined ? activeUserWindowMs : Math.max(0, lastUser + activeUserWindowMs - Date.now())) + 1000;
2565
+ if (w.toolTextTimer)
2566
+ clearTimeout(w.toolTextTimer);
2567
+ w.toolTextTimer = setTimeout(() => {
2568
+ w.toolTextTimer = null;
2569
+ if (w.status !== "idle") {
2570
+ dbg(`${short(sid)} stand-down recheck skipped \u2014 session is ${w.status}, not idle`);
2571
+ return;
2572
+ }
2573
+ if (w.userCancelled)
2574
+ return;
2575
+ inspectOnIdle(sid, "pattern");
2576
+ }, waitMs);
2577
+ }
2578
+ return;
2579
+ }
2580
+ if (phase === "structural") {
2581
+ schedulePatternPass(sid);
2582
+ return;
2583
+ }
2584
+ if (endsWithCelebration(text)) {
2585
+ const open = getOpenTodos(await readTodos(sid));
2586
+ if (open.length > 0) {
2587
+ await targetedRecovery(sid, "open-todos-celebration-false-positive", buildOpenTodosReminder(open), "todoNudgeAttempts");
2588
+ return;
2589
+ }
2590
+ if (!w.completionSignaled) {
2591
+ w.completionSignaled = true;
2592
+ w.completionSignaledAt = Date.now();
2593
+ log("info", `${short(sid)} turn ends with a celebration and no open todos \u2014 latching completion, not nudging`);
2594
+ } else {
2595
+ dbg(`${short(sid)} completion already latched \u2014 skipping`);
2596
+ }
2597
+ return;
2598
+ }
2599
+ const reasoning = lastAssistantReasoning(messages);
2600
+ if (reasoning && containsToolCallAsText(reasoning)) {
2601
+ await targetedRecovery(sid, "tool-call-in-reasoning", thinkingToolRecoveryPrompt, "toolTextAttempts");
2602
+ return;
2603
+ }
2604
+ if (containsToolCallAsText(text)) {
2605
+ await targetedRecovery(sid, "tool-call-as-text", opts.toolTextRecoveryPrompt ?? TOOL_TEXT_RECOVERY_PROMPT, "toolTextAttempts");
2606
+ return;
2607
+ }
2608
+ if (containsReadyToContinuePattern(text, readyToContinuePatterns)) {
2609
+ await targetedRecovery(sid, "ready-to-continue", opts.continuePrompt ?? SAFE_CONTINUE_PROMPT, "intentNudgeAttempts");
2610
+ return;
2611
+ }
2612
+ if (resumeOnActionIntent && containsActionIntent(text)) {
2613
+ await targetedRecovery(sid, "action-intent", opts.actionIntentPrompt ?? opts.continuePrompt ?? SAFE_CONTINUE_PROMPT, "intentNudgeAttempts");
2614
+ return;
2615
+ }
2616
+ if (containsStopStatement(text)) {
2617
+ const open = getOpenTodos(await readTodos(sid));
2618
+ if (open.length > 0) {
2619
+ await targetedRecovery(sid, "stop-open-todos", buildOpenTodosReminder(open), "todoNudgeAttempts");
2620
+ return;
2621
+ }
2622
+ if (!w.completionSignaled) {
2623
+ w.completionSignaled = true;
2624
+ w.completionSignaledAt = Date.now();
2625
+ log("info", `${short(sid)} turn ends with an explicit stop and no open todos \u2014 latching completion, not nudging`);
2626
+ } else {
2627
+ dbg(`${short(sid)} completion already latched \u2014 skipping`);
2628
+ }
2629
+ return;
2630
+ }
2631
+ if (containsDoneClaimPattern(text, doneClaimPatterns)) {
2632
+ const open = getOpenTodos(await readTodos(sid));
2633
+ if (open.length > 0) {
2634
+ await targetedRecovery(sid, "done-claim-open-todos", doneWithoutWorkPrompt, "doneClaimOpenTodosAttempts");
2635
+ return;
2636
+ }
2637
+ if (w.doneClaimAttempts < maxRetries) {
2638
+ if (!containsWorkDescription(text)) {
2639
+ await targetedRecovery(sid, "done-claim-no-details", doneWithoutDetailsPrompt, "doneClaimAttempts");
2640
+ } else {
2641
+ dbg(`${short(sid)} done-claim carries a work description \u2014 skipping details prompt`);
2642
+ }
2643
+ }
2644
+ }
2645
+ await checkContextSaturation(sid);
2646
+ }
2647
+ async function getActiveSessions() {
2648
+ try {
2649
+ const fn = ctx.session.active;
2650
+ if (typeof fn !== "function") {
2651
+ const http = await activeSessionsHttp();
2652
+ if (http !== null)
2653
+ return http;
2654
+ return busySessions();
2655
+ }
2656
+ const active = await fn.call(ctx.session);
2657
+ return Object.keys(active ?? {}).filter((k) => typeof k === "string");
2658
+ } catch {
2659
+ const http = await activeSessionsHttp();
2660
+ if (http !== null)
2661
+ return http;
2662
+ return busySessions();
2663
+ }
2664
+ }
2665
+ let activeCache = null;
2666
+ const ACTIVE_TTL_MS = 1e4;
2667
+ async function activeSessionsHttp() {
2668
+ const now = Date.now();
2669
+ if (activeCache && now - activeCache.at < ACTIVE_TTL_MS)
2670
+ return activeCache.ids;
2671
+ for (const base of serverBaseUrls()) {
2672
+ try {
2673
+ const res = await fetch(`${base}/api/session/active`, { headers: serverHeaders() });
2674
+ if (!res.ok)
2675
+ continue;
2676
+ const body = await res.json();
2677
+ const data = body?.data ?? body;
2678
+ if (data && typeof data === "object") {
2679
+ const ids = Object.keys(data).filter((k) => k.startsWith("ses_"));
2680
+ activeCache = { at: now, ids };
2681
+ return ids;
2682
+ }
2683
+ } catch {}
2684
+ }
2685
+ return null;
2686
+ }
2687
+ function busySessionsForOrphanWatch() {
2688
+ const out = [];
2689
+ for (const [sid, w] of sessions) {
2690
+ if (w.status === "busy" && !w.userCancelled)
2691
+ out.push(sid);
2692
+ }
2693
+ return out;
2694
+ }
2695
+ async function listSubagentIds(parentSid) {
2696
+ const listed = unwrapList(await callSessionApi("list", { parentID: parentSid }));
2697
+ const out = [];
2698
+ for (const row of listed) {
2699
+ const sid = row?.id;
2700
+ if (typeof sid !== "string" || !sid.startsWith("ses_"))
2701
+ continue;
2702
+ if (sid === parentSid)
2703
+ continue;
2704
+ if (row.parentID !== parentSid)
2705
+ continue;
2706
+ out.push(sid);
2707
+ }
2708
+ if (out.length === 0) {
2709
+ for (const row of await listSessionsHttp()) {
2710
+ const sid = row?.id;
2711
+ if (typeof sid !== "string" || !sid.startsWith("ses_"))
2712
+ continue;
2713
+ if (sid === parentSid)
2714
+ continue;
2715
+ if (row?.parentID !== parentSid)
2716
+ continue;
2717
+ out.push(sid);
2718
+ }
2719
+ }
2720
+ return out;
2721
+ }
2722
+ async function subagentVerdict(parentSid, activeIDs) {
2723
+ try {
2724
+ const children = await listSubagentIds(parentSid);
2725
+ if (children.length === 0)
2726
+ return { status: "idle" };
2727
+ const now = Date.now();
2728
+ let sawBusy = false;
2729
+ let sawWaiting = null;
2730
+ for (const child of children) {
2731
+ const messages = await loadMessages(child);
2732
+ const last = messages[messages.length - 1];
2733
+ if (last && msgRole(last) === "assistant" && last.error !== undefined) {
2734
+ dbg(`subagent ${short(child)} reported an error`);
2735
+ return { status: "crashed" };
2736
+ }
2737
+ const msgTime = msgAt(last) || undefined;
2738
+ const silentFor = typeof msgTime === "number" ? now - msgTime : Infinity;
2739
+ if (activeIDs.has(child)) {
2740
+ sawBusy = true;
2741
+ continue;
2742
+ }
2743
+ const hasToolCall = (last?.content ?? []).some((p) => p?.type === "tool" && p.state?.status !== "completed" && p.state?.status !== "error");
2744
+ const limit = hasToolCall ? SUBAGENT_STUCK_MS * 3 : SUBAGENT_STUCK_MS;
2745
+ const deadLimit = hasToolCall ? subagentDeadMs * 3 : subagentDeadMs;
2746
+ if (silentFor <= limit) {
2747
+ if (sawWaiting === null)
2748
+ sawWaiting = child;
2749
+ continue;
2750
+ }
2751
+ if (silentFor <= deadLimit) {
2752
+ dbg(`subagent ${short(child)} quiet for ${Math.round(silentFor / 1000)}s, inside ${Math.round(deadLimit / 1000)}s patience \u2014 waiting, not crashed (active=${activeIDs.has(child)})`);
2753
+ if (sawWaiting === null)
2754
+ sawWaiting = child;
2755
+ continue;
2756
+ }
2757
+ dbg(`subagent ${short(child)} silent for ${Math.round(silentFor / 1000)}s (active=${activeIDs.has(child)})`);
2758
+ return { status: "crashed", stuckSid: child };
2759
+ }
2760
+ if (sawBusy)
2761
+ return { status: "busy" };
2762
+ if (sawWaiting !== null)
2763
+ return { status: "waiting", stuckSid: sawWaiting };
2764
+ return { status: "idle" };
2765
+ } catch (e) {
2766
+ dbg(`subagent check failed for ${short(parentSid)}:`, e instanceof Error ? e.message : String(e));
2767
+ return { status: "busy" };
2768
+ }
2769
+ }
2770
+ async function recoverStuckSubagent(sid) {
2771
+ if (!running)
2772
+ return false;
2773
+ try {
2774
+ await callSessionApi("synthetic", { sessionID: sid, text: SUBAGENT_RECOVERY_PROMPT });
2775
+ log("info", `${short(sid)} recovery prompt sent to stuck subagent`);
2776
+ return true;
2777
+ } catch (e) {
2778
+ log("warn", `failed to recover subagent ${short(sid)}: ${e instanceof Error ? e.message : String(e)}`);
2779
+ return false;
2780
+ }
2781
+ }
2782
+ async function armOrphanWatch(sid) {
2783
+ const w = sessions.get(sid);
2784
+ if (!w || w.orphanWatchStartAt !== null)
2785
+ return;
2786
+ if (w.status !== "busy" || w.userCancelled || w.completionSignaled)
2787
+ return;
2788
+ try {
2789
+ if (await isSubAgentSession(sid))
2790
+ return;
2791
+ const children = await listSubagentIds(sid);
2792
+ if (w.orphanWatchStartAt !== null || w.status !== "busy" || w.userCancelled)
2793
+ return;
2794
+ if (children.length === 0) {
2795
+ dbg(`${short(sid)} outlived a busy session but has no subagents \u2014 not an orphan parent`);
2796
+ return;
2797
+ }
2798
+ w.orphanWatchStartAt = Date.now();
2799
+ w.orphanWatchArmedAt = w.orphanWatchStartAt;
2800
+ w.orphanRecoveryTried = false;
2801
+ log("info", `subagents of ${short(sid)} fell quiet while it stayed busy. orphan watch (${subagentWaitMs / 1000}s, ${children.length} subagent(s))`);
2802
+ } catch (e) {
2803
+ dbg(`orphan arming check failed for ${short(sid)}:`, e instanceof Error ? e.message : String(e));
2804
+ }
2805
+ }
2806
+ async function runOrphanWatch(sid, w, now, activeIDs) {
2807
+ if (now - w.orphanWatchStartAt < subagentWaitMs + gracePeriodMs)
2808
+ return;
2809
+ const profile = resolveProfile(sid);
2810
+ const profileMaxRetries = profile.maxRetries;
2811
+ if (w.resumeAttempts >= profileMaxRetries) {
2812
+ if (!w.gaveUp) {
2813
+ w.gaveUp = true;
2814
+ w.orphanWatchStartAt = null;
2815
+ w.orphanWatchArmedAt = null;
2816
+ w.orphanEscalatedChildSid = undefined;
2817
+ log("warn", `${short(sid)} orphan watch gave up after ${profileMaxRetries} attempts`);
2818
+ }
2819
+ return;
2820
+ }
2821
+ if (w.pendingTools > 0) {
2822
+ dbg(`${short(sid)} parent has ${w.pendingTools} tool(s) in flight \u2014 deferring orphan abort`);
2823
+ w.orphanWatchStartAt = now;
2824
+ return;
2825
+ }
2826
+ if (hasPendingUserInput(await loadMessages(sid))) {
2827
+ dbg(`${short(sid)} parent is waiting on the user \u2014 deferring orphan abort`);
2828
+ w.orphanWatchStartAt = now;
2829
+ return;
2830
+ }
2831
+ if (w.orphanWatchArmedAt !== null) {
2832
+ const msgs = await loadMessages(sid);
2833
+ let newestUserAt = 0;
2834
+ for (const m of msgs) {
2835
+ if (msgRole(m) !== "user")
2836
+ continue;
2837
+ const at = msgAt(m);
2838
+ if (typeof at === "number" && at > newestUserAt)
2839
+ newestUserAt = at;
2840
+ }
2841
+ if (newestUserAt > w.orphanWatchArmedAt) {
2842
+ dbg(`${short(sid)} user spoke ${Math.round((now - newestUserAt) / 1000)}s ago, after the orphan watch armed \u2014 deferring orphan abort`);
2843
+ return;
2844
+ }
2845
+ }
2846
+ const verdict = await subagentVerdict(sid, activeIDs);
2847
+ if (verdict.status === "crashed") {
2848
+ if (verdict.stuckSid && w.orphanEscalatedChildSid === verdict.stuckSid) {
2849
+ w.resumeAttempts++;
2850
+ const profile = resolveProfile(sid);
2851
+ const profileMaxRetries = profile.maxRetries;
2852
+ if (w.resumeAttempts >= profileMaxRetries) {
2853
+ if (!w.gaveUp) {
2854
+ w.gaveUp = true;
2855
+ log("warn", `${short(sid)} orphan watch gave up after ${profileMaxRetries} attempts`);
2856
+ }
2857
+ } else {
2858
+ w.orphanWatchStartAt = now;
2859
+ }
2860
+ dbg(`${short(sid)} subagent ${short(verdict.stuckSid)} already escalated this episode \u2014 not re-aborting the parent`);
2861
+ return;
2862
+ }
2863
+ if (verdict.stuckSid && !w.orphanRecoveryTried) {
2864
+ w.orphanRecoveryTried = true;
2865
+ if (await recoverStuckSubagent(verdict.stuckSid)) {
2866
+ w.orphanWatchStartAt = now;
2867
+ return;
2868
+ }
2869
+ }
2870
+ log("info", `${short(sid)} subagent crashed and did not recover \u2014 aborting and resuming the parent`);
2871
+ if (verdict.stuckSid)
2872
+ w.orphanEscalatedChildSid = verdict.stuckSid;
2873
+ w.resumeAttempts++;
2874
+ tryAbortAndResume(sid, w, true);
2875
+ return;
2876
+ }
2877
+ if (verdict.status === "waiting") {
2878
+ dbg(`${short(sid)} subagents quiet but inside patience \u2014 waiting, not aborting`);
2879
+ w.orphanWatchStartAt = now;
2880
+ return;
2881
+ }
2882
+ if (verdict.status === "busy") {
2883
+ dbg(`${short(sid)} subagents still working \u2014 waiting`);
2884
+ w.orphanWatchStartAt = now;
2885
+ return;
2886
+ }
2887
+ log("info", `${short(sid)} stuck with no live subagents \u2014 aborting and resuming`);
2888
+ tryAbortAndResume(sid, w);
2889
+ w.resumeAttempts++;
2890
+ }
2891
+ async function parentWaitingOnSubagents(sid, activeIDs) {
2892
+ const verdict = await subagentVerdict(sid, activeIDs);
2893
+ if (verdict.status === "busy" || verdict.status === "waiting")
2894
+ return true;
2895
+ if (verdict.status === "crashed")
2896
+ return false;
2897
+ const w = ensureWatch(sid);
2898
+ if (w.pendingTools <= 0)
2899
+ return false;
2900
+ return false;
2901
+ }
2902
+ function stallYoungAgeMsFor(name) {
2903
+ return LONG_RUNNING_TOOLS.includes(name) ? LONG_RUNNING_TOOL_STALE_TTL_MS : stuckToolAgeMs;
2904
+ }
2905
+ async function hasYoungUnfinishedWork(sid, now) {
2906
+ const messages = await loadMessages(sid);
2907
+ let lastUserAt = 0;
2908
+ for (const m of messages) {
2909
+ if (msgRole(m) === "user")
2910
+ lastUserAt = Math.max(lastUserAt, msgAt(m));
2911
+ }
2912
+ for (const m of messages) {
2913
+ if (!m || msgRole(m) !== "assistant" || !Array.isArray(m.content))
2914
+ continue;
2915
+ for (const part of m.content) {
2916
+ if (part?.type !== "tool")
2917
+ continue;
2918
+ const st = part?.state?.status;
2919
+ if (st === "completed" || st === "error")
2920
+ continue;
2921
+ const created = typeof part?.time?.created === "number" ? part.time.created : typeof m?.time?.created === "number" ? m.time.created : 0;
2922
+ if (created <= lastUserAt)
2923
+ continue;
2924
+ const name = typeof part?.name === "string" && part.name ? part.name.toLowerCase() : "";
2925
+ const age = now - created;
2926
+ if (neverInterruptTools.includes(name)) {
2927
+ if (age < NEVER_INTERRUPT_STALE_TTL_MS)
2928
+ return true;
2929
+ continue;
2930
+ }
2931
+ if (age < stallYoungAgeMsFor(name))
2932
+ return true;
2933
+ }
2934
+ }
2935
+ return false;
2936
+ }
2937
+ async function checkActiveSessions() {
2938
+ const now = Date.now();
2939
+ const activeIDs = await getActiveSessions();
2940
+ for (const sid of activeIDs) {
2941
+ const w = ensureWatch(sid);
2942
+ if (w.status !== "busy")
2943
+ markBusy(sid);
2944
+ }
2945
+ const activeSet = new Set(activeIDs);
2946
+ for (const [sid, w] of sessions) {
2947
+ if (isPaused(sid))
2948
+ continue;
2949
+ if (w.status !== "busy" || w.userCancelled) {
2950
+ if (w.status === "busy" && w.userCancelled) {
2951
+ noteInboundUserMessage(sid, w, await loadMessages(sid));
2952
+ if (w.userCancelled)
2953
+ continue;
2954
+ } else if (w.status === "idle" && !w.userCancelled) {
2955
+ const idleFor = now - (w.idleSince ?? w.lastActivityAt);
2956
+ if (idleFor > chunkTimeoutMs && now - w.lastSilentReasoningCheck > chunkTimeoutMs) {
2957
+ w.lastSilentReasoningCheck = now;
2958
+ await checkForSilentReasoningTurn(sid, w);
2959
+ }
2960
+ if (idleFor > stuckToolAgeMs && now - w.lastStuckToolCheck > stuckToolAgeMs) {
2961
+ w.lastStuckToolCheck = now;
2962
+ await checkForStuckToolCalls(sid, w);
2963
+ }
2964
+ {
2965
+ const history = await loadMessages(sid);
2966
+ noteInboundUserMessage(sid, w, history);
2967
+ if (w.completionSignaled) {
2968
+ if (w.lastGenuineUserAt > w.completionSignaledAt) {
2969
+ w.completionSignaled = false;
2970
+ w.completionSignaledAt = 0;
2971
+ dbg(`${short(sid)} genuine new user message after latched completion \u2014 re-opening stall detection`);
2972
+ } else {
2973
+ dbg(`${short(sid)} completion latched \u2014 skipping stall detection`);
2974
+ }
2975
+ } else if (!isWaitingOnUser(sid, w, history)) {
2976
+ if (w.compacting) {
2977
+ dbg(`${short(sid)} mid-compaction \u2014 skipping stall detection`);
2978
+ } else if (await hasYoungUnfinishedWork(sid, now)) {
2979
+ dbg(`${short(sid)} tool call still unfinished in history \u2014 working, not stalled`);
2980
+ } else if (await parentWaitingOnSubagents(sid, activeSet)) {
2981
+ dbg(`${short(sid)} waiting on subagents \u2014 skipping stall detection`);
2982
+ } else {
2983
+ const profile = resolveProfile(sid);
2984
+ const profileChunkTimeoutMs = profile.chunkTimeoutMs;
2985
+ const profileGracePeriodMs = profile.gracePeriodMs;
2986
+ const silence = now - w.lastActivityAt;
2987
+ const fuseMult = Math.min(8, 2 ** Math.max(0, w.episodeRefires));
2988
+ const baseInterval = (profileChunkTimeoutMs + profileGracePeriodMs) * fuseMult;
2989
+ const jitter = baseInterval > 60000 ? Math.floor(Math.random() * STALL_JITTER_MS * 2) - STALL_JITTER_MS : 0;
2990
+ if (silence >= baseInterval + jitter) {
2991
+ try {
2992
+ const _qlast2 = history.length ? history[history.length - 1] : null;
2993
+ let _qtype2 = "";
2994
+ let _qmsg2 = "";
2995
+ if (_qlast2 && msgRole(_qlast2) === "assistant") {
2996
+ const _qe2 = _qlast2.error ?? _qlast2.info?.error;
2997
+ if (typeof _qe2 === "string")
2998
+ _qmsg2 = _qe2;
2999
+ else if (_qe2 && typeof _qe2 === "object") {
3000
+ _qtype2 = String(_qe2.name ?? _qe2.type ?? "");
3001
+ _qmsg2 = String(_qe2.message ?? "");
3002
+ }
3003
+ const _qtxt2 = lastAssistantTextFrom(history);
3004
+ if (_qtxt2)
3005
+ _qmsg2 = _qmsg2 ? `${_qmsg2} ${_qtxt2}` : _qtxt2;
3006
+ }
3007
+ if (isRateLimitError(_qtype2, _qmsg2)) {
3008
+ handleRateLimitFailure(sid, w, _qtype2, _qmsg2);
3009
+ continue;
3010
+ }
3011
+ } catch (e) {
3012
+ dbg(`${short(sid)} quota guard (idle) failed: ${e instanceof Error ? e.message : String(e)}`);
3013
+ }
3014
+ if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) {
3015
+ dbg(`${short(sid)} quota-armed \u2014 skipping stall recover`);
3016
+ continue;
3017
+ }
3018
+ await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`);
3019
+ }
3020
+ }
3021
+ }
3022
+ }
3023
+ continue;
3024
+ } else {
3025
+ continue;
3026
+ }
3027
+ }
3028
+ if (now - w.createdAt < warmupMs)
3029
+ continue;
3030
+ if (w.orphanWatchStartAt !== null) {
3031
+ if (!w.aborting && !w.gaveUp && !w.completionSignaled) {
3032
+ await runOrphanWatch(sid, w, now, activeSet);
3033
+ }
3034
+ continue;
3035
+ }
3036
+ if (w.completionSignaled) {
3037
+ noteInboundUserMessage(sid, w, await loadMessages(sid));
3038
+ if (w.lastGenuineUserAt > w.completionSignaledAt) {
3039
+ w.completionSignaled = false;
3040
+ w.completionSignaledAt = 0;
3041
+ dbg(`${short(sid)} genuine new user message after latched completion \u2014 re-opening stall detection`);
3042
+ } else {
3043
+ dbg(`${short(sid)} completion latched \u2014 skipping stall detection`);
3044
+ continue;
3045
+ }
3046
+ }
3047
+ if (w.compacting && w.compactionStartedAt && now - w.compactionStartedAt > COMPACTION_STALE_TTL_MS) {
3048
+ w.compacting = false;
3049
+ w.compactionStartedAt = null;
3050
+ log("warn", `${short(sid)} compaction flag stale (>${COMPACTION_STALE_TTL_MS / 60000}m) \u2014 clearing guard`);
3051
+ }
3052
+ if (w.compacting) {
3053
+ dbg(`${short(sid)} mid-compaction \u2014 skipping stall detection`);
3054
+ continue;
3055
+ }
3056
+ const profile = resolveProfile(sid);
3057
+ const profileChunkTimeoutMs = profile.chunkTimeoutMs;
3058
+ const profileGracePeriodMs = profile.gracePeriodMs;
3059
+ const silence = now - w.lastActivityAt;
3060
+ const fuseMult = Math.min(8, 2 ** Math.max(0, w.episodeRefires));
3061
+ const baseInterval = (profileChunkTimeoutMs + profileGracePeriodMs) * fuseMult;
3062
+ const jitter = baseInterval > 60000 ? Math.floor(Math.random() * STALL_JITTER_MS * 2) - STALL_JITTER_MS : 0;
3063
+ if (silence < baseInterval + jitter)
3064
+ continue;
3065
+ {
3066
+ const histText = lastAssistantTextFrom(await loadMessages(sid));
3067
+ if (histText && isUserHandoff(histText)) {
3068
+ dbg(`${short(sid)} quiet turn ends with a hand-off \u2014 waiting on user, not stalled`);
3069
+ continue;
3070
+ }
3071
+ if (histText && turnEndsWithCompletionClaim(histText)) {
3072
+ const open = getOpenTodos(await readTodos(sid));
3073
+ if (open.length > 0) {
3074
+ await targetedRecovery(sid, "stop-open-todos", buildOpenTodosReminder(open), "todoNudgeAttempts");
3075
+ } else if (!w.completionSignaled) {
3076
+ w.completionSignaled = true;
3077
+ w.completionSignaledAt = Date.now();
3078
+ log("info", `${short(sid)} quiet turn ends with a completion claim and no open todos \u2014 latching completion, not nudging`);
3079
+ } else {
3080
+ dbg(`${short(sid)} completion already latched \u2014 skipping`);
3081
+ }
3082
+ continue;
3083
+ }
3084
+ }
3085
+ if (busyStallStrategy === "off") {
3086
+ dbg(`stall on ${short(sid)} ignored (busyStallStrategy=off)`);
3087
+ continue;
3088
+ }
3089
+ clearStalePermissionFlag(sid, w);
3090
+ if (w.permissionPending) {
3091
+ dbg(`${short(sid)} silent but permission pending \u2014 skipping`);
3092
+ continue;
3093
+ }
3094
+ if (selfAbortActive(w)) {
3095
+ dbg(`${short(sid)} silent but inside our own abort window \u2014 skipping`);
3096
+ continue;
3097
+ }
3098
+ const busyShells = openShellCount(sid);
3099
+ if (busyShells > 0) {
3100
+ dbg(`${short(sid)} silent with ${busyShells} shell(s) still running \u2014 waiting`);
3101
+ continue;
3102
+ }
3103
+ {
3104
+ const now = Date.now();
3105
+ let youngTool = false;
3106
+ for (const [, rec] of w.inflightTools ?? []) {
3107
+ const age = now - rec.at;
3108
+ if (neverInterruptTools.includes(rec.name)) {
3109
+ if (age < NEVER_INTERRUPT_STALE_TTL_MS) {
3110
+ dbg(`${short(sid)} silent with exempt tool "${rec.name}" still running (${Math.round(age / 1000)}s) \u2014 waiting`);
3111
+ youngTool = true;
3112
+ break;
3113
+ }
3114
+ continue;
3115
+ }
3116
+ if (age < stallYoungAgeMsFor(rec.name))
3117
+ youngTool = true;
3118
+ }
3119
+ if (youngTool)
3120
+ continue;
3121
+ }
3122
+ if (await hasYoungUnfinishedWork(sid, now)) {
3123
+ dbg(`${short(sid)} tool call still unfinished in history \u2014 working, not stalled`);
3124
+ continue;
3125
+ }
3126
+ if (await parentWaitingOnSubagents(sid, activeSet)) {
3127
+ dbg(`${short(sid)} waiting on subagents \u2014 skipping stall detection`);
3128
+ continue;
3129
+ }
3130
+ if (isWaitingOnUser(sid, w, await loadMessages(sid))) {
3131
+ dbg(`${short(sid)} waiting on user input \u2014 skipping stall detection`);
3132
+ continue;
3133
+ }
3134
+ if (busyStallStrategy === "abort" && w.resumeAttempts < maxRetries) {
3135
+ log("info", `${short(sid)} stall (busyStallStrategy=abort): aborting before continue`);
3136
+ await tryAbortAndResume(sid, w, true);
3137
+ } else {
3138
+ try {
3139
+ const _qm = await loadMessages(sid);
3140
+ const _qlast = _qm.length ? _qm[_qm.length - 1] : null;
3141
+ let _qtype = "";
3142
+ let _qmsg = "";
3143
+ if (_qlast && msgRole(_qlast) === "assistant") {
3144
+ const _qe = _qlast.error ?? _qlast.info?.error;
3145
+ if (typeof _qe === "string")
3146
+ _qmsg = _qe;
3147
+ else if (_qe && typeof _qe === "object") {
3148
+ _qtype = String(_qe.name ?? _qe.type ?? "");
3149
+ _qmsg = String(_qe.message ?? "");
3150
+ }
3151
+ const _qtxt = lastAssistantTextFrom(_qm);
3152
+ if (_qtxt)
3153
+ _qmsg = _qmsg ? `${_qmsg} ${_qtxt}` : _qtxt;
3154
+ }
3155
+ if (isRateLimitError(_qtype, _qmsg)) {
3156
+ handleRateLimitFailure(sid, w, _qtype, _qmsg);
3157
+ continue;
3158
+ }
3159
+ } catch (e) {
3160
+ dbg(`${short(sid)} quota guard failed: ${e instanceof Error ? e.message : String(e)}`);
3161
+ }
3162
+ if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) {
3163
+ dbg(`${short(sid)} quota-armed \u2014 skipping stall recover`);
3164
+ continue;
3165
+ }
3166
+ await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`);
3167
+ }
3168
+ }
3169
+ for (const [rsid, rw] of sessions) {
3170
+ if (!rw.awaitingQuotaRetry)
3171
+ continue;
3172
+ const ladder = rateLimitCooldownsMs;
3173
+ const rn = rw.rateLimitAttempts;
3174
+ if (rn >= ladder.length) {
3175
+ rw.awaitingQuotaRetry = false;
3176
+ continue;
3177
+ }
3178
+ if (rw.userCancelled || rw.gaveUp || rw.compacting || rw.permissionPending)
3179
+ continue;
3180
+ if (selfAbortActive(rw))
3181
+ continue;
3182
+ if (Date.now() - rw.rateLimitedAt < ladder[rn])
3183
+ continue;
3184
+ serveQuotaRung(rsid, rw, rn, ladder);
3185
+ }
3186
+ cleanupIdleSessions();
3187
+ }
3188
+ let watchdogBusy = false;
3189
+ const watchdog = setInterval(() => {
3190
+ if (watchdogBusy) {
3191
+ dbg("watchdog sweep still running \u2014 skipping this tick");
3192
+ return;
3193
+ }
3194
+ watchdogBusy = true;
3195
+ checkActiveSessions().catch((e) => log("error", `watchdog failed: ${e instanceof Error ? e.message : String(e)}`)).finally(() => {
3196
+ watchdogBusy = false;
3197
+ });
3198
+ }, checkIntervalMs);
3199
+ const discoveryTimer = setInterval(() => {
3200
+ discoverSessions().catch((e) => log("error", `discovery failed: ${e instanceof Error ? e.message : String(e)}`));
3201
+ }, DEFAULT_SESSION_DISCOVERY_INTERVAL_MS);
3202
+ const initialDiscovery = setTimeout(() => {
3203
+ discoverSessions().catch(() => {});
3204
+ }, discoveryDelayMs);
3205
+ await registerTaskCompleteFallback();
3206
+ await observeTaskCompleteTool();
3207
+ function tokenTotalOf(tokens) {
3208
+ if (!tokens || typeof tokens !== "object")
3209
+ return 0;
3210
+ const t = tokens;
3211
+ const cache = t.cache ?? {};
3212
+ return posNum(t.input) + posNum(t.output) + posNum(t.reasoning) + posNum(cache.read) + posNum(cache.write);
3213
+ }
3214
+ function posNum(v) {
3215
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
3216
+ }
3217
+ const usableLimitCache = new Map;
3218
+ async function getUsableContextLimit(sid) {
3219
+ try {
3220
+ let ref = sessions.get(sid)?.modelRef;
3221
+ if (!ref) {
3222
+ const msgs = await ctx.session.context({ sessionID: sid });
3223
+ if (Array.isArray(msgs)) {
3224
+ for (let i = msgs.length - 1;i >= 0; i--) {
3225
+ const m = msgs[i];
3226
+ if (msgRole(m) !== "assistant" || !m.model)
3227
+ continue;
3228
+ const providerID = m.model.providerID;
3229
+ const modelID = m.model.modelID ?? m.model.id;
3230
+ if (typeof providerID === "string" && typeof modelID === "string") {
3231
+ ref = { providerID, modelID };
3232
+ const w = sessions.get(sid);
3233
+ if (w)
3234
+ w.modelRef = ref;
3235
+ }
3236
+ break;
3237
+ }
3238
+ }
3239
+ }
3240
+ if (!ref)
3241
+ return null;
3242
+ const key = `${ref.providerID}/${ref.modelID}`;
3243
+ const cached = usableLimitCache.get(key);
3244
+ if (cached !== undefined)
3245
+ return cached;
3246
+ const info = ctx.model?.get?.(ref.providerID, ref.modelID);
3247
+ if (!info)
3248
+ return null;
3249
+ const limit = info.limit;
3250
+ if (!limit || typeof limit.context !== "number" || limit.context === 0)
3251
+ return null;
3252
+ const usable = limit.context - Math.min(20000, limit.output ?? 0);
3253
+ if (!Number.isFinite(usable) || usable <= 0)
3254
+ return null;
3255
+ usableLimitCache.set(key, usable);
3256
+ return usable;
3257
+ } catch (e) {
3258
+ dbg(`usable-context-limit lookup failed for ${short(sid)}:`, e instanceof Error ? e.message : String(e));
3259
+ return null;
3260
+ }
3261
+ }
3262
+ async function callSessionApi(name, args) {
3263
+ const fromDomain = ctx.session[name];
3264
+ if (typeof fromDomain === "function") {
3265
+ try {
3266
+ return await fromDomain.call(ctx.session, args);
3267
+ } catch (e) {
3268
+ dbg(`ctx.session.${name}() failed:`, e instanceof Error ? e.message : String(e));
3269
+ }
3270
+ }
3271
+ const fromClient = ctx.client?.session?.[name];
3272
+ if (typeof fromClient === "function") {
3273
+ try {
3274
+ return await fromClient.call(ctx.client.session, args);
3275
+ } catch (e) {
3276
+ dbg(`ctx.client.session.${name}() failed:`, e instanceof Error ? e.message : String(e));
3277
+ }
3278
+ }
3279
+ return;
3280
+ }
3281
+ let magicContextDetected = null;
3282
+ async function isMagicContextInstalled() {
3283
+ if (magicContextDetected !== null)
3284
+ return magicContextDetected;
3285
+ try {
3286
+ const list = ctx.plugin?.list;
3287
+ if (typeof list !== "function") {
3288
+ dbg("magic-context detection: ctx.plugin.list unavailable, treating as not installed");
3289
+ return false;
3290
+ }
3291
+ const plugins = unwrapList(await list.call(ctx.plugin));
3292
+ magicContextDetected = plugins.some((p) => {
3293
+ const id = typeof p?.id === "string" ? p.id : "";
3294
+ if (id.toLowerCase().includes("magic-context"))
3295
+ return true;
3296
+ const src = p?.source;
3297
+ const spec = src?.target ?? src?.path ?? "";
3298
+ return typeof spec === "string" && spec.toLowerCase().includes("magic-context");
3299
+ });
3300
+ dbg(`magic-context detection: ${magicContextDetected ? "installed" : "not installed"}`);
3301
+ return magicContextDetected;
3302
+ } catch (e) {
3303
+ dbg("magic-context detection failed, treating as not installed:", e instanceof Error ? e.message : String(e));
3304
+ return false;
3305
+ }
3306
+ }
3307
+ function unwrapList(response) {
3308
+ if (Array.isArray(response))
3309
+ return response;
3310
+ if (response && typeof response === "object") {
3311
+ const data = response.data;
3312
+ if (Array.isArray(data))
3313
+ return data;
3314
+ }
3315
+ return [];
3316
+ }
3317
+ async function checkContextSaturation(sid) {
3318
+ const w = sessions.get(sid);
3319
+ if (!w)
3320
+ return;
3321
+ if (w.lastTokenTotal <= 0)
3322
+ return;
3323
+ if (w.contextWrapupAttempts >= 1)
3324
+ return;
3325
+ if (w.userCancelled || w.completionSignaled || w.aborting)
3326
+ return;
3327
+ const usable = await getUsableContextLimit(sid);
3328
+ if (!usable)
3329
+ return;
3330
+ if (w.lastTokenTotal / usable < contextSaturationThreshold)
3331
+ return;
3332
+ const pct = Math.round(w.lastTokenTotal / usable * 100);
3333
+ if (await isSubAgentSession(sid)) {
3334
+ if (!subagentNativeCompactionEnabled) {
3335
+ dbg(`${short(sid)} subagent at ${pct}% of usable context; native compaction is opt-in`);
3336
+ return;
3337
+ }
3338
+ w.contextWrapupAttempts++;
3339
+ log("warn", `${short(sid)} context saturation (subagent): ${w.lastTokenTotal}/${usable} tokens (${pct}% of usable); triggering native compaction`);
3340
+ if (w.userCancelled || w.completionSignaled)
3341
+ return;
3342
+ const compacted = await callSessionApi("compact", { sessionID: sid });
3343
+ if (compacted === undefined) {
3344
+ log("warn", `${short(sid)} native compaction is not exposed on this host \u2014 skipping`);
3345
+ }
3346
+ return;
3347
+ }
3348
+ if (!await isMagicContextInstalled()) {
3349
+ dbg(`${short(sid)} at ${pct}% of usable context; magic-context not installed, not intervening`);
3350
+ return;
3351
+ }
3352
+ w.contextWrapupAttempts++;
3353
+ log("warn", `${short(sid)} context saturation: ${w.lastTokenTotal}/${usable} tokens (${pct}% of usable); sending magic-context wrapup command`);
3354
+ if (w.userCancelled || w.completionSignaled)
3355
+ return;
3356
+ try {
3357
+ await ctx.session.command({ sessionID: sid, name: CTX_WRAPUP_TRIGGER });
3358
+ } catch (e) {
3359
+ log("warn", `${short(sid)} magic-context wrapup command failed: ${e instanceof Error ? e.message : String(e)}`);
3360
+ }
3361
+ }
3362
+ async function discoverSessions() {
3363
+ try {
3364
+ const listed = unwrapList(await callSessionApi("list"));
3365
+ let seeded = 0;
3366
+ for (const row of listed) {
3367
+ const sid = row?.id;
3368
+ if (typeof sid !== "string" || !sid.startsWith("ses_"))
3369
+ continue;
3370
+ if (!sessions.has(sid)) {
3371
+ ensureWatch(sid);
3372
+ seeded++;
3373
+ }
3374
+ }
3375
+ const active = await callSessionApi("active");
3376
+ let adopted = 0;
3377
+ if (active && typeof active === "object") {
3378
+ for (const [sid, val] of Object.entries(active)) {
3379
+ if (!sid.startsWith("ses_"))
3380
+ continue;
3381
+ const w = ensureWatch(sid);
3382
+ if (w.status !== "busy") {
3383
+ markBusy(sid);
3384
+ adopted++;
3385
+ }
3386
+ }
3387
+ }
3388
+ if (seeded > 0 || adopted > 0) {
3389
+ log("info", `discovery: seeded ${seeded} session(s), adopted ${adopted} already running`);
3390
+ } else {
3391
+ dbg("discovery: nothing new");
3392
+ }
3393
+ } catch (err) {
3394
+ const msg = err instanceof Error ? err.message : String(err);
3395
+ dbg(`session discovery failed: ${msg}`);
3396
+ }
3397
+ }
3398
+ async function restoreQuotaLadders() {
3399
+ let listed;
3400
+ try {
3401
+ listed = new Set(unwrapList(await callSessionApi("list")).map((r) => r.id).filter((id) => typeof id === "string"));
3402
+ } catch {
3403
+ return;
3404
+ }
3405
+ const horizon = rateLimitCooldownsMs.reduce((a, b) => a + b, 0);
3406
+ const restored = new Set;
3407
+ if (ctx.storage?.set) {
3408
+ for (const sid of listed) {
3409
+ let rec;
3410
+ try {
3411
+ rec = await ctx.storage.get(`${QUOTA_STATE_PREFIX}${sid}`);
3412
+ } catch (e) {
3413
+ dbg(`${short(sid)} quota record read failed:`, e instanceof Error ? e.message : String(e));
3414
+ continue;
3415
+ }
3416
+ const r = rec;
3417
+ if (!r || typeof r.at !== "number")
3418
+ continue;
3419
+ if (Date.now() - r.at >= QUOTA_STATE_MAX_AGE_MS) {
3420
+ syncQuotaState(sid, ensureWatch(sid), true);
3421
+ continue;
3422
+ }
3423
+ if (Date.now() - r.at >= horizon)
3424
+ continue;
3425
+ const w = ensureWatch(sid);
3426
+ if (w.awaitingQuotaRetry || w.rateLimitedAt > 0)
3427
+ continue;
3428
+ if (w.status === "busy") {
3429
+ dbg(`${short(sid)} busy \u2014 skipping quota restore`);
3430
+ continue;
3431
+ }
3432
+ w.rateLimitedAt = r.at;
3433
+ w.rateLimitAttempts = typeof r.rung === "number" ? r.rung : 0;
3434
+ w.awaitingQuotaRetry = true;
3435
+ w.quotaNoticedAt = typeof r.noticed === "number" && r.noticed > 0 ? r.noticed : r.at;
3436
+ restored.add(sid);
3437
+ log("warn", `${short(sid)} quota ladder restored (armed ${Math.round((Date.now() - r.at) / 1000)}s ago, rung ${w.rateLimitAttempts}) \u2014 retry due on schedule`);
3438
+ }
3439
+ }
3440
+ }
3441
+ async function syncQuotaState(sid, w, force = false) {
3442
+ try {
3443
+ const st = ctx.storage;
3444
+ if (!st?.set)
3445
+ return;
3446
+ const key = `${QUOTA_STATE_PREFIX}${sid}`;
3447
+ if (!w.rateLimitedAt) {
3448
+ if (force || st.remove)
3449
+ await st.remove?.(key);
3450
+ return;
3451
+ }
3452
+ await st.set(key, {
3453
+ v: 1,
3454
+ at: w.rateLimitedAt,
3455
+ rung: w.rateLimitAttempts,
3456
+ noticed: w.quotaNoticedAt
3457
+ });
3458
+ } catch (e) {
3459
+ dbg(`${short(sid)} quota state sync failed:`, e instanceof Error ? e.message : String(e));
3460
+ }
3461
+ }
3462
+ async function syncCounters(sid, w) {
3463
+ try {
3464
+ const st = ctx.storage;
3465
+ if (!st?.set)
3466
+ return;
3467
+ const key = `${COUNTER_STATE_PREFIX}${sid}`;
3468
+ await st.set(key, {
3469
+ v: 1,
3470
+ workRetries: w.workRetries ?? 0,
3471
+ resumeAttempts: w.resumeAttempts ?? 0,
3472
+ lifetimeContinues: w.lifetimeContinues ?? 0,
3473
+ episodeRefires: w.episodeRefires ?? 0,
3474
+ at: Date.now()
3475
+ });
3476
+ } catch (e) {
3477
+ dbg(`${short(sid)} counter sync failed:`, e instanceof Error ? e.message : String(e));
3478
+ }
3479
+ }
3480
+ function handleEvent(ev) {
3481
+ switch (ev.type) {
3482
+ case "session.execution.started": {
3483
+ const sid = sidOf(ev);
3484
+ if (!sid)
3485
+ return;
3486
+ const w = ensureWatch(sid);
3487
+ if (w.selfRecovery) {
3488
+ w.selfRecovery = false;
3489
+ } else {
3490
+ const providerRetryFresh = isProviderRetryFresh(w);
3491
+ clearLocks(w, sid);
3492
+ w.rateLimitedAt = 0;
3493
+ w.rateLimitAttempts = 0;
3494
+ w.quotaNoticedAt = 0;
3495
+ w.awaitingQuotaRetry = false;
3496
+ w.gaveUp = false;
3497
+ (async () => {
3498
+ const selfInjectFresh = w.lastInjectAt > 0 && w.lastInjectAt >= w.lastGenuineUserAt;
3499
+ let ownRung = !providerRetryFresh && selfInjectFresh;
3500
+ if (ownRung) {
3501
+ try {
3502
+ for (const m of await loadMessages(sid)) {
3503
+ if (msgRole(m) === "user" && msgAt(m) > w.lastInjectAt) {
3504
+ ownRung = false;
3505
+ break;
3506
+ }
3507
+ }
3508
+ } catch {
3509
+ ownRung = false;
3510
+ }
3511
+ }
3512
+ if (!ownRung) {
3513
+ w.lifetimeContinues = 0;
3514
+ w.workRetries = 0;
3515
+ w.emptyIdleNudged = false;
3516
+ if (ctx.storage?.remove) {
3517
+ ctx.storage.remove(`${COUNTER_STATE_PREFIX}${sid}`).catch(() => {});
3518
+ }
3519
+ }
3520
+ syncQuotaState(sid, w);
3521
+ if (!ownRung) {
3522
+ w.resumeAttempts = 0;
3523
+ }
3524
+ })();
3525
+ }
3526
+ markBusy(sid);
3527
+ return;
3528
+ }
3529
+ case "session.usage.updated": {
3530
+ const sid = sidOf(ev);
3531
+ if (!sid)
3532
+ return;
3533
+ const total = tokenTotalOf(ev.data?.tokens);
3534
+ if (total <= 0)
3535
+ return;
3536
+ const w = ensureWatch(sid);
3537
+ if (total > w.lastTokenTotal)
3538
+ touch(sid);
3539
+ w.lastTokenTotal = total;
3540
+ dbg(`${short(sid)} usage: ${total} tokens`);
3541
+ return;
3542
+ }
3543
+ case "session.execution.succeeded":
3544
+ case "session.idle": {
3545
+ const sid = sidOf(ev);
3546
+ if (!sid)
3547
+ return;
3548
+ dbg(`${short(sid)} idle event data keys: ${JSON.stringify(Object.keys(ev?.data ?? {}))}`);
3549
+ const busyBefore = busySessionsForOrphanWatch().length;
3550
+ const wPre = ensureWatch(sid);
3551
+ wPre.toolsInFlightAtIdle = wPre.pendingTools;
3552
+ markIdle(sid);
3553
+ const w = ensureWatch(sid);
3554
+ w.pendingRecoveryArmed = false;
3555
+ w.selfRecovery = false;
3556
+ if (selfAbortActive(w)) {
3557
+ dbg(`${short(sid)} idle after plugin-initiated abort \u2014 skipping targeted recovery`);
3558
+ return;
3559
+ }
3560
+ const busyNow = busySessionsForOrphanWatch();
3561
+ if (busyBefore > 1 && busyNow.length === 1) {
3562
+ armOrphanWatch(busyNow[0]);
3563
+ }
3564
+ inspectOnIdle(sid);
3565
+ if (!w.completionSignaled && !w.userCancelled) {
3566
+ checkForUnknownToolCalls(sid, w);
3567
+ }
3568
+ return;
3569
+ }
3570
+ case "session.execution.interrupted": {
3571
+ const sid = sidOf(ev);
3572
+ if (!sid)
3573
+ return;
3574
+ const w = ensureWatch(sid);
3575
+ const reason = typeof ev.data?.reason === "string" ? ev.data.reason : undefined;
3576
+ const mine = w.aborting || selfAbortActive(w);
3577
+ if (!mine) {
3578
+ w.userCancelled = reason === undefined || reason === "user";
3579
+ if (w.userCancelled)
3580
+ w.userCancelledAt = Date.now();
3581
+ }
3582
+ w.compacting = false;
3583
+ w.compactionStartedAt = null;
3584
+ w.recovering = false;
3585
+ markIdle(sid);
3586
+ return;
3587
+ }
3588
+ case "session.deleted": {
3589
+ const sid = sidOf(ev);
3590
+ if (!sid)
3591
+ return;
3592
+ forgetShells(sid);
3593
+ sessions.delete(sid);
3594
+ return;
3595
+ }
3596
+ case "session.revert.staged": {
3597
+ const sid = sidOf(ev);
3598
+ if (!sid)
3599
+ return;
3600
+ touch(sid);
3601
+ return;
3602
+ }
3603
+ case "session.revert.cleared":
3604
+ case "session.revert.committed": {
3605
+ const sid = sidOf(ev);
3606
+ if (!sid)
3607
+ return;
3608
+ forgetShells(sid);
3609
+ sessions.delete(sid);
3610
+ return;
3611
+ }
3612
+ case "session.compaction.started": {
3613
+ const sid = sidOf(ev);
3614
+ if (!sid)
3615
+ return;
3616
+ const w = ensureWatch(sid);
3617
+ if (!w.compacting) {
3618
+ w.compacting = true;
3619
+ w.compactionStartedAt = Date.now();
3620
+ log("info", `${short(sid)} compaction started \u2014 auto-resume recovery suspended until it ends`);
3621
+ }
3622
+ touch(sid);
3623
+ return;
3624
+ }
3625
+ case "session.compaction.ended":
3626
+ case "session.compaction.failed": {
3627
+ const sid = sidOf(ev);
3628
+ if (!sid)
3629
+ return;
3630
+ const w = ensureWatch(sid);
3631
+ if (w.compacting) {
3632
+ w.compacting = false;
3633
+ w.compactionStartedAt = null;
3634
+ log("info", `${short(sid)} compaction ${ev.type.endsWith("failed") ? "failed" : "ended"} \u2014 guard cleared`);
3635
+ }
3636
+ touch(sid);
3637
+ return;
3638
+ }
3639
+ case "session.step.started": {
3640
+ const sid = sidOf(ev);
3641
+ if (!sid)
3642
+ return;
3643
+ const w = ensureWatch(sid);
3644
+ w.agent = typeof ev.data?.agent === "string" ? ev.data.agent : w.agent;
3645
+ {
3646
+ const m = ev.data?.model;
3647
+ const providerID = m?.providerID ?? m?.provider;
3648
+ const modelID = m?.modelID ?? m?.id;
3649
+ if (typeof providerID === "string" && typeof modelID === "string") {
3650
+ w.modelRef = { providerID, modelID };
3651
+ w.model = `${providerID}/${modelID}`;
3652
+ } else if (m) {
3653
+ w.model = `${providerID ?? "?"}/${modelID ?? "?"}`;
3654
+ } else {
3655
+ w.model = w.model;
3656
+ }
3657
+ }
3658
+ w.lastWasTaskTool = false;
3659
+ markBusy(sid);
3660
+ return;
3661
+ }
3662
+ case "session.step.ended": {
3663
+ const sid = sidOf(ev);
3664
+ if (!sid)
3665
+ return;
3666
+ touch(sid);
3667
+ return;
3668
+ }
3669
+ case "session.step.streamed":
3670
+ case "session.text.started":
3671
+ case "session.reasoning.started":
3672
+ case "session.synthetic": {
3673
+ const sid = sidOf(ev);
3674
+ if (!sid)
3675
+ return;
3676
+ touch(sid);
3677
+ return;
3678
+ }
3679
+ case "session.tool.input.started":
3680
+ case "session.tool.input.delta": {
3681
+ const sid = sidOf(ev);
3682
+ if (!sid)
3683
+ return;
3684
+ touch(sid);
3685
+ return;
3686
+ }
3687
+ case "session.text.delta":
3688
+ case "session.reasoning.delta": {
3689
+ const sid = sidOf(ev);
3690
+ if (!sid)
3691
+ return;
3692
+ appendText(sid, ev.data?.assistantMessageID, typeof ev.data?.delta === "string" ? ev.data.delta : "");
3693
+ touch(sid);
3694
+ return;
3695
+ }
3696
+ case "session.text.ended":
3697
+ case "session.reasoning.ended": {
3698
+ const sid = sidOf(ev);
3699
+ if (!sid)
3700
+ return;
3701
+ touch(sid);
3702
+ return;
3703
+ }
3704
+ case "session.tool.called": {
3705
+ const sid = sidOf(ev);
3706
+ if (!sid)
3707
+ return;
3708
+ const w = ensureWatch(sid);
3709
+ const name = typeof ev.data?.tool === "string" ? ev.data.tool : ev.data?.id ?? "tool";
3710
+ w.lastWasTaskTool = isTaskToolCall(ev);
3711
+ w.pendingTools++;
3712
+ {
3713
+ const callId = typeof ev.data?.id === "string" && ev.data.id ? ev.data.id : `${name}#${Date.now()}`;
3714
+ w.inflightTools.set(callId, { name: String(name).toLowerCase(), at: Date.now() });
3715
+ }
3716
+ if (name !== "task_complete")
3717
+ w.taskCompleteSignals = 0;
3718
+ if (trackToolCall(w, name)) {
3719
+ targetedRecovery(sid, "tool-loop", TOOL_LOOP_RECOVERY_PROMPT, "intentNudgeAttempts").catch(() => {});
3720
+ }
3721
+ touch(sid);
3722
+ return;
3723
+ }
3724
+ case "session.tool.progress":
3725
+ case "session.shell.started":
3726
+ case "session.shell.ended": {
3727
+ const sid = sidOf(ev);
3728
+ if (!sid)
3729
+ return;
3730
+ touch(sid);
3731
+ return;
3732
+ }
3733
+ case "shell.created": {
3734
+ const info = ev.data?.info;
3735
+ if (!info || typeof info !== "object")
3736
+ return;
3737
+ const rec = info;
3738
+ const id = rec.id;
3739
+ const sid = rec.metadata?.sessionID;
3740
+ if (typeof id !== "string" || typeof sid !== "string")
3741
+ return;
3742
+ openShells.set(id, { sessionID: sid, startedAt: Date.now() });
3743
+ touch(sid);
3744
+ return;
3745
+ }
3746
+ case "shell.exited": {
3747
+ const id = ev.data?.id;
3748
+ if (typeof id !== "string")
3749
+ return;
3750
+ const sid = openShells.get(id)?.sessionID;
3751
+ if (sid === undefined)
3752
+ return;
3753
+ openShells.delete(id);
3754
+ const wsh = ensureWatch(sid);
3755
+ wsh.completedSinceProd++;
3756
+ wsh.succeededSinceProd++;
3757
+ touch(sid);
3758
+ return;
3759
+ }
3760
+ case "session.tool.success": {
3761
+ const sid = sidOf(ev);
3762
+ if (!sid)
3763
+ return;
3764
+ const w = ensureWatch(sid);
3765
+ w.lastWasTaskTool = false;
3766
+ w.pendingTools = Math.max(0, w.pendingTools - 1);
3767
+ {
3768
+ const callId = typeof ev.data?.id === "string" ? ev.data.id : undefined;
3769
+ if (callId !== undefined)
3770
+ w.inflightTools.delete(callId);
3771
+ else {
3772
+ const oldest = w.inflightTools.keys().next().value;
3773
+ if (typeof oldest === "string")
3774
+ w.inflightTools.delete(oldest);
3775
+ }
3776
+ }
3777
+ w.completedSinceProd++;
3778
+ w.succeededSinceProd++;
3779
+ touch(sid);
3780
+ return;
3781
+ }
3782
+ case "session.tool.failed": {
3783
+ const sid = sidOf(ev);
3784
+ if (!sid)
3785
+ return;
3786
+ const w = ensureWatch(sid);
3787
+ w.lastWasTaskTool = false;
3788
+ w.pendingTools = Math.max(0, w.pendingTools - 1);
3789
+ {
3790
+ const callId = typeof ev.data?.id === "string" ? ev.data.id : undefined;
3791
+ if (callId !== undefined)
3792
+ w.inflightTools.delete(callId);
3793
+ else {
3794
+ const oldest = w.inflightTools.keys().next().value;
3795
+ if (typeof oldest === "string")
3796
+ w.inflightTools.delete(oldest);
3797
+ }
3798
+ }
3799
+ w.completedSinceProd++;
3800
+ touch(sid);
3801
+ return;
3802
+ }
3803
+ case "permission.asked": {
3804
+ const sid = sidOf(ev);
3805
+ if (!sid)
3806
+ return;
3807
+ const w = ensureWatch(sid);
3808
+ w.permissionPending = true;
3809
+ w.permissionPendingAt = Date.now();
3810
+ return;
3811
+ }
3812
+ case "permission.replied": {
3813
+ const sid = sidOf(ev);
3814
+ if (!sid)
3815
+ return;
3816
+ const w = ensureWatch(sid);
3817
+ w.permissionPending = false;
3818
+ w.permissionPendingAt = null;
3819
+ touch(sid);
3820
+ return;
3821
+ }
3822
+ case "session.retry.scheduled": {
3823
+ const sid = sidOf(ev);
3824
+ if (!sid)
3825
+ return;
3826
+ const w = ensureWatch(sid);
3827
+ touch(sid);
3828
+ w.providerRetryAt = Date.now();
3829
+ const errType = String(ev.data?.error?.type ?? "");
3830
+ const errMsg = String(ev.data?.error?.message ?? "");
3831
+ maybeLockOoc(sid, errMsg, errType);
3832
+ if (maybeLockPermanent(sid, errMsg, errType, ev.data?.error?.status))
3833
+ return;
3834
+ log("info", `${short(sid)} provider retry #${ev.data?.attempt ?? "?"}: ${errType || errMsg}`);
3835
+ if (!isStreamingFailure(errMsg, streamingFailureMessagePatterns) && !isStreamingFailureName(errType, streamingFailureErrorNames) && errType !== "retryable")
3836
+ return;
3837
+ if (nowSilenceTooLong(w)) {
3838
+ w.pendingRecoveryArmed = true;
3839
+ recover(sid, "streaming failure with scheduled retry");
3840
+ }
3841
+ return;
3842
+ }
3843
+ case "session.step.failed":
3844
+ case "session.execution.failed": {
3845
+ const sid = sidOf(ev);
3846
+ if (!sid)
3847
+ return;
3848
+ const w = ensureWatch(sid);
3849
+ const errMsg = String(ev.data?.error?.message ?? "");
3850
+ const errType = String(ev.data?.error?.type ?? "");
3851
+ if (w.aborting || selfAbortActive(w)) {
3852
+ dbg(`${short(sid)} ${ev.type} is this plugin's own abort (${errType || "error"}) \u2014 not recovering`);
3853
+ w.recovering = false;
3854
+ w.pendingRecoveryArmed = false;
3855
+ return;
3856
+ }
3857
+ if (isAbortError(errType, errMsg)) {
3858
+ dbg(`${short(sid)} failure was an interrupt (${errType || "error"}) \u2014 not recovering`);
3859
+ w.pendingRecoveryArmed = false;
3860
+ return;
3861
+ }
3862
+ if (isRateLimitError(errType, errMsg)) {
3863
+ handleRateLimitFailure(sid, w, errType, errMsg);
3864
+ return;
3865
+ }
3866
+ maybeLockOoc(sid, errMsg, errType);
3867
+ if (maybeLockPermanent(sid, errMsg, errType, ev.data?.error?.status)) {
3868
+ markIdle(sid);
3869
+ w.pendingRecoveryArmed = false;
3870
+ w.recovering = false;
3871
+ log("warn", `${short(sid)} ${ev.type}: ${errType || "error"} ${errMsg.slice(0, 160)}`);
3872
+ return;
3873
+ }
3874
+ markIdle(sid);
3875
+ w.pendingRecoveryArmed = true;
3876
+ log("warn", `${short(sid)} ${ev.type}: ${errType || "error"} ${errMsg.slice(0, 160)}`);
3877
+ recover(sid, `${ev.type}${isStreamingFailure(errMsg, streamingFailureMessagePatterns) ? " (streaming)" : ""}`);
3878
+ return;
3879
+ }
3880
+ default:
3881
+ return;
3882
+ }
3883
+ }
3884
+ function nowSilenceTooLong(w) {
3885
+ return Date.now() - w.lastActivityAt > chunkTimeoutMs + gracePeriodMs;
3886
+ }
3887
+ let running = true;
3888
+ const eventAbort = new AbortController;
3889
+ const pump = async () => {
3890
+ try {
3891
+ const stream = ctx.event.subscribe({ signal: eventAbort.signal });
3892
+ for await (const raw of stream) {
3893
+ if (!running)
3894
+ return;
3895
+ const ev = raw;
3896
+ if (!ev || typeof ev.type !== "string")
3897
+ continue;
3898
+ try {
3899
+ handleEvent(ev);
3900
+ } catch (e) {
3901
+ dbg("handler error:", e instanceof Error ? e.message : String(e));
3902
+ }
3903
+ }
3904
+ } catch (e) {
3905
+ if (running)
3906
+ log("error", `event stream ended: ${e instanceof Error ? e.message : String(e)}`);
3907
+ }
3908
+ };
3909
+ pump();
3910
+ restoreQuotaLadders();
3911
+ (async () => {
3912
+ try {
3913
+ const listed = new Set(unwrapList(await callSessionApi("list")).map((r) => r.id).filter((id) => typeof id === "string"));
3914
+ for (const sid of listed) {
3915
+ let rec;
3916
+ try {
3917
+ rec = await ctx.storage.get(`${COUNTER_STATE_PREFIX}${sid}`);
3918
+ } catch {
3919
+ continue;
3920
+ }
3921
+ if (!rec || typeof rec !== "object")
3922
+ continue;
3923
+ const recObj = rec;
3924
+ if (typeof recObj.v !== "number")
3925
+ continue;
3926
+ if (typeof recObj.at !== "number" || Date.now() - recObj.at > 24 * 3600000)
3927
+ continue;
3928
+ const w = ensureWatch(sid);
3929
+ if (w.workRetries > 0)
3930
+ continue;
3931
+ w.workRetries = typeof recObj.workRetries === "number" ? recObj.workRetries : 0;
3932
+ w.resumeAttempts = typeof recObj.resumeAttempts === "number" ? recObj.resumeAttempts : 0;
3933
+ w.lifetimeContinues = typeof recObj.lifetimeContinues === "number" ? recObj.lifetimeContinues : 0;
3934
+ w.episodeRefires = typeof recObj.episodeRefires === "number" ? recObj.episodeRefires : 0;
3935
+ dbg(`${short(sid)} counters restored: workRetries=${w.workRetries} resumeAttempts=${w.resumeAttempts} lifetimeContinues=${w.lifetimeContinues}`);
3936
+ }
3937
+ } catch (e) {
3938
+ dbg("counter restore failed:", e instanceof Error ? e.message : String(e));
3939
+ }
3940
+ })();
3941
+ if (superseded) {
3942
+ log("warn", `superseded live instance mod=${superseded.mod} (age ${Math.round((Date.now() - superseded.startedAt) / 1000)}s) by mod=${MODULE_INSTANCE} boot=${HOST_BOOT}`);
3943
+ }
3944
+ if (adoptedSessions > 0) {
3945
+ log("info", `adopted ${adoptedSessions} watched session(s) from previous instance (stall memory retained) mod=${MODULE_INSTANCE}`);
3946
+ }
3947
+ if (ctx.command && typeof ctx.command.transform === "function") {
3948
+ try {
3949
+ await ctx.command.transform((cmd) => {
3950
+ cmd.add({
3951
+ name: "auto-resume",
3952
+ description: "Pause or resume auto-resume for this session: /auto-resume off|on|status",
3953
+ execute: async (input) => {
3954
+ const sessionID = String(input?.sessionID ?? "");
3955
+ const rawArg = input?.args ?? input?.prompt?.args ?? input?.prompt?.text ?? input?.text ?? "";
3956
+ const verb = String(rawArg).trim().toLowerCase().split(/\s+/)[0] || "status";
3957
+ const w = ensureWatch(sessionID);
3958
+ const msg = (text) => ctx.session.prompt({ sessionID, text });
3959
+ if (verb === "off" || verb === "pause") {
3960
+ setPaused(sessionID, true);
3961
+ w.paused = true;
3962
+ await msg("auto-resume paused for this session. Run /auto-resume on to resume.");
3963
+ } else if (verb === "on" || verb === "resume") {
3964
+ setPaused(sessionID, false);
3965
+ w.paused = false;
3966
+ await msg("auto-resume resumed for this session.");
3967
+ } else {
3968
+ const state = isPaused(sessionID) ? "paused" : "active";
3969
+ await msg(`auto-resume is ${state} for this session.`);
3970
+ }
3971
+ }
3972
+ });
3973
+ });
3974
+ } catch (e) {
3975
+ log("warn", `auto-resume command registration failed: ${e instanceof Error ? e.message : String(e)}`);
3976
+ }
3977
+ }
3978
+ try {
3979
+ writeFileSync(PAUSED_MARKER_FILE, "", "utf8");
3980
+ } catch {}
3981
+ if (gatedInUse.length > 0) {
3982
+ log("warn", `accepted-but-inert=${gatedInUse.join(",")} \u2014 this v2 build recognises these option(s) but does not act on them yet.`);
3983
+ }
3984
+ const dispose = () => {
3985
+ running = false;
3986
+ eventAbort.abort();
3987
+ clearInterval(watchdog);
3988
+ clearInterval(discoveryTimer);
3989
+ clearTimeout(initialDiscovery);
3990
+ for (const w of sessions.values()) {
3991
+ if (w.toolTextTimer)
3992
+ clearTimeout(w.toolTextTimer);
3993
+ w.toolTextTimer = null;
3994
+ if (w.recoverTimer)
3995
+ clearTimeout(w.recoverTimer);
3996
+ w.recoverTimer = null;
3997
+ }
3998
+ const reg = singletonRegistry(registryScope);
3999
+ if (reg.live?.dispose === dispose)
4000
+ reg.live = undefined;
4001
+ log("info", `stopped mod=${MODULE_INSTANCE}`);
4002
+ };
4003
+ registry.live = { dispose, mod: MODULE_INSTANCE, startedAt: Date.now() };
4004
+ return dispose;
4005
+ }
4006
+ });
4007
+ export {
4008
+ configMentionsTaskComplete,
4009
+ v2_default as default
4010
+ };