@suwujs/king-ai 0.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 (104) hide show
  1. package/README.md +96 -0
  2. package/dist/src/agent-config-validation.d.ts +9 -0
  3. package/dist/src/agent-config-validation.js +30 -0
  4. package/dist/src/api.d.ts +4 -0
  5. package/dist/src/api.js +48 -0
  6. package/dist/src/attachments.d.ts +45 -0
  7. package/dist/src/attachments.js +322 -0
  8. package/dist/src/cli.d.ts +20 -0
  9. package/dist/src/cli.js +1697 -0
  10. package/dist/src/config.d.ts +3 -0
  11. package/dist/src/config.js +20 -0
  12. package/dist/src/cron.d.ts +11 -0
  13. package/dist/src/cron.js +65 -0
  14. package/dist/src/daemon.d.ts +36 -0
  15. package/dist/src/daemon.js +373 -0
  16. package/dist/src/engine.d.ts +32 -0
  17. package/dist/src/engine.js +1014 -0
  18. package/dist/src/heartbeat.d.ts +18 -0
  19. package/dist/src/heartbeat.js +28 -0
  20. package/dist/src/host-api.d.ts +40 -0
  21. package/dist/src/host-api.js +59 -0
  22. package/dist/src/host-control.d.ts +48 -0
  23. package/dist/src/host-control.js +1279 -0
  24. package/dist/src/host-export.d.ts +50 -0
  25. package/dist/src/host-export.js +187 -0
  26. package/dist/src/host-feedback.d.ts +78 -0
  27. package/dist/src/host-feedback.js +178 -0
  28. package/dist/src/host-home.d.ts +13 -0
  29. package/dist/src/host-home.js +54 -0
  30. package/dist/src/host-ledger.d.ts +261 -0
  31. package/dist/src/host-ledger.js +554 -0
  32. package/dist/src/host-loop-events.d.ts +69 -0
  33. package/dist/src/host-loop-events.js +288 -0
  34. package/dist/src/host-permission.d.ts +36 -0
  35. package/dist/src/host-permission.js +180 -0
  36. package/dist/src/host-policy.d.ts +15 -0
  37. package/dist/src/host-policy.js +36 -0
  38. package/dist/src/host-run-executor.d.ts +13 -0
  39. package/dist/src/host-run-executor.js +221 -0
  40. package/dist/src/host-run-heartbeat.d.ts +40 -0
  41. package/dist/src/host-run-heartbeat.js +103 -0
  42. package/dist/src/host-run-layout.d.ts +17 -0
  43. package/dist/src/host-run-layout.js +387 -0
  44. package/dist/src/host-run-meta.d.ts +41 -0
  45. package/dist/src/host-run-meta.js +115 -0
  46. package/dist/src/host-run-spec.d.ts +149 -0
  47. package/dist/src/host-run-spec.js +465 -0
  48. package/dist/src/host-runs.d.ts +77 -0
  49. package/dist/src/host-runs.js +195 -0
  50. package/dist/src/host-sdk.d.ts +412 -0
  51. package/dist/src/host-sdk.js +628 -0
  52. package/dist/src/host-server.d.ts +26 -0
  53. package/dist/src/host-server.js +921 -0
  54. package/dist/src/host-timeline.d.ts +24 -0
  55. package/dist/src/host-timeline.js +161 -0
  56. package/dist/src/jsonl.d.ts +13 -0
  57. package/dist/src/jsonl.js +47 -0
  58. package/dist/src/lifecycle.d.ts +5 -0
  59. package/dist/src/lifecycle.js +18 -0
  60. package/dist/src/message-routing.d.ts +32 -0
  61. package/dist/src/message-routing.js +119 -0
  62. package/dist/src/paths.d.ts +19 -0
  63. package/dist/src/paths.js +26 -0
  64. package/dist/src/project-profile.d.ts +49 -0
  65. package/dist/src/project-profile.js +356 -0
  66. package/dist/src/remediation.d.ts +14 -0
  67. package/dist/src/remediation.js +114 -0
  68. package/dist/src/remote-devices.d.ts +41 -0
  69. package/dist/src/remote-devices.js +156 -0
  70. package/dist/src/remote-diagnostics.d.ts +39 -0
  71. package/dist/src/remote-diagnostics.js +199 -0
  72. package/dist/src/remote-ssh.d.ts +39 -0
  73. package/dist/src/remote-ssh.js +129 -0
  74. package/dist/src/run-stream.d.ts +57 -0
  75. package/dist/src/run-stream.js +119 -0
  76. package/dist/src/runner.d.ts +131 -0
  77. package/dist/src/runner.js +1161 -0
  78. package/dist/src/runtime-data.d.ts +68 -0
  79. package/dist/src/runtime-data.js +172 -0
  80. package/dist/src/service.d.ts +114 -0
  81. package/dist/src/service.js +631 -0
  82. package/dist/src/shared-skills.d.ts +26 -0
  83. package/dist/src/shared-skills.js +85 -0
  84. package/dist/src/shim.d.ts +1 -0
  85. package/dist/src/shim.js +64 -0
  86. package/dist/src/skill-check.d.ts +17 -0
  87. package/dist/src/skill-check.js +158 -0
  88. package/dist/src/sse.d.ts +9 -0
  89. package/dist/src/sse.js +36 -0
  90. package/dist/src/team-routing.d.ts +55 -0
  91. package/dist/src/team-routing.js +131 -0
  92. package/dist/src/team-workflow.d.ts +78 -0
  93. package/dist/src/team-workflow.js +253 -0
  94. package/dist/src/text.d.ts +7 -0
  95. package/dist/src/text.js +27 -0
  96. package/dist/src/types.d.ts +98 -0
  97. package/dist/src/types.js +1 -0
  98. package/dist/src/usage.d.ts +116 -0
  99. package/dist/src/usage.js +350 -0
  100. package/dist/src/workspace.d.ts +9 -0
  101. package/dist/src/workspace.js +56 -0
  102. package/dist/src/worktree.d.ts +47 -0
  103. package/dist/src/worktree.js +201 -0
  104. package/package.json +63 -0
@@ -0,0 +1,1161 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { delimiter, join } from "node:path";
4
+ import { api, runtimeGet, runtimePost, tenantHeader } from "./api.js";
5
+ import { AGENTS_ROOT, SESSIONS_DIR, TRIAGE_DIR } from "./paths.js";
6
+ import { formatEngineLogLine, getAdapter, parseTriage } from "./engine.js";
7
+ import { parseSseStream } from "./sse.js";
8
+ import { writeShim } from "./shim.js";
9
+ import { authFailureHint, concise, hashText, isRateLimited } from "./text.js";
10
+ import { agentWorkspaceRoot, detectLocalCapabilities, formatWorkspacePolicy } from "./workspace.js";
11
+ import { formatWorktreePlanForPrompt, formatWorktreePreparationResults, planAgentWorktrees, prepareWorktreePlans } from "./worktree.js";
12
+ import { checkTokenBudget, emptyAgentRunStats, recordAgentRunStats, tokenBudgetFromEnv } from "./usage.js";
13
+ import { normalizeAgentLifecycle, runtimeLifecycleNote } from "./lifecycle.js";
14
+ import { installSharedSkills } from "./shared-skills.js";
15
+ import { linkHostHomeEntries } from "./host-home.js";
16
+ import { formatMessageRouteSummary, messageRouteTag, sortRuntimeMessages } from "./message-routing.js";
17
+ import { cacheLocalAttachments, formatAttachmentPrompt, normalizeRuntimeAttachments } from "./attachments.js";
18
+ import { engineRemediationAdvice } from "./remediation.js";
19
+ import { validateAgentConfig } from "./agent-config-validation.js";
20
+ const TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
21
+ const INBOX_POLL_MS = Number(process.env.KING_AI_INBOX_POLL_MS) || 20_000;
22
+ const WAKE_DEBOUNCE_MS = Number(process.env.KING_AI_WAKE_DEBOUNCE_MS) || 2500;
23
+ const RUN_HEARTBEAT_MS = Number(process.env.KING_AI_RUN_HEARTBEAT_MS) || 60_000;
24
+ const TRIAGE_TIMEOUT_MS = Number(process.env.KING_AI_TRIAGE_TIMEOUT_MS) || 30_000;
25
+ const ENGINE_BACKOFF_MS = Number(process.env.KING_AI_ENGINE_BACKOFF_MS) || 60_000;
26
+ const TRIAGE_BACKOFF_MAX_MS = Number(process.env.KING_AI_TRIAGE_BACKOFF_MAX_MS) || 600_000;
27
+ const BIG_BRAIN_SPAWN_JITTER_MS = Number(process.env.KING_AI_BYOA_BIG_BRAIN_SPAWN_JITTER_MS) || 1500;
28
+ const TRIAGE_SPAWN_JITTER_MS = Number(process.env.KING_AI_BYOA_TRIAGE_SPAWN_JITTER_MS) || 500;
29
+ const AGENDA_QUIET_MS = Number(process.env.KING_AI_AGENDA_QUIET_MS) || 180_000;
30
+ const AGENDA_CHECK_MS = Number(process.env.KING_AI_AGENDA_CHECK_MS) || 120_000;
31
+ const PREPARE_WORKTREES = process.env.KING_AI_PREPARE_WORKTREES === "1" || process.env.KING_AI_AGENT_PREPARE_WORKTREES === "1";
32
+ const NESTED_ENV_BLOCKLIST = [
33
+ "CODEX_CI",
34
+ "CODEX_SANDBOX_NETWORK_DISABLED",
35
+ "CODEX_THREAD_ID",
36
+ "CLAUDECODE",
37
+ "CLAUDE_CODE_ENTRYPOINT",
38
+ "CLAUDE_CODE_SSE_PORT",
39
+ "KING_AI_AGENT_RUNTIME_URL",
40
+ "KING_AI_AGENT_RUNTIME_TOKEN",
41
+ "KING_AI_AGENT_RUNTIME_TOKEN_FILE",
42
+ "KING_AI_AGENT_RUNTIME_TENANT",
43
+ "KING_AI_AGENT_ID",
44
+ "KING_AI_AGENT_ENGINE",
45
+ "KING_AI_AGENT_HOME",
46
+ "KING_AI_AGENT_WORKSPACE_ROOT",
47
+ "KING_AI_AGENT_WORKSPACES",
48
+ "KING_AI_AGENT_WORKTREE_PLAN",
49
+ "KING_AI_AGENT_SKILL_SNAPSHOT_ID",
50
+ "KING_AI_AGENT_SKILL_SNAPSHOT_PATH",
51
+ "KING_AI_AGENT_SKILL_SNAPSHOT_MANIFEST"
52
+ ];
53
+ export class Semaphore {
54
+ max;
55
+ inFlight = 0;
56
+ waiters = [];
57
+ constructor(max) {
58
+ this.max = max;
59
+ }
60
+ async acquire() {
61
+ if (this.inFlight < this.max) {
62
+ this.inFlight += 1;
63
+ return;
64
+ }
65
+ await new Promise((resolve) => this.waiters.push(resolve));
66
+ this.inFlight += 1;
67
+ }
68
+ release() {
69
+ this.inFlight = Math.max(0, this.inFlight - 1);
70
+ const next = this.waiters.shift();
71
+ if (next)
72
+ next();
73
+ }
74
+ get queueDepth() {
75
+ return this.waiters.length;
76
+ }
77
+ }
78
+ function envConcurrency(name, fallback) {
79
+ const n = Number(process.env[name] || fallback);
80
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
81
+ }
82
+ const bigBrainSem = new Semaphore(envConcurrency("KING_AI_BYOA_MAX_CONCURRENT_BIG_BRAIN", 2));
83
+ const triageSem = new Semaphore(envConcurrency("KING_AI_BYOA_MAX_CONCURRENT_TRIAGE", 4));
84
+ function usageForRuntime(usage) {
85
+ if (!usage || typeof usage !== "object")
86
+ return null;
87
+ return usage;
88
+ }
89
+ export function sanitizeNestedEngineEnv(env) {
90
+ const clean = { ...env };
91
+ for (const key of NESTED_ENV_BLOCKLIST)
92
+ delete clean[key];
93
+ for (const key of Object.keys(clean)) {
94
+ if (key.startsWith("ORCA_") || key.startsWith("OPENAI_CODEX_"))
95
+ delete clean[key];
96
+ }
97
+ return clean;
98
+ }
99
+ export function selectSteerMessage(rows, conversationId, agentId, lastSteeredMsgId) {
100
+ const inConversation = sortRuntimeMessages(rows.filter((row) => row.conversation_id === conversationId), agentId)
101
+ .filter((item) => item.route === "steer" || item.route === "respond")
102
+ .map((item) => item.row);
103
+ if (inConversation.length === 0)
104
+ return null;
105
+ return inConversation.find((row) => row.id && row.id !== lastSteeredMsgId) ?? null;
106
+ }
107
+ export function formatSteerPrompt(row, conversationId, agentId = row.to_agent_id ?? "") {
108
+ const who = row.author_name ?? "someone";
109
+ const body = (row.body ?? "").replace(/\s+/g, " ").slice(0, 300);
110
+ const routed = sortRuntimeMessages([row], agentId)[0];
111
+ const tag = routed ? messageRouteTag(routed) : "message";
112
+ return `A ${tag} runtime message arrived while you work. Answer it briefly if it needs you, then resume your current task. ${who} in ${conversationId}: "${body}". Reply one line now with: king-ai reply ${conversationId} 'text'. Then continue what you were doing.`;
113
+ }
114
+ export function buildStandingPrompt(workspaces = [], agentRoot, worktreeNote = "") {
115
+ return `You are a local BYOA teammate agent with your own voice. Use the king-ai CLI on PATH to interact with the runtime.
116
+
117
+ Privacy boundary: this machine belongs to the operator. Stay inside your private agent home unless the operator explicitly asks otherwise in this runtime.
118
+ ${formatWorkspacePolicy(workspaces, agentRoot)}
119
+ ${worktreeNote}
120
+
121
+ Shared skills: when KING_AI_SHARED_SKILLS is configured, the daemon copies every skill directory containing SKILL.md into .claude/skills and .codex/skills in this agent home before starting you.
122
+ The daemon also writes an activation snapshot under .king-ai/skill-snapshots, or KING_AI_SKILL_SNAPSHOTS_DIR when configured, so a run can audit exactly which skill files were available.
123
+
124
+ Host home entries: when KING_AI_HOST_HOME_ENTRIES is configured, the operator has explicitly linked selected host-home dotfiles or dot directories into this agent home. Treat them as sensitive credentials/configuration and use them only for the runtime task.
125
+
126
+ Remote test diagnostics: when a human asks you to investigate bugs, logs, database records, Redis state, or statistics on test environments, use king-ai host remote-list --json to discover configured devices and autonomously use king-ai host remote-profile, king-ai host remote-probe, king-ai host remote-run, king-ai host remote-logs, king-ai host remote-find-logs, king-ai host remote-pg, and king-ai host remote-redis. Do not ask the human for SSH commands unless the target device, app, or business object is missing. When reporting, include conclusion, evidence sources, checked scope, and remaining unknowns.
127
+
128
+ Memory: durable memory lives in memory/MEMORY.md and detail files under memory/. When asked to remember something, write it to a memory file and add a one-line pointer to MEMORY.md.
129
+
130
+ Coordination:
131
+ - Before you commit a reply or shared tool action, run king-ai glance <conversationId>. Treat the composing list as raised hands ordered by who started first.
132
+ - If another teammate already posted the same angle, do not repeat it. React, stay silent, or build on it only when you add something new.
133
+ - If a teammate has an earlier composing claim and your planned reply or shared tool action is redundant, wait and glance again until their claim clears or their reply lands.
134
+ - For shared resources, especially doc create, calendar create, group-level mutations, games, moderation, or one concrete deliverable, announce or claim before doing the work. Prefer king-ai card claim <cardId> for real work and king-ai claim <name> --in <conversationId> for short-lived locks.
135
+ - Trust board cards and claims over your memory. If a card is done or someone owns the same lock, do not duplicate the work.
136
+ - For sequence, relay, round-robin, no-duplicates, or "who starts" tasks, continue from the newest visible message. Never restart the count or fork the opener.
137
+
138
+ Posting: for replies with backticks, code, $, quotes, or multiple lines, write a draft under notes/ and send it with king-ai reply <conversationId> --file notes/reply.md or king-ai reply <conversationId> --file notes/reply.md. For short plain text, king-ai reply <conversationId> '<text>' is fine. When answering a specific message, use --quote <messageId>. Address teammates by @<agent-id>, not by display name.
139
+
140
+ Expressiveness: King AI clients can render Skype shortcode text such as (smile), (clap), (ok), (think), (coffee), or (wfh). Use at most one when it genuinely helps tone; plain text is usually better.
141
+
142
+ Drive what you own forward. Multi-step turns are fine. If someone DMs you mid-task, answer briefly and continue. If progress is waiting on someone else, send one short follow-up and schedule a check-back with king-ai calendar create '<chase>' --at <iso> --assignee <your-agent-id> --prompt '<what future-you should do>'.
143
+
144
+ Useful runtime commands include king-ai inbox, messages <conversationId> --tail 30, glance <conversationId>, roster, participants, contacts, whoami, agenda, observe [--json], initiative create|list|get|update, task list|create|update|done, capsule create|list|mine|get|update, artifact put|list|get, hypothesis create|list|update, context get|set|list, send <agentId> <message>, recv [--agent agent-id], escalate <message>, calendar list, card list, dm <agentId> <text>, react <messageId> <emoji>, and doc list|create|show.`;
145
+ }
146
+ export function shouldStopEngineOnBeginStop() {
147
+ return false;
148
+ }
149
+ export function visibleEngineError(engine, home, exitCode, detail) {
150
+ const raw = concise(detail || `process exited with code ${exitCode}`);
151
+ const homeDir = homedir();
152
+ const userHomePattern = String.raw `(?:\/Users\/[^/\s"']+|\/home\/[^/\s"']+|[A-Za-z]:\\Users\\[^\\\s"']+)`;
153
+ const redacted = raw
154
+ .split(home)
155
+ .join("<agent home>")
156
+ .split(homeDir)
157
+ .join("~")
158
+ .replace(new RegExp(userHomePattern, "g"), "~");
159
+ return `local ${engine} failed (exit ${exitCode}): ${redacted}`;
160
+ }
161
+ export function formatTriageNote(triage) {
162
+ if (!triage)
163
+ return "";
164
+ const state = triage.actionable === false ? "not relevant" : "relevant";
165
+ const source = triage.source ?? "server";
166
+ const note = triage.promptNote?.trim();
167
+ const reason = triage.reason?.trim();
168
+ const responseMode = triage.responseMode === "me"
169
+ ? "Response mode: me - this wake is specifically for you; answer if the message needs a response."
170
+ : triage.responseMode === "each"
171
+ ? "Response mode: each - every relevant teammate may contribute their own distinct reply."
172
+ : triage.responseMode === "one-of-us"
173
+ ? "Response mode: one-of-us - coordinate with glance/claims so only one teammate handles the reply."
174
+ : "";
175
+ return [
176
+ `Small-brain inbox triage (${state}, ${source}).`,
177
+ responseMode,
178
+ triage.routeHint ? `Route hint: ${triage.routeHint}.` : "",
179
+ triage.priority ? `Priority: ${triage.priority}.` : "",
180
+ note ? `Instruction: ${note}` : "",
181
+ reason ? `Reason: ${reason.slice(0, 500)}` : ""
182
+ ].filter(Boolean).join("\n");
183
+ }
184
+ export function buildChatDelta(digest, memoryDigest, rosterDigest, triage) {
185
+ const triageNote = formatTriageNote(triage);
186
+ return `You've been woken because there's new runtime activity, and local triage already decided whether you should respond. If triage marked it relevant, your job is to act, not re-litigate whether to wake.
187
+
188
+ ${triageNote ? `${triageNote}\n\n` : ""}Your unread messages (ALREADY FETCHED - no need to re-run king-ai inbox or messages just to reread these; do run king-ai glance before posting in a group to catch anything posted while you compose):
189
+ ${digest || "(none)"}
190
+
191
+ Your memory index (memory/MEMORY.md):
192
+ ${memoryDigest || "(empty)"}
193
+
194
+ Your team right now (trust over memory - use these current ids for @mentions and king-ai dm):
195
+ ${rosterDigest || "(unavailable)"}`;
196
+ }
197
+ export function buildAgendaDelta(brief, memoryDigest, rosterDigest) {
198
+ return `You've been woken by your own agenda: assigned board work, due calendar items, or follow-up work. The system already triaged that there is real work to progress, so act on one timely item instead of re-deciding whether to wake.
199
+
200
+ For quiet conversations, first decide if they are genuinely waiting or already concluded. If concluded, do not post; reviving a finished thread is noise. Only when someone is plainly waiting, send at most one useful follow-up.
201
+
202
+ Agenda:
203
+ ${brief}
204
+
205
+ Your memory index (memory/MEMORY.md):
206
+ ${memoryDigest || "(empty)"}
207
+
208
+ Your team right now (trust over memory - use these current ids for @mentions and king-ai dm):
209
+ ${rosterDigest || "(unavailable)"}`;
210
+ }
211
+ export function buildRuntimePreambleSection(preamble) {
212
+ const text = preamble?.trim();
213
+ if (!text)
214
+ return "";
215
+ return `Runtime preamble (current system context):
216
+ ${text.slice(0, 4000)}`;
217
+ }
218
+ export function appendRuntimePreamble(delta, preamble) {
219
+ const section = buildRuntimePreambleSection(preamble);
220
+ return section ? `${section}\n\n${delta}` : delta;
221
+ }
222
+ const CONTEXT_OVERFLOW_RE = /context window|context length|context_length_exceeded|maximum context|reached its context|prompt is too long|input is too long|too many tokens/i;
223
+ const POISONED_BODY_RE = /no (?:low|high) surrogate|unpaired surrogate|lone surrogate|surrogate in string|request body is not valid json/i;
224
+ export function isContextOverflow(error) {
225
+ return CONTEXT_OVERFLOW_RE.test(error);
226
+ }
227
+ export function isPoisonedTranscript(error) {
228
+ return POISONED_BODY_RE.test(error);
229
+ }
230
+ export function mustResetSession(error, hadResume) {
231
+ if (isContextOverflow(error))
232
+ return true;
233
+ if (isPoisonedTranscript(error))
234
+ return true;
235
+ if (hadResume && /resume|session|conversation/i.test(error))
236
+ return true;
237
+ return false;
238
+ }
239
+ export function sessionResetReason(error) {
240
+ if (isContextOverflow(error))
241
+ return "engine hit its context-window limit";
242
+ if (isPoisonedTranscript(error))
243
+ return "transcript poisoned by a malformed character";
244
+ return "engine session problem";
245
+ }
246
+ export function swallowTurnRejection(task, onError) {
247
+ void task.catch((err) => {
248
+ onError(err instanceof Error ? err.message : String(err));
249
+ });
250
+ }
251
+ export function agentSessionFile(agentId, engine) {
252
+ return join(SESSIONS_DIR, `${agentId}.${engine}.session`);
253
+ }
254
+ export function parseWakeEventInfo(rawData, now = Date.now()) {
255
+ if (!rawData)
256
+ return { conversationId: null, agentId: null, sentAt: null, deliveryLatencyMs: null };
257
+ try {
258
+ const data = JSON.parse(rawData);
259
+ const conversationId = typeof data.conversationId === "string" ? data.conversationId : null;
260
+ const agentId = typeof data.agentId === "string" ? data.agentId : null;
261
+ const sentAt = typeof data.at === "number" && Number.isFinite(data.at) ? data.at : null;
262
+ return {
263
+ conversationId,
264
+ agentId,
265
+ sentAt,
266
+ deliveryLatencyMs: sentAt == null ? null : Math.max(0, now - sentAt)
267
+ };
268
+ }
269
+ catch {
270
+ return { conversationId: null, agentId: null, sentAt: null, deliveryLatencyMs: null };
271
+ }
272
+ }
273
+ export function shouldHandleWakeEvent(info, agentId) {
274
+ return !info.agentId || info.agentId === agentId;
275
+ }
276
+ export class AgentRunner {
277
+ cfg;
278
+ agent;
279
+ availableEngines;
280
+ onStateChange;
281
+ home;
282
+ binDir;
283
+ sessionFile;
284
+ adapter;
285
+ token = "";
286
+ tokenExpiresAt = 0;
287
+ pollTimer = null;
288
+ wakeDebounceTimer = null;
289
+ wakeStreamController = null;
290
+ busy = false;
291
+ pendingRerun = false;
292
+ stopped = false;
293
+ sessionId = null;
294
+ engineSession = null;
295
+ engineBackoffUntil = 0;
296
+ triageBackoffUntil = 0;
297
+ triageTroubleStreak = 0;
298
+ lastWakeConvo = null;
299
+ lastTurnEndedAt = 0;
300
+ lastAgendaCheckAt = 0;
301
+ sideSteering = false;
302
+ lastSteeredMsgId = null;
303
+ runStats = emptyAgentRunStats();
304
+ lastBudgetEventState = null;
305
+ sharedSkillSnapshot;
306
+ hostHomeEntries = [];
307
+ remediation = null;
308
+ constructor(cfg, agent, engine, availableEngines = [engine], onStateChange) {
309
+ this.cfg = cfg;
310
+ this.agent = agent;
311
+ this.availableEngines = availableEngines;
312
+ this.onStateChange = onStateChange;
313
+ this.home = join(AGENTS_ROOT, agent.id);
314
+ this.binDir = join(this.home, "bin");
315
+ this.adapter = getAdapter(engine);
316
+ this.sessionFile = agentSessionFile(agent.id, this.adapter.id);
317
+ }
318
+ get isBusy() {
319
+ return this.busy;
320
+ }
321
+ runningState() {
322
+ return {
323
+ id: this.agent.id,
324
+ name: this.agent.name,
325
+ engine: this.adapter.id,
326
+ lifecycle: normalizeAgentLifecycle(this.agent.lifecycle),
327
+ status: this.busy ? "running" : "idle",
328
+ model: this.agent.model,
329
+ sharedSkillSnapshot: this.sharedSkillSnapshot
330
+ ? {
331
+ id: this.sharedSkillSnapshot.id,
332
+ root: this.sharedSkillSnapshot.root,
333
+ manifestPath: this.sharedSkillSnapshot.manifestPath,
334
+ skills: this.sharedSkillSnapshot.skills.map((skill) => skill.name)
335
+ }
336
+ : null,
337
+ hostHomeEntries: this.hostHomeEntries,
338
+ workspaceRoot: this.workspaceRoot(),
339
+ worktreePlans: this.worktreePlans(),
340
+ worktreeMaterializationEnabled: PREPARE_WORKTREES,
341
+ runStats: this.runStats,
342
+ tokenBudget: checkTokenBudget(this.runStats, tokenBudgetFromEnv()),
343
+ remediation: this.remediation,
344
+ configWarnings: this.configWarnings(),
345
+ updatedAt: new Date().toISOString()
346
+ };
347
+ }
348
+ configWarnings() {
349
+ return validateAgentConfig(this.agent, this.adapter.id, this.availableEngines);
350
+ }
351
+ notifyStateChange() {
352
+ this.onStateChange?.();
353
+ }
354
+ workspaceRoot() {
355
+ return agentWorkspaceRoot(this.agent.id, this.home);
356
+ }
357
+ worktreePlans() {
358
+ return planAgentWorktrees({
359
+ agentId: this.agent.id,
360
+ workspaces: detectLocalCapabilities().workspaces,
361
+ baseRoot: this.workspaceRoot()
362
+ });
363
+ }
364
+ configMatches(agent, engine) {
365
+ return (this.adapter.id === engine &&
366
+ this.agent.name === agent.name &&
367
+ this.agent.role === agent.role &&
368
+ this.agent.model === agent.model &&
369
+ this.agent.fastModel === agent.fastModel &&
370
+ normalizeAgentLifecycle(this.agent.lifecycle) === normalizeAgentLifecycle(agent.lifecycle));
371
+ }
372
+ async start() {
373
+ await this.adapter.seedHome(this.home, this.agent);
374
+ this.hostHomeEntries = await linkHostHomeEntries(this.home);
375
+ const sharedSkills = await installSharedSkills(this.home);
376
+ this.sharedSkillSnapshot = sharedSkills.snapshot;
377
+ await mkdir(this.workspaceRoot(), { recursive: true });
378
+ const worktreePlans = this.worktreePlans();
379
+ const worktreePreparation = PREPARE_WORKTREES
380
+ ? await prepareWorktreePlans(worktreePlans, { execute: true })
381
+ : [];
382
+ await writeShim(this.binDir);
383
+ await this.loadSessionId();
384
+ await this.publishEvent("agent.started", {
385
+ engine: this.adapter.id,
386
+ lifecycle: normalizeAgentLifecycle(this.agent.lifecycle),
387
+ lifecycleNote: runtimeLifecycleNote(normalizeAgentLifecycle(this.agent.lifecycle)),
388
+ home: this.home,
389
+ workspaces: detectLocalCapabilities().workspaces,
390
+ worktreePlans,
391
+ worktreeMaterialization: {
392
+ enabled: PREPARE_WORKTREES,
393
+ results: worktreePreparation
394
+ },
395
+ sharedSkillRoots: sharedSkills.sourceRoots,
396
+ sharedSkills: sharedSkills.installed.map((skill) => skill.name),
397
+ sharedSkillSnapshot: sharedSkills.snapshot
398
+ ? {
399
+ id: sharedSkills.snapshot.id,
400
+ root: sharedSkills.snapshot.root,
401
+ manifestPath: sharedSkills.snapshot.manifestPath,
402
+ skills: sharedSkills.snapshot.skills.map((skill) => skill.name)
403
+ }
404
+ : null,
405
+ hostHomeEntries: this.hostHomeEntries
406
+ }, "info", `${this.agent.name} started${PREPARE_WORKTREES ? `; ${formatWorktreePreparationResults(worktreePreparation, true).split("\n")[0]}` : ""}`);
407
+ void this.streamLoop();
408
+ this.pollTimer = setInterval(() => {
409
+ if (!this.busy && !this.stopped)
410
+ this.scheduleWake("poll");
411
+ }, INBOX_POLL_MS);
412
+ }
413
+ beginStop() {
414
+ this.stopped = true;
415
+ if (this.pollTimer)
416
+ clearInterval(this.pollTimer);
417
+ if (this.wakeDebounceTimer)
418
+ clearTimeout(this.wakeDebounceTimer);
419
+ abortWakeStream(this.wakeStreamController);
420
+ this.wakeStreamController = null;
421
+ this.pollTimer = null;
422
+ this.wakeDebounceTimer = null;
423
+ }
424
+ stop() {
425
+ this.beginStop();
426
+ this.engineSession?.stop();
427
+ this.engineSession = null;
428
+ }
429
+ scheduleWake(reason, conversationId) {
430
+ if (this.stopped)
431
+ return;
432
+ if (conversationId)
433
+ this.lastWakeConvo = conversationId;
434
+ if (this.busy) {
435
+ this.pendingRerun = true;
436
+ this.kickTurn(reason);
437
+ if (conversationId)
438
+ void this.maybeSteer(conversationId);
439
+ return;
440
+ }
441
+ if (this.wakeDebounceTimer)
442
+ return;
443
+ this.wakeDebounceTimer = setTimeout(() => {
444
+ this.wakeDebounceTimer = null;
445
+ this.kickTurn(reason);
446
+ }, WAKE_DEBOUNCE_MS);
447
+ this.wakeDebounceTimer.unref?.();
448
+ }
449
+ kickTurn(reason) {
450
+ swallowTurnRejection(this.runTurn(reason), (message) => {
451
+ console.error(`[${this.agent.id}/${this.adapter.id}] runTurn rejected (swallowed): ${message}`);
452
+ });
453
+ }
454
+ steerRunningTurn(text) {
455
+ if (!this.busy || !this.engineSession?.alive || !text.trim())
456
+ return;
457
+ this.engineSession.steer(text);
458
+ }
459
+ async maybeSteer(conversationId) {
460
+ const session = this.engineSession;
461
+ if (!session?.alive || this.sideSteering)
462
+ return;
463
+ this.sideSteering = true;
464
+ try {
465
+ const token = await this.ensureToken();
466
+ const inbox = await runtimeGet(this.cfg.serverUrl, "/inbox?probe=1", token, this.cfg.tenantId);
467
+ const latest = selectSteerMessage(inbox?.rows ?? [], conversationId, this.agent.id, this.lastSteeredMsgId);
468
+ if (!latest?.id)
469
+ return;
470
+ this.lastSteeredMsgId = latest.id;
471
+ session.steer(formatSteerPrompt(latest, conversationId, this.agent.id));
472
+ console.log(`[${this.agent.id}/${this.adapter.id}] steered live turn for ${conversationId}`);
473
+ }
474
+ catch {
475
+ // Best-effort only; the normal coalesced rerun still handles the inbox.
476
+ }
477
+ finally {
478
+ this.sideSteering = false;
479
+ }
480
+ }
481
+ async ensureToken() {
482
+ if (this.token && Date.now() < this.tokenExpiresAt - TOKEN_REFRESH_SKEW_MS)
483
+ return this.token;
484
+ const minted = await api(this.cfg.serverUrl, `/api/agents/${this.agent.id}/runtime-token`, {
485
+ method: "POST",
486
+ headers: { Authorization: `Bearer ${this.cfg.deviceToken}`, ...tenantHeader(this.cfg.tenantId) },
487
+ body: "{}"
488
+ });
489
+ this.token = minted.token;
490
+ this.tokenExpiresAt = Date.now() + minted.expiresInSeconds * 1000;
491
+ await mkdir(this.binDir, { recursive: true });
492
+ await writeFile(join(this.binDir, ".runtime-token"), this.token, { mode: 0o600 });
493
+ return this.token;
494
+ }
495
+ engineEnv() {
496
+ const capabilities = detectLocalCapabilities();
497
+ return {
498
+ ...sanitizeNestedEngineEnv(process.env),
499
+ PATH: `${this.binDir}:${process.env.PATH ?? ""}`,
500
+ KING_AI_AGENT_RUNTIME_URL: `${this.cfg.serverUrl}/runtime`,
501
+ KING_AI_AGENT_RUNTIME_TOKEN: this.token,
502
+ KING_AI_AGENT_RUNTIME_TOKEN_FILE: join(this.binDir, ".runtime-token"),
503
+ KING_AI_AGENT_RUNTIME_TENANT: this.cfg.tenantId ?? "",
504
+ KING_AI_AGENT_ID: this.agent.id,
505
+ KING_AI_AGENT_ENGINE: this.adapter.id,
506
+ KING_AI_AGENT_HOME: this.home,
507
+ KING_AI_AGENT_WORKSPACE_ROOT: this.workspaceRoot(),
508
+ KING_AI_AGENT_WORKSPACES: capabilities.workspaces.join(delimiter),
509
+ KING_AI_AGENT_WORKTREE_PLAN: JSON.stringify(this.worktreePlans()),
510
+ KING_AI_AGENT_SKILL_SNAPSHOT_ID: this.sharedSkillSnapshot?.id ?? "",
511
+ KING_AI_AGENT_SKILL_SNAPSHOT_PATH: this.sharedSkillSnapshot?.root ?? "",
512
+ KING_AI_AGENT_SKILL_SNAPSHOT_MANIFEST: this.sharedSkillSnapshot?.manifestPath ?? ""
513
+ };
514
+ }
515
+ standingPrompt() {
516
+ const note = formatWorktreePlanForPrompt(this.worktreePlans()) +
517
+ (PREPARE_WORKTREES ? "\nKING_AI_PREPARE_WORKTREES=1: the daemon attempted to create these local worktrees before starting this agent." : "");
518
+ return buildStandingPrompt(detectLocalCapabilities().workspaces, this.workspaceRoot(), note);
519
+ }
520
+ async memoryDigest() {
521
+ try {
522
+ const text = (await readFile(join(this.home, "memory", "MEMORY.md"), "utf8")).trim();
523
+ return text.length > 4000 ? `${text.slice(0, 4000)}\n...(truncated; open memory/MEMORY.md for the rest)` : text;
524
+ }
525
+ catch {
526
+ return "";
527
+ }
528
+ }
529
+ async loadSessionId() {
530
+ try {
531
+ const s = (await readFile(this.sessionFile, "utf8")).trim();
532
+ if (s) {
533
+ this.sessionId = s;
534
+ console.log(`[${this.agent.id}/${this.adapter.id}] restored engine session ${s.slice(0, 8)} from disk; will resume`);
535
+ }
536
+ }
537
+ catch {
538
+ this.sessionId = null;
539
+ }
540
+ }
541
+ async setSessionId(id) {
542
+ if (id === this.sessionId)
543
+ return;
544
+ if (!id) {
545
+ await this.clearSessionId();
546
+ return;
547
+ }
548
+ this.sessionId = id;
549
+ await mkdir(SESSIONS_DIR, { recursive: true });
550
+ await writeFile(this.sessionFile, id, "utf8");
551
+ }
552
+ async clearSessionId() {
553
+ this.sessionId = null;
554
+ await rm(this.sessionFile, { force: true });
555
+ }
556
+ async resetEngineSession(reason) {
557
+ console.warn(`[${this.agent.id}/${this.adapter.id}] ${reason}; starting a fresh engine session next wake`);
558
+ await this.clearSessionId();
559
+ this.engineSession?.stop();
560
+ this.engineSession = null;
561
+ }
562
+ beatRun(token, runId) {
563
+ if (!runId)
564
+ return () => undefined;
565
+ const beat = () => void runtimePost(this.cfg.serverUrl, `/runs/${runId}/heartbeat`, token, {}, this.cfg.tenantId);
566
+ beat();
567
+ const timer = setInterval(beat, RUN_HEARTBEAT_MS);
568
+ return () => clearInterval(timer);
569
+ }
570
+ async failureConversationIds(token, preferred) {
571
+ if (preferred)
572
+ return [preferred];
573
+ const inbox = await runtimeGet(this.cfg.serverUrl, "/inbox?probe=1", token, this.cfg.tenantId);
574
+ const ids = new Set();
575
+ for (const row of inbox?.rows ?? []) {
576
+ if (row.conversation_id)
577
+ ids.add(row.conversation_id);
578
+ if (ids.size >= 5)
579
+ break;
580
+ }
581
+ return [...ids];
582
+ }
583
+ async publishEngineFailure(args) {
584
+ await runtimePost(this.cfg.serverUrl, "/events", args.token, {
585
+ runId: args.runId,
586
+ kind: "engine.failed",
587
+ level: "error",
588
+ title: `${this.adapter.id} failed`,
589
+ data: { error: args.error, exitCode: args.exitCode }
590
+ }, this.cfg.tenantId);
591
+ const conversationIds = await this.failureConversationIds(args.token, args.conversationId);
592
+ await Promise.all(conversationIds.map((conversationId) => runtimePost(this.cfg.serverUrl, "/notices", args.token, {
593
+ conversationId,
594
+ agentId: this.agent.id,
595
+ noticeKind: "byoa_engine_failed",
596
+ text: `${this.agent.name} could not run on local ${this.adapter.id}: ${args.error}\n${authFailureHint(this.adapter.id, args.error)}`,
597
+ dedupeKey: `byoa_engine_failed:${this.agent.id}:${conversationId}:${hashText(args.error)}`,
598
+ dedupeTtlSec: 900
599
+ }, this.cfg.tenantId)));
600
+ }
601
+ async publishEvent(kind, data, level = "info", title) {
602
+ try {
603
+ const token = await this.ensureToken();
604
+ await runtimePost(this.cfg.serverUrl, "/events", token, {
605
+ kind,
606
+ level,
607
+ title: title ?? kind,
608
+ agentId: this.agent.id,
609
+ engine: this.adapter.id,
610
+ data
611
+ }, this.cfg.tenantId);
612
+ }
613
+ catch {
614
+ // Events are observability only; never block agent execution on them.
615
+ }
616
+ }
617
+ async publishBudgetEvent(token, runId) {
618
+ const check = checkTokenBudget(this.runStats, tokenBudgetFromEnv());
619
+ if (!check || check.state === "ok") {
620
+ this.lastBudgetEventState = null;
621
+ return;
622
+ }
623
+ if (check.state === this.lastBudgetEventState)
624
+ return;
625
+ this.lastBudgetEventState = check.state;
626
+ await runtimePost(this.cfg.serverUrl, "/events", token, {
627
+ runId,
628
+ kind: check.exceeded ? "agent.budget_exceeded" : "agent.budget_warning",
629
+ level: check.exceeded ? "error" : "warn",
630
+ title: `${this.agent.name} token budget ${check.state}`,
631
+ agentId: this.agent.id,
632
+ engine: this.adapter.id,
633
+ data: check
634
+ }, this.cfg.tenantId);
635
+ }
636
+ triageModel() {
637
+ return process.env.KING_AI_TRIAGE_MODEL || (this.adapter.id === "claude" ? "haiku" : "gpt-5.4-mini");
638
+ }
639
+ async recordTriageUsage(token, verdict, usage) {
640
+ await runtimePost(this.cfg.serverUrl, "/triage", token, {
641
+ source: `byoa-${this.adapter.id}`,
642
+ model: this.triageModel(),
643
+ actionable: verdict.actionable,
644
+ reason: verdict.reason ?? "",
645
+ responseMode: verdict.responseMode,
646
+ usage: usageForRuntime(usage)
647
+ }, this.cfg.tenantId);
648
+ }
649
+ async inboxTriage(token) {
650
+ const payload = await runtimeGet(this.cfg.serverUrl, "/inbox-triage/payload", token, this.cfg.tenantId);
651
+ if (!payload)
652
+ return null;
653
+ if (payload.verdict)
654
+ return payload.verdict;
655
+ if (!payload.instructions || !payload.input)
656
+ return null;
657
+ const jitter = Math.floor(Math.random() * TRIAGE_SPAWN_JITTER_MS);
658
+ if (jitter > 0)
659
+ await new Promise((resolve) => setTimeout(resolve, jitter));
660
+ await triageSem.acquire();
661
+ const controller = new AbortController();
662
+ const timer = setTimeout(() => controller.abort(), TRIAGE_TIMEOUT_MS);
663
+ try {
664
+ await mkdir(TRIAGE_DIR, { recursive: true });
665
+ const res = await this.adapter.classify({
666
+ cwd: TRIAGE_DIR,
667
+ prompt: `${payload.instructions}\n\n${payload.input}`,
668
+ env: this.engineEnv(),
669
+ model: process.env.KING_AI_TRIAGE_MODEL,
670
+ signal: controller.signal
671
+ });
672
+ if (res.error || !res.text.trim()) {
673
+ const errText = res.error || "no output";
674
+ if (controller.signal.aborted || isRateLimited(errText)) {
675
+ return { actionable: false, source: "rate-limited", reason: `triage rate-limited (${errText.slice(0, 120)}); backing off` };
676
+ }
677
+ return {
678
+ actionable: true,
679
+ source: "fail-open",
680
+ reason: `local triage failed (${errText.slice(0, 120)}); fail open`,
681
+ promptNote: "Local small-brain triage failed; read the inbox/context yourself and respond only if a human needs you. Do not silently ack unread human messages unless the thread clearly shows they are irrelevant or already handled."
682
+ };
683
+ }
684
+ const parsed = parseTriage(res.text);
685
+ if (parsed) {
686
+ const verdict = { ...parsed, source: "local" };
687
+ void this.recordTriageUsage(token, verdict, res.usage);
688
+ return verdict;
689
+ }
690
+ return {
691
+ actionable: true,
692
+ source: "fail-open",
693
+ reason: "local triage produced no usable verdict; fail open",
694
+ promptNote: "Local small-brain triage gave no clear verdict; read the inbox/context yourself and respond only if a human needs you."
695
+ };
696
+ }
697
+ finally {
698
+ clearTimeout(timer);
699
+ triageSem.release();
700
+ }
701
+ }
702
+ async snapshotUnread(token) {
703
+ const inbox = await runtimeGet(this.cfg.serverUrl, "/inbox", token, this.cfg.tenantId);
704
+ const rows = inbox?.rows ?? [];
705
+ const seen = new Map();
706
+ const lines = [];
707
+ const imagePaths = [];
708
+ let hasReal = false;
709
+ const headered = new Set();
710
+ for (const row of [...rows].sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0))) {
711
+ if (row.conversation_id && row.id)
712
+ seen.set(row.conversation_id, row.id);
713
+ }
714
+ const routedRows = sortRuntimeMessages(rows, this.agent.id);
715
+ const routeSummary = formatMessageRouteSummary(rows, this.agent.id, 10);
716
+ if (routeSummary) {
717
+ lines.push("Priority route:");
718
+ for (const line of routeSummary.split("\n"))
719
+ lines.push(` ${line}`);
720
+ }
721
+ for (const routed of routedRows) {
722
+ const row = routed.row;
723
+ if (!row.conversation_id)
724
+ continue;
725
+ if (row.kind !== "system")
726
+ hasReal = true;
727
+ if (!headered.has(row.conversation_id)) {
728
+ headered.add(row.conversation_id);
729
+ lines.push(`# ${row.conversation_id}${row.conversation_title ? ` "${row.conversation_title}"` : ""}`);
730
+ }
731
+ const who = row.author_name ?? "someone";
732
+ const body = row.kind === "system" ? "[system]" : (row.body ?? "").replace(/\s+/g, " ").slice(0, 600);
733
+ lines.push(` [${row.id ?? "?"}] [${messageRouteTag(routed)}] ${who}: ${body}`);
734
+ const attachments = await cacheLocalAttachments(normalizeRuntimeAttachments(row.attachments));
735
+ for (const attachment of attachments) {
736
+ if (attachment.kind === "image" && attachment.decision === "accepted" && attachment.localPath)
737
+ imagePaths.push(attachment.localPath);
738
+ }
739
+ const attachmentPrompt = formatAttachmentPrompt(attachments);
740
+ if (attachmentPrompt) {
741
+ for (const line of attachmentPrompt.split("\n"))
742
+ lines.push(` ${line}`);
743
+ }
744
+ }
745
+ return { seen, digest: lines.slice(-40).join("\n"), hasReal, imagePaths };
746
+ }
747
+ async ackSeen(token, seen) {
748
+ await Promise.all([...seen].map(([conversationId, upToMessageId]) => runtimePost(this.cfg.serverUrl, "/conversation/mark-read", token, { conversationId, upToMessageId }, this.cfg.tenantId)));
749
+ }
750
+ async rosterDigest(token) {
751
+ const payload = await runtimeGet(this.cfg.serverUrl, "/roster", token, this.cfg.tenantId);
752
+ return typeof payload?.roster === "string" ? payload.roster.trim().slice(0, 4000) : "";
753
+ }
754
+ async runtimePreamble(token, reason, runId, steerReason) {
755
+ const params = new URLSearchParams({
756
+ agent: this.agent.id,
757
+ reason
758
+ });
759
+ if (runId)
760
+ params.set("runId", runId);
761
+ if (steerReason)
762
+ params.set("steerReason", steerReason);
763
+ const payload = await runtimeGet(this.cfg.serverUrl, `/preamble?${params.toString()}`, token, this.cfg.tenantId);
764
+ return typeof payload?.text === "string" ? payload.text.trim().slice(0, 4000) : "";
765
+ }
766
+ promptForTurn(digest, memoryDigest, rosterDigest, preamble, triage) {
767
+ const delta = appendRuntimePreamble(buildChatDelta(digest, memoryDigest, rosterDigest, triage), preamble);
768
+ if (this.engineSession?.carriesStandingPrompt)
769
+ return delta;
770
+ return `${this.standingPrompt()}
771
+
772
+ ${delta}`;
773
+ }
774
+ promptForAgenda(brief, memoryDigest, rosterDigest, preamble) {
775
+ const delta = appendRuntimePreamble(buildAgendaDelta(brief, memoryDigest, rosterDigest), preamble);
776
+ if (this.engineSession?.carriesStandingPrompt)
777
+ return delta;
778
+ return `${this.standingPrompt()}
779
+
780
+ ${delta}`;
781
+ }
782
+ logEngineLine(line) {
783
+ const display = formatEngineLogLine(this.adapter.id, line);
784
+ if (display)
785
+ console.log(`[${this.agent.id}/${this.adapter.id}] ${display.slice(0, 500)}`);
786
+ }
787
+ ensureEngineSession() {
788
+ if (this.engineSession && this.engineSession.alive)
789
+ return this.engineSession;
790
+ const resumeSessionId = this.sessionId;
791
+ this.engineSession = this.adapter.startSession?.({
792
+ home: this.home,
793
+ env: this.engineEnv(),
794
+ model: this.agent.model,
795
+ fastModel: this.agent.fastModel,
796
+ resumeSessionId: this.sessionId,
797
+ standingPrompt: this.standingPrompt(),
798
+ onLog: (line) => this.logEngineLine(line)
799
+ }) ?? null;
800
+ if (this.engineSession) {
801
+ const resumeLabel = resumeSessionId ? `resume ${resumeSessionId.slice(0, 8)}` : "fresh";
802
+ console.log(`[${this.agent.id}/${this.adapter.id}] engine session spawned (${resumeLabel}); persistent mode active`);
803
+ void this.publishEvent("engine.session.started", {
804
+ mode: "persistent",
805
+ resumeSessionId: resumeSessionId ?? null
806
+ });
807
+ }
808
+ return this.engineSession;
809
+ }
810
+ async runTurn(reason) {
811
+ if (this.busy) {
812
+ this.pendingRerun = true;
813
+ return;
814
+ }
815
+ this.busy = true;
816
+ try {
817
+ do {
818
+ this.pendingRerun = false;
819
+ if (Date.now() < this.triageBackoffUntil)
820
+ break;
821
+ if (Date.now() < this.engineBackoffUntil)
822
+ break;
823
+ const token = await this.ensureToken();
824
+ const activeConversationId = this.lastWakeConvo;
825
+ this.lastWakeConvo = null;
826
+ await this.publishEvent("turn.check", { reason, conversationId: activeConversationId });
827
+ const { seen, digest, hasReal, imagePaths } = await this.snapshotUnread(token);
828
+ if (!hasReal) {
829
+ await this.ackSeen(token, seen);
830
+ await runtimePost(this.cfg.serverUrl, "/status", token, { status: "avail" }, this.cfg.tenantId);
831
+ await this.maybeAgendaTurn(token);
832
+ continue;
833
+ }
834
+ const triage = await this.inboxTriage(token);
835
+ if (triage?.source === "rate-limited") {
836
+ this.triageTroubleStreak += 1;
837
+ const backoff = Math.min(TRIAGE_BACKOFF_MAX_MS, 30_000 * 2 ** (this.triageTroubleStreak - 1));
838
+ this.triageBackoffUntil = Date.now() + backoff;
839
+ console.warn(`[${this.agent.id}/${this.adapter.id}] triage rate-limited; backing off ${Math.round(backoff / 1000)}s without acking unread`);
840
+ await runtimePost(this.cfg.serverUrl, "/status", token, { status: "avail" }, this.cfg.tenantId);
841
+ break;
842
+ }
843
+ if (triage?.source !== "fail-open") {
844
+ this.triageTroubleStreak = 0;
845
+ this.triageBackoffUntil = 0;
846
+ }
847
+ if (triage?.actionable === false) {
848
+ await this.ackSeen(token, seen);
849
+ await runtimePost(this.cfg.serverUrl, "/status", token, { status: "avail" }, this.cfg.tenantId);
850
+ await this.maybeAgendaTurn(token);
851
+ continue;
852
+ }
853
+ await runtimePost(this.cfg.serverUrl, "/status", token, { status: "thinking" }, this.cfg.tenantId);
854
+ let typingTimer = null;
855
+ const typingConvo = activeConversationId;
856
+ if (typingConvo) {
857
+ const ping = () => {
858
+ void runtimePost(this.cfg.serverUrl, "/typing", token, { conversationId: typingConvo, done: false }, this.cfg.tenantId);
859
+ void runtimePost(this.cfg.serverUrl, "/thinking/mark", token, { conversationIds: [typingConvo], ttlSec: 60 }, this.cfg.tenantId);
860
+ };
861
+ ping();
862
+ typingTimer = setInterval(ping, 6000);
863
+ }
864
+ const run = await runtimePost(this.cfg.serverUrl, "/runs", token, {
865
+ trigger: { source: reason, engine: this.adapter.id }
866
+ }, this.cfg.tenantId);
867
+ await this.publishEvent("turn.started", { reason, runId: run?.runId, conversationId: activeConversationId });
868
+ const stopBeat = this.beatRun(token, run?.runId);
869
+ let error;
870
+ let exitCode = 0;
871
+ let turnModel;
872
+ let turnUsage;
873
+ const runStartedAt = Date.now();
874
+ const jitter = Math.floor(Math.random() * BIG_BRAIN_SPAWN_JITTER_MS);
875
+ if (jitter > 0)
876
+ await new Promise((resolve) => setTimeout(resolve, jitter));
877
+ await bigBrainSem.acquire();
878
+ try {
879
+ const [memory, roster, preamble] = await Promise.all([
880
+ this.memoryDigest(),
881
+ this.rosterDigest(token),
882
+ this.runtimePreamble(token, reason, run?.runId)
883
+ ]);
884
+ const prompt = this.promptForTurn(digest, memory, roster, preamble, triage);
885
+ const controller = new AbortController();
886
+ const resumeSessionId = this.sessionId;
887
+ const session = this.ensureEngineSession();
888
+ const result = session
889
+ ? await session.send(prompt, { imagePaths })
890
+ : await this.adapter.run({
891
+ home: this.home,
892
+ prompt,
893
+ env: this.engineEnv(),
894
+ model: this.agent.model,
895
+ fastModel: this.agent.fastModel,
896
+ resumeSessionId: this.sessionId,
897
+ standingPrompt: this.standingPrompt(),
898
+ imagePaths,
899
+ signal: controller.signal,
900
+ onLog: (line) => this.logEngineLine(line)
901
+ });
902
+ exitCode = result.exitCode;
903
+ turnModel = result.model;
904
+ turnUsage = result.usage;
905
+ if (result.error)
906
+ this.remediation = engineRemediationAdvice(this.adapter.id, result.error);
907
+ else
908
+ this.remediation = null;
909
+ this.notifyStateChange();
910
+ error = result.error ? visibleEngineError(this.adapter.id, this.home, exitCode, result.error) : undefined;
911
+ if (result.sessionId)
912
+ await this.setSessionId(result.sessionId);
913
+ if (session && !session.alive)
914
+ this.engineSession = null;
915
+ if (error && mustResetSession(error, !!resumeSessionId))
916
+ await this.resetEngineSession(sessionResetReason(error));
917
+ }
918
+ finally {
919
+ bigBrainSem.release();
920
+ stopBeat();
921
+ if (typingTimer)
922
+ clearInterval(typingTimer);
923
+ if (typingConvo) {
924
+ await runtimePost(this.cfg.serverUrl, "/typing", token, { conversationId: typingConvo, done: true }, this.cfg.tenantId);
925
+ await runtimePost(this.cfg.serverUrl, "/thinking/unmark", token, { conversationIds: [typingConvo] }, this.cfg.tenantId);
926
+ }
927
+ }
928
+ if (error) {
929
+ if (isRateLimited(error))
930
+ this.engineBackoffUntil = Date.now() + ENGINE_BACKOFF_MS;
931
+ if (!isRateLimited(error)) {
932
+ await this.publishEngineFailure({ token, runId: run?.runId, conversationId: typingConvo, error, exitCode });
933
+ }
934
+ }
935
+ else {
936
+ await this.ackSeen(token, seen);
937
+ }
938
+ await runtimePost(this.cfg.serverUrl, `/runs/${run?.runId}/finish`, token, {
939
+ status: error ? "failed" : "completed",
940
+ summary: error ?? `local ${this.adapter.id} completed`,
941
+ error,
942
+ exitCode,
943
+ model: turnModel ?? this.agent.model,
944
+ usage: turnUsage ?? null
945
+ }, this.cfg.tenantId);
946
+ const durationMs = Date.now() - runStartedAt;
947
+ this.runStats = recordAgentRunStats(this.runStats, {
948
+ status: error ? "failed" : "completed",
949
+ usage: turnUsage && typeof turnUsage === "object" ? turnUsage : null,
950
+ durationMs,
951
+ model: turnModel ?? this.agent.model ?? null
952
+ });
953
+ this.notifyStateChange();
954
+ await this.publishBudgetEvent(token, run?.runId);
955
+ await this.publishEvent(error ? "turn.failed" : "turn.completed", {
956
+ reason,
957
+ runId: run?.runId,
958
+ conversationId: typingConvo,
959
+ exitCode,
960
+ model: turnModel ?? this.agent.model ?? null,
961
+ usage: turnUsage ?? null,
962
+ durationMs
963
+ }, error ? "error" : "info");
964
+ await runtimePost(this.cfg.serverUrl, "/status", token, { status: "avail" }, this.cfg.tenantId);
965
+ this.lastTurnEndedAt = Date.now();
966
+ } while (this.pendingRerun && !this.stopped);
967
+ }
968
+ finally {
969
+ this.busy = false;
970
+ }
971
+ }
972
+ async maybeAgendaTurn(token) {
973
+ const now = Date.now();
974
+ if (now - this.lastTurnEndedAt < AGENDA_QUIET_MS)
975
+ return;
976
+ if (now - this.lastAgendaCheckAt < AGENDA_CHECK_MS)
977
+ return;
978
+ this.lastAgendaCheckAt = now;
979
+ if (Date.now() < this.engineBackoffUntil)
980
+ return;
981
+ const agenda = await runtimeGet(this.cfg.serverUrl, "/agenda", token, this.cfg.tenantId);
982
+ if (!agenda?.actionable || !agenda.brief)
983
+ return;
984
+ console.log(`[${this.agent.id}/${this.adapter.id}] agenda turn START${agenda.focus ? ` ${agenda.focus}` : ""}`);
985
+ await this.publishEvent("agenda.started", { focus: agenda.focus ?? null });
986
+ await runtimePost(this.cfg.serverUrl, "/status", token, { status: "thinking" }, this.cfg.tenantId);
987
+ const run = await runtimePost(this.cfg.serverUrl, "/runs", token, {
988
+ trigger: { source: "agenda", engine: this.adapter.id }
989
+ }, this.cfg.tenantId);
990
+ const stopBeat = this.beatRun(token, run?.runId);
991
+ let error;
992
+ let exitCode = 0;
993
+ let turnModel;
994
+ let turnUsage;
995
+ const runStartedAt = Date.now();
996
+ const jitter = Math.floor(Math.random() * BIG_BRAIN_SPAWN_JITTER_MS);
997
+ if (jitter > 0)
998
+ await new Promise((resolve) => setTimeout(resolve, jitter));
999
+ await bigBrainSem.acquire();
1000
+ try {
1001
+ const [memory, roster, preamble] = await Promise.all([
1002
+ this.memoryDigest(),
1003
+ this.rosterDigest(token),
1004
+ this.runtimePreamble(token, "agenda", run?.runId)
1005
+ ]);
1006
+ const prompt = this.promptForAgenda(agenda.brief, memory, roster, preamble);
1007
+ const controller = new AbortController();
1008
+ const resumeSessionId = this.sessionId;
1009
+ const session = this.ensureEngineSession();
1010
+ const result = session
1011
+ ? await session.send(prompt)
1012
+ : await this.adapter.run({
1013
+ home: this.home,
1014
+ prompt,
1015
+ env: this.engineEnv(),
1016
+ model: this.agent.model,
1017
+ fastModel: this.agent.fastModel,
1018
+ resumeSessionId: this.sessionId,
1019
+ standingPrompt: this.standingPrompt(),
1020
+ signal: controller.signal,
1021
+ onLog: (line) => this.logEngineLine(line)
1022
+ });
1023
+ exitCode = result.exitCode;
1024
+ turnModel = result.model;
1025
+ turnUsage = result.usage;
1026
+ if (result.error)
1027
+ this.remediation = engineRemediationAdvice(this.adapter.id, result.error);
1028
+ else
1029
+ this.remediation = null;
1030
+ this.notifyStateChange();
1031
+ error = result.error ? visibleEngineError(this.adapter.id, this.home, exitCode, result.error) : undefined;
1032
+ if (result.sessionId)
1033
+ await this.setSessionId(result.sessionId);
1034
+ if (session && !session.alive)
1035
+ this.engineSession = null;
1036
+ if (error && mustResetSession(error, !!resumeSessionId))
1037
+ await this.resetEngineSession(sessionResetReason(error));
1038
+ }
1039
+ finally {
1040
+ bigBrainSem.release();
1041
+ stopBeat();
1042
+ }
1043
+ if (error) {
1044
+ if (isRateLimited(error))
1045
+ this.engineBackoffUntil = Date.now() + ENGINE_BACKOFF_MS;
1046
+ if (!isRateLimited(error)) {
1047
+ await runtimePost(this.cfg.serverUrl, "/events", token, {
1048
+ runId: run?.runId,
1049
+ kind: "agenda.failed",
1050
+ level: "error",
1051
+ title: `${this.adapter.id} agenda failed`,
1052
+ data: { error }
1053
+ }, this.cfg.tenantId);
1054
+ }
1055
+ }
1056
+ await runtimePost(this.cfg.serverUrl, `/runs/${run?.runId}/finish`, token, {
1057
+ status: error ? "failed" : "completed",
1058
+ summary: error ?? `agenda ${this.adapter.id} completed`,
1059
+ error,
1060
+ exitCode,
1061
+ model: turnModel ?? this.agent.model,
1062
+ usage: turnUsage ?? null
1063
+ }, this.cfg.tenantId);
1064
+ const durationMs = Date.now() - runStartedAt;
1065
+ this.runStats = recordAgentRunStats(this.runStats, {
1066
+ status: error ? "failed" : "completed",
1067
+ usage: turnUsage && typeof turnUsage === "object" ? turnUsage : null,
1068
+ durationMs,
1069
+ model: turnModel ?? this.agent.model ?? null
1070
+ });
1071
+ this.notifyStateChange();
1072
+ await this.publishBudgetEvent(token, run?.runId);
1073
+ await this.publishEvent(error ? "agenda.failed" : "agenda.completed", {
1074
+ runId: run?.runId,
1075
+ focus: agenda.focus ?? null,
1076
+ exitCode,
1077
+ model: turnModel ?? this.agent.model ?? null,
1078
+ usage: turnUsage ?? null,
1079
+ durationMs
1080
+ }, error ? "error" : "info");
1081
+ await runtimePost(this.cfg.serverUrl, "/status", token, { status: "avail" }, this.cfg.tenantId);
1082
+ this.lastTurnEndedAt = Date.now();
1083
+ }
1084
+ async streamLoop() {
1085
+ let backoff = 1000;
1086
+ while (!this.stopped) {
1087
+ try {
1088
+ const token = await this.ensureToken();
1089
+ this.wakeStreamController = replaceWakeStreamController(this.wakeStreamController);
1090
+ const res = await fetch(`${this.cfg.serverUrl}/runtime/wake-stream`, {
1091
+ headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", ...tenantHeader(this.cfg.tenantId) },
1092
+ signal: this.wakeStreamController.signal
1093
+ });
1094
+ if (isWakeStreamAuthFailure(res.status)) {
1095
+ const message = `wake-stream HTTP ${res.status}`;
1096
+ this.token = "";
1097
+ this.tokenExpiresAt = 0;
1098
+ console.error(`[${this.agent.id}/${this.adapter.id}] ${message}; authentication failed, stopping wake stream`);
1099
+ await this.publishEvent("wake.stream.auth_failed", { status: res.status, error: message }, "error");
1100
+ break;
1101
+ }
1102
+ if (!res.ok || !res.body)
1103
+ throw new Error(`wake-stream HTTP ${res.status}`);
1104
+ console.log(`[${this.agent.id}/${this.adapter.id}] wake-stream connected`);
1105
+ await this.publishEvent("wake.stream.connected", { backoffMs: backoff });
1106
+ backoff = 1000;
1107
+ this.scheduleWake("reconnect-catchup");
1108
+ for await (const evt of parseSseStream(res.body)) {
1109
+ if (this.stopped)
1110
+ break;
1111
+ if (evt.event !== "wake" && evt.event !== "steer")
1112
+ continue;
1113
+ const info = parseWakeEventInfo(evt.data);
1114
+ if (!shouldHandleWakeEvent(info, this.agent.id))
1115
+ continue;
1116
+ const conversationId = info.conversationId;
1117
+ if (evt.event === "steer") {
1118
+ if (conversationId)
1119
+ void this.maybeSteer(conversationId);
1120
+ else {
1121
+ const steerText = evt.data ? `New runtime steering event: ${evt.data}` : "New runtime steering event.";
1122
+ this.steerRunningTurn(steerText);
1123
+ }
1124
+ }
1125
+ console.log(`[${this.agent.id}/${this.adapter.id}] SSE ${evt.event} received${conversationId ? ` conversation=${conversationId}` : ""}${info.deliveryLatencyMs == null ? "" : ` deliveryLatency=${info.deliveryLatencyMs}ms`}`);
1126
+ await this.publishEvent("wake.received", {
1127
+ event: evt.event,
1128
+ conversationId,
1129
+ sentAt: info.sentAt,
1130
+ deliveryLatencyMs: info.deliveryLatencyMs
1131
+ });
1132
+ this.scheduleWake(`sse-${evt.event}`, conversationId);
1133
+ }
1134
+ }
1135
+ catch (err) {
1136
+ if (this.stopped)
1137
+ break;
1138
+ const message = err instanceof Error ? err.message : String(err);
1139
+ console.warn(`[${this.agent.id}/${this.adapter.id}] wake-stream error: ${message}; retry in ${backoff}ms`);
1140
+ await this.publishEvent("wake.stream.error", { error: message, retryInMs: backoff }, "warn");
1141
+ await new Promise((resolve) => setTimeout(resolve, backoff));
1142
+ backoff = Math.min(backoff * 2, 30_000);
1143
+ }
1144
+ finally {
1145
+ abortWakeStream(this.wakeStreamController);
1146
+ this.wakeStreamController = null;
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+ export function abortWakeStream(controller) {
1152
+ if (controller && !controller.signal.aborted)
1153
+ controller.abort();
1154
+ }
1155
+ export function replaceWakeStreamController(controller) {
1156
+ abortWakeStream(controller);
1157
+ return new AbortController();
1158
+ }
1159
+ export function isWakeStreamAuthFailure(status) {
1160
+ return status === 401 || status === 403;
1161
+ }