@indigoai-us/hq-cli 5.109.16 → 5.110.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.
- package/CHANGELOG.md +28 -0
- package/assets/bot-workers/setup/context/USER-GUIDE.md +363 -0
- package/assets/bot-workers/setup/context/quick-reference.md +199 -0
- package/assets/bot-workers/setup/skills/first-company.md +71 -0
- package/assets/bot-workers/setup/skills/standing-help.md +74 -0
- package/assets/bot-workers/setup/worker.yaml +422 -0
- package/dist/commands/bot-continuity.d.ts +28 -0
- package/dist/commands/bot-continuity.js +68 -0
- package/dist/commands/bot.d.ts +73 -0
- package/dist/commands/bot.js +776 -0
- package/dist/commands/workers.d.ts +2 -14
- package/dist/commands/workers.js +2 -8
- package/dist/lib/bot/api.d.ts +202 -0
- package/dist/lib/bot/api.js +202 -0
- package/dist/lib/bot/company-bind.d.ts +27 -0
- package/dist/lib/bot/company-bind.js +62 -0
- package/dist/lib/bot/config.d.ts +106 -0
- package/dist/lib/bot/config.js +141 -0
- package/dist/lib/bot/continuity-download.d.ts +28 -0
- package/dist/lib/bot/continuity-download.js +75 -0
- package/dist/lib/bot/continuity-install.d.ts +14 -0
- package/dist/lib/bot/continuity-install.js +101 -0
- package/dist/lib/bot/continuity.d.ts +66 -0
- package/dist/lib/bot/continuity.js +301 -0
- package/dist/lib/bot/creds.d.ts +24 -0
- package/dist/lib/bot/creds.js +51 -0
- package/dist/lib/bot/daemon.d.ts +75 -0
- package/dist/lib/bot/daemon.js +316 -0
- package/dist/lib/bot/inbox-state.d.ts +18 -0
- package/dist/lib/bot/inbox-state.js +51 -0
- package/dist/lib/bot/index.d.ts +16 -0
- package/dist/lib/bot/index.js +16 -0
- package/dist/lib/bot/inflight.d.ts +40 -0
- package/dist/lib/bot/inflight.js +44 -0
- package/dist/lib/bot/log.d.ts +13 -0
- package/dist/lib/bot/log.js +59 -0
- package/dist/lib/bot/owner-context.d.ts +75 -0
- package/dist/lib/bot/owner-context.js +151 -0
- package/dist/lib/bot/paths.d.ts +61 -0
- package/dist/lib/bot/paths.js +103 -0
- package/dist/lib/bot/progress.d.ts +84 -0
- package/dist/lib/bot/progress.js +167 -0
- package/dist/lib/bot/promote.d.ts +16 -0
- package/dist/lib/bot/promote.js +106 -0
- package/dist/lib/bot/promotion-hold.d.ts +24 -0
- package/dist/lib/bot/promotion-hold.js +103 -0
- package/dist/lib/bot/promotion-receipt.d.ts +9 -0
- package/dist/lib/bot/promotion-receipt.js +56 -0
- package/dist/lib/bot/promotion-upload.d.ts +16 -0
- package/dist/lib/bot/promotion-upload.js +65 -0
- package/dist/lib/bot/prompt.d.ts +103 -0
- package/dist/lib/bot/prompt.js +329 -0
- package/dist/lib/bot/room-policy.d.ts +53 -0
- package/dist/lib/bot/room-policy.js +73 -0
- package/dist/lib/bot/run.d.ts +98 -0
- package/dist/lib/bot/run.js +787 -0
- package/dist/lib/bot/runtime/claude.d.ts +49 -0
- package/dist/lib/bot/runtime/claude.js +151 -0
- package/dist/lib/bot/runtime/codex.d.ts +28 -0
- package/dist/lib/bot/runtime/codex.js +147 -0
- package/dist/lib/bot/runtime/grok.d.ts +16 -0
- package/dist/lib/bot/runtime/grok.js +67 -0
- package/dist/lib/bot/runtime/index.d.ts +35 -0
- package/dist/lib/bot/runtime/index.js +279 -0
- package/dist/lib/bot/runtime/messages-stream.d.ts +27 -0
- package/dist/lib/bot/runtime/messages-stream.js +85 -0
- package/dist/lib/bot/runtime/types.d.ts +136 -0
- package/dist/lib/bot/runtime/types.js +51 -0
- package/dist/lib/bot/scaffold.d.ts +38 -0
- package/dist/lib/bot/scaffold.js +94 -0
- package/dist/lib/bot/session.d.ts +19 -0
- package/dist/lib/bot/session.js +39 -0
- package/dist/lib/bot/status.d.ts +40 -0
- package/dist/lib/bot/status.js +66 -0
- package/dist/lib/bot/worker-source.d.ts +66 -0
- package/dist/lib/bot/worker-source.js +283 -0
- package/dist/lib/workers-registry/read.d.ts +15 -0
- package/dist/lib/workers-registry/read.js +17 -0
- package/dist/register-all.js +2 -0
- package/package.json +2 -1
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resident bot process: `hq bot run <name>` (local-bots US-003/US-004).
|
|
3
|
+
*
|
|
4
|
+
* - pid lock in ~/.hq/bots/<name>/pid; status.json is the observable state
|
|
5
|
+
* - refuses to start unless the creds file names this bot's agt_ AND the
|
|
6
|
+
* server-side record is owned by the configured owner
|
|
7
|
+
* - heartbeat every 30s (POST /v1/agents/{uid}/heartbeat, agent JWT)
|
|
8
|
+
* - inbox poll every 5s: owner DMs go to ONE headless model turn (resumed by
|
|
9
|
+
* session id); the reply is sent as the bot; the message is acked only after
|
|
10
|
+
* the reply is sent; processed ids persist so a crash between reply and ack
|
|
11
|
+
* never answers twice
|
|
12
|
+
* - non-owner DM senders get a fixed refusal and never reach the model
|
|
13
|
+
* - first start only: the intro DM, then (when bot.json has `kickoff`) ONE
|
|
14
|
+
* model turn on that prompt as if the owner sent it, answered in the DM, so
|
|
15
|
+
* a guided bot starts working without waiting for the owner to type
|
|
16
|
+
* - room items (channels / group chats): answered when the bot is @mentioned,
|
|
17
|
+
* or when the owner posts in a small group (see room-policy.ts); claimed
|
|
18
|
+
* first (first-claim-wins), one session per room, reply posted in the room
|
|
19
|
+
* - while a turn runs, each finished assistant message is posted as its own
|
|
20
|
+
* message (progress.ts); turns have no time limit
|
|
21
|
+
* - every received message gets a reply: a turn that fails after the model
|
|
22
|
+
* started (error, crash, owner stop, restart) posts one "did not finish"
|
|
23
|
+
* message and is NOT run again — it may already have done outward things.
|
|
24
|
+
* inflight.json marks the turn in progress so a restart can say so.
|
|
25
|
+
* - failures before the model starts (missing binary) retry with backoff;
|
|
26
|
+
* 5 model failures in 10 minutes → state `failed` and a clean exit (launchd
|
|
27
|
+
* does not respawn a clean exit). Owner stops never count.
|
|
28
|
+
*
|
|
29
|
+
* Everything with a side effect is injectable so the loop is unit-testable.
|
|
30
|
+
*/
|
|
31
|
+
import { ownerContextBlock } from "./owner-context.js";
|
|
32
|
+
import { randomUUID } from "node:crypto";
|
|
33
|
+
import { hasPromotionHold } from "./promotion-hold.js";
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
import { fullJitterDelayMs } from "../mesh/live/backoff.js";
|
|
37
|
+
import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
38
|
+
import { patchBotConfig, readBotConfig } from "./config.js";
|
|
39
|
+
import { ensureBotSessionMeta } from "./company-bind.js";
|
|
40
|
+
import { readBotCredsIdentity } from "./creds.js";
|
|
41
|
+
import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
|
|
42
|
+
import { clearInflight, patchInflight, readInflight, writeInflight } from "./inflight.js";
|
|
43
|
+
import { ProgressPoster } from "./progress.js";
|
|
44
|
+
import { createBotLogger } from "./log.js";
|
|
45
|
+
import { buildSystemPrompt, introDmText, nonOwnerRefusalText, readPromptSources } from "./prompt.js";
|
|
46
|
+
import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
|
|
47
|
+
import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
|
|
48
|
+
import { isDmItem, resolveReplyTarget } from "./room-policy.js";
|
|
49
|
+
import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
|
|
50
|
+
import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
|
|
51
|
+
export const INBOX_POLL_INTERVAL_MS = 2_000;
|
|
52
|
+
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
53
|
+
export const FAILURE_WINDOW_MS = 10 * 60 * 1000;
|
|
54
|
+
export const FAILURE_LIMIT = 5;
|
|
55
|
+
export const MAX_ATTEMPTS_PER_MESSAGE = 3;
|
|
56
|
+
export const THINKING_EMOJI = "👀";
|
|
57
|
+
export const ROOM_THINKING_STATUS = "is thinking…";
|
|
58
|
+
export const RECENT_MESSAGES_LIMIT = 15;
|
|
59
|
+
export const RECENT_MESSAGE_MAX_CHARS = 400;
|
|
60
|
+
export const KICKOFF_MESSAGE_ID = "kickoff";
|
|
61
|
+
/** The core worker HQ's setup bot runs. */
|
|
62
|
+
export const SETUP_WORKER_ID = "setup";
|
|
63
|
+
/** The one message a person gets when a turn on their message did not finish. */
|
|
64
|
+
export function didNotFinishText(reason, progressPosted) {
|
|
65
|
+
const r = reason.trim().replace(/[.\s]+$/, "");
|
|
66
|
+
return `I didn't finish that — ${r}.${progressPosted ? " The messages above show how far I got." : ""}`;
|
|
67
|
+
}
|
|
68
|
+
/** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
|
|
69
|
+
export const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
|
|
70
|
+
export const RESTARTED_REASON = "I was restarted while I was working on it";
|
|
71
|
+
class FailureWindow {
|
|
72
|
+
windowMs;
|
|
73
|
+
limit;
|
|
74
|
+
stamps = [];
|
|
75
|
+
constructor(windowMs, limit) {
|
|
76
|
+
this.windowMs = windowMs;
|
|
77
|
+
this.limit = limit;
|
|
78
|
+
}
|
|
79
|
+
record(nowMs) {
|
|
80
|
+
this.stamps.push(nowMs);
|
|
81
|
+
while (this.stamps.length && nowMs - this.stamps[0] > this.windowMs)
|
|
82
|
+
this.stamps.shift();
|
|
83
|
+
return this.stamps.length;
|
|
84
|
+
}
|
|
85
|
+
count(nowMs) {
|
|
86
|
+
while (this.stamps.length && nowMs - this.stamps[0] > this.windowMs)
|
|
87
|
+
this.stamps.shift();
|
|
88
|
+
return this.stamps.length;
|
|
89
|
+
}
|
|
90
|
+
exceeded(nowMs) {
|
|
91
|
+
return this.count(nowMs) >= this.limit;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function defaultSleep(ms) {
|
|
95
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
|
+
}
|
|
97
|
+
export function preflightCreds(dir, config) {
|
|
98
|
+
const identity = readBotCredsIdentity(dir);
|
|
99
|
+
if (!identity)
|
|
100
|
+
return `No machine credentials for bot "${config.name}" — run: hq bot create ${config.name}`;
|
|
101
|
+
if (identity.entityUid !== config.agentUid) {
|
|
102
|
+
return `Credentials in ${dir} belong to ${identity.entityUid}, not this bot (${config.agentUid}); refusing to start`;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
export async function runBot(deps) {
|
|
107
|
+
const now = deps.now ?? (() => new Date());
|
|
108
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
109
|
+
const log = deps.log ?? createBotLogger(deps.dir, now);
|
|
110
|
+
const pid = deps.pid ?? process.pid;
|
|
111
|
+
const { dir, config, api, runtime } = deps;
|
|
112
|
+
const runTurn = deps.runTurn ?? ((rt, input) => runRuntimeTurn(rt, input));
|
|
113
|
+
const inboxIntervalMs = deps.inboxIntervalMs ?? INBOX_POLL_INTERVAL_MS;
|
|
114
|
+
const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
115
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
116
|
+
const fallback = () => defaultBotStatus(pid, config.runtime, now);
|
|
117
|
+
const setStatus = (patch) => patchBotStatus(dir, patch, fallback, now);
|
|
118
|
+
let state = "starting";
|
|
119
|
+
let stopping = false;
|
|
120
|
+
/** Aborts the model turn in progress (owner stop). */
|
|
121
|
+
let currentTurn = null;
|
|
122
|
+
let resolveDone;
|
|
123
|
+
const done = new Promise((r) => (resolveDone = r));
|
|
124
|
+
// Only override isProcessAlive when a caller injected one: spreading an
|
|
125
|
+
// explicit `undefined` over defaultPidLockDeps() would erase the default and
|
|
126
|
+
// crash the daemon at startup ("deps.isProcessAlive is not a function").
|
|
127
|
+
// Model turns run in the owner's HQ root so every skill, policy and `hq`
|
|
128
|
+
// command resolves the way it does for the owner. (A scratch cwd was tried
|
|
129
|
+
// on 2026-09-11 for speed; it cost the bot its skills and memory writes.)
|
|
130
|
+
const turnCwd = config.hqRoot;
|
|
131
|
+
// HQ's setup bot is a guided conversation: no "working on it" filler between steps.
|
|
132
|
+
const isSetupBot = config.workerId === SETUP_WORKER_ID;
|
|
133
|
+
const lockDeps = defaultPidLockDeps({
|
|
134
|
+
pid,
|
|
135
|
+
...(deps.isProcessAlive ? { isProcessAlive: deps.isProcessAlive } : {}),
|
|
136
|
+
});
|
|
137
|
+
const finish = async (final, code, reason) => {
|
|
138
|
+
if (state === "stopped" || state === "failed")
|
|
139
|
+
return;
|
|
140
|
+
state = final;
|
|
141
|
+
setStatus({ state: final, ...(final === "failed" ? { lastError: reason.slice(0, 400) } : {}) });
|
|
142
|
+
log(final === "failed" ? "error" : "info", `bot ${final}: ${reason}`);
|
|
143
|
+
if (!deps.skipPidLock)
|
|
144
|
+
releasePidLock(dir, lockDeps);
|
|
145
|
+
resolveDone(final);
|
|
146
|
+
exit(code);
|
|
147
|
+
};
|
|
148
|
+
// ── Preflight: creds, pid lock, owner verification ─────────────────────────
|
|
149
|
+
const credsProblem = preflightCreds(dir, config);
|
|
150
|
+
if (credsProblem) {
|
|
151
|
+
setStatus({ state: "failed", lastError: credsProblem, pid });
|
|
152
|
+
log("error", credsProblem);
|
|
153
|
+
resolveDone("failed");
|
|
154
|
+
exit(0);
|
|
155
|
+
return { stop: async () => { }, done, status: () => null };
|
|
156
|
+
}
|
|
157
|
+
if (config.enabled === false) {
|
|
158
|
+
setStatus({ state: "stopped", pid });
|
|
159
|
+
log("info", "bot is disabled (hq bot start to enable)");
|
|
160
|
+
resolveDone("stopped");
|
|
161
|
+
exit(0);
|
|
162
|
+
return { stop: async () => { }, done, status: () => null };
|
|
163
|
+
}
|
|
164
|
+
if (!deps.skipPidLock) {
|
|
165
|
+
const lock = acquirePidLock(dir, lockDeps);
|
|
166
|
+
if (!lock.ok) {
|
|
167
|
+
const msg = `bot "${config.name}" is already running (pid ${lock.owner.pid})`;
|
|
168
|
+
log("warn", msg);
|
|
169
|
+
resolveDone("stopped");
|
|
170
|
+
exit(0);
|
|
171
|
+
return { stop: async () => { }, done, status: () => null };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (hasPromotionHold(dir)) {
|
|
175
|
+
await finish("stopped", 0, "local bot held for cloud promotion");
|
|
176
|
+
return { stop: async () => { }, done, status: () => null };
|
|
177
|
+
}
|
|
178
|
+
setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false });
|
|
179
|
+
log("info", `starting ${config.name} (${config.runtime}) as ${config.agentUid} for ${config.ownerUid}`);
|
|
180
|
+
try {
|
|
181
|
+
const record = await api.getAgent(config.agentUid);
|
|
182
|
+
// Promotion keeps the identity and owner. Ownership alone must not let
|
|
183
|
+
// an old local installation start consuming the cloud bot's inbox.
|
|
184
|
+
if (record.uid !== config.agentUid || record.computeMode !== "local" || record.botKind !== "personal") {
|
|
185
|
+
await finish("failed", 0, `identity ${config.agentUid} is not a personal local bot; this installation cannot run it`);
|
|
186
|
+
return { stop: async () => { }, done, status: () => null };
|
|
187
|
+
}
|
|
188
|
+
const owner = record.ownerUid ?? null;
|
|
189
|
+
if (owner !== config.ownerUid) {
|
|
190
|
+
await finish("failed", 0, `identity ${config.agentUid} is owned by ${owner ?? "nobody"}, not ${config.ownerUid}`);
|
|
191
|
+
return { stop: async () => { }, done, status: () => null };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
await finish("failed", 0, `could not verify bot identity: ${err instanceof Error ? err.message : String(err)}`);
|
|
196
|
+
return { stop: async () => { }, done, status: () => null };
|
|
197
|
+
}
|
|
198
|
+
// ── System prompt ──────────────────────────────────────────────────────────
|
|
199
|
+
const systemPrompt = deps.systemPromptOverride ??
|
|
200
|
+
buildSystemPrompt({
|
|
201
|
+
botName: config.name,
|
|
202
|
+
agentUid: config.agentUid,
|
|
203
|
+
ownerUid: config.ownerUid,
|
|
204
|
+
hqRoot: config.hqRoot,
|
|
205
|
+
workerDir: config.workerDir,
|
|
206
|
+
workerSource: config.workerSource,
|
|
207
|
+
workerId: config.workerId,
|
|
208
|
+
companySlug: config.companySlug,
|
|
209
|
+
memoryDir: config.memoryDir,
|
|
210
|
+
sources: readPromptSources(config.hqRoot, config.workerDir, undefined, {
|
|
211
|
+
memoryDir: config.memoryDir,
|
|
212
|
+
workerSource: config.workerSource,
|
|
213
|
+
companySlug: config.companySlug,
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
const systemPromptFile = path.join(dir, "system-prompt.md");
|
|
217
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
218
|
+
fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
|
|
219
|
+
if (hasPromotionHold(dir)) {
|
|
220
|
+
await finish("stopped", 0, "local bot held for cloud promotion");
|
|
221
|
+
return { stop: async () => { }, done, status: () => null };
|
|
222
|
+
}
|
|
223
|
+
state = "running";
|
|
224
|
+
setStatus({ state: "running", lastActivityAt: now().toISOString() });
|
|
225
|
+
log("info", "running");
|
|
226
|
+
// Read before the kickoff can write its own marker: whatever is here now was
|
|
227
|
+
// left by a previous run that was cut off mid-turn.
|
|
228
|
+
const leftover = readInflight(dir);
|
|
229
|
+
// ── Intro DM (once) ────────────────────────────────────────────────────────
|
|
230
|
+
// The kickoff turn is tied to the intro: it runs only in the start that sent
|
|
231
|
+
// the intro, so introSentAt (persisted before the turn) guarantees it never
|
|
232
|
+
// runs again on a restart.
|
|
233
|
+
let kickoffAfterIntro = null;
|
|
234
|
+
if (!config.introSentAt) {
|
|
235
|
+
try {
|
|
236
|
+
const sent = await api.sendDm({ toPersonUid: config.ownerUid, body: introDmText(config.name, config.runtime, config.intro) });
|
|
237
|
+
patchBotConfig(dir, { introSentAt: now().toISOString() });
|
|
238
|
+
log("info", "intro DM sent to owner");
|
|
239
|
+
if (config.kickoff?.trim())
|
|
240
|
+
kickoffAfterIntro = { ...(sent?.eventId ? { introEventId: sent.eventId } : {}) };
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
log("warn", `intro DM failed (will retry next start): ${err instanceof Error ? err.message : String(err)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ── Heartbeat loop ─────────────────────────────────────────────────────────
|
|
247
|
+
const failures = new FailureWindow(FAILURE_WINDOW_MS, FAILURE_LIMIT);
|
|
248
|
+
let modelHealth = "ok";
|
|
249
|
+
const heartbeatLoop = (async () => {
|
|
250
|
+
while (!stopping && state === "running") {
|
|
251
|
+
try {
|
|
252
|
+
// The server's component vocabulary is the fleet one (sync/model/task…);
|
|
253
|
+
// runtime → model, inbox → task, sync → sync.
|
|
254
|
+
await api.heartbeat(config.agentUid, { model: modelHealth, task: "ok", sync: "ok" });
|
|
255
|
+
setStatus({ lastHeartbeatAt: now().toISOString(), lastHeartbeatOk: true });
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
setStatus({ lastHeartbeatAt: now().toISOString(), lastHeartbeatOk: false });
|
|
259
|
+
log("warn", `heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
260
|
+
}
|
|
261
|
+
await sleep(heartbeatIntervalMs);
|
|
262
|
+
}
|
|
263
|
+
})();
|
|
264
|
+
// ── Inbox loop ─────────────────────────────────────────────────────────────
|
|
265
|
+
const attempts = new Map();
|
|
266
|
+
/** Build the user turn for a room trigger: header + recent transcript + the message. */
|
|
267
|
+
const buildRoomTurn = async (item, target, text) => {
|
|
268
|
+
const name = (item.channelName ?? "").trim() || target.channelId;
|
|
269
|
+
const members = typeof item.memberCount === "number" ? `${item.memberCount} members` : "group";
|
|
270
|
+
const from = (item.fromDisplayName ?? "").trim() || item.fromPersonUid || "unknown";
|
|
271
|
+
const header = `[#${name} · ${members} · from ${from}]`;
|
|
272
|
+
let recent = "";
|
|
273
|
+
try {
|
|
274
|
+
const rows = await api.fetchChannelMessages(target.channelId, RECENT_MESSAGES_LIMIT);
|
|
275
|
+
const lines = rows
|
|
276
|
+
.filter((m) => !(item.eventId && m.eventId === item.eventId))
|
|
277
|
+
.map((m) => `- ${m.fromDisplayName ?? m.fromPersonUid ?? "someone"}: ${m.body.replace(/\s+/g, " ").slice(0, RECENT_MESSAGE_MAX_CHARS)}`);
|
|
278
|
+
if (lines.length > 0)
|
|
279
|
+
recent = `Recent messages:\n${lines.join("\n")}\n\n`;
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
log("warn", `recent messages for ${target.channelId} unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
283
|
+
}
|
|
284
|
+
return `${header}\n${recent}${from}: ${text}`;
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* One model turn in the given session scope: company bind, the Claude
|
|
288
|
+
* bypass → auto fallback, and the session file update on success. Throws
|
|
289
|
+
* the runtime error on failure (callers account for it via recordTurnFailure).
|
|
290
|
+
*/
|
|
291
|
+
const executeTurn = async (prompt, sessionScope, tReceived, stream = {}) => {
|
|
292
|
+
const session = readBotSession(dir, config.runtime, sessionScope);
|
|
293
|
+
// Company bind (worker-sourced bots): HQ's company hooks key on
|
|
294
|
+
// workspace/sessions/<sid>/meta.yaml, so the session id is fixed up front
|
|
295
|
+
// and the meta file written before every turn (idempotent; covers session
|
|
296
|
+
// resets). Claude gets the full hook set via its payload session id;
|
|
297
|
+
// Codex/Grok only see HQ_SESSION_ID.
|
|
298
|
+
const sid = session?.sessionId ?? randomUUID();
|
|
299
|
+
let bind = {};
|
|
300
|
+
if (config.companySlug) {
|
|
301
|
+
try {
|
|
302
|
+
ensureBotSessionMeta(config.hqRoot, sid, config.companySlug, undefined, now);
|
|
303
|
+
bind = { newSessionId: sid, env: { ...process.env, HQ_SESSION_ID: sid } };
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
log("warn", `company bind failed for ${config.companySlug}: ${err instanceof Error ? err.message : String(err)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Only the owner's own DM session (DMs and the kickoff). Room prompts must
|
|
310
|
+
// keep their [#channel …] header first, and a room turn can be triggered
|
|
311
|
+
// by other people, who must not be handed the owner's companies.
|
|
312
|
+
if (deps.ownerContext && sessionScope === "dm") {
|
|
313
|
+
try {
|
|
314
|
+
prompt = `${ownerContextBlock(await deps.ownerContext(), config.agentUid)}\n\n${prompt}`;
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
log("warn", `owner context unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const tTurnStart = now().getTime();
|
|
321
|
+
// Model and thinking level are read fresh each turn, so a change from the
|
|
322
|
+
// bot's profile (`hq bot set`) applies to the next message without a restart.
|
|
323
|
+
const live = readBotConfig(dir) ?? config;
|
|
324
|
+
const attemptTurn = (permissionMode) => runTurn(runtime, {
|
|
325
|
+
prompt,
|
|
326
|
+
systemPrompt,
|
|
327
|
+
systemPromptFile,
|
|
328
|
+
cwd: turnCwd,
|
|
329
|
+
hqRoot: config.hqRoot,
|
|
330
|
+
autoApprove: config.autoApprove !== false,
|
|
331
|
+
...(permissionMode ? { permissionMode } : {}),
|
|
332
|
+
sessionId: session?.sessionId,
|
|
333
|
+
...(live.model ? { model: live.model } : {}),
|
|
334
|
+
...(live.effort ? { effort: live.effort } : {}),
|
|
335
|
+
...bind,
|
|
336
|
+
...stream,
|
|
337
|
+
});
|
|
338
|
+
let turn;
|
|
339
|
+
const remembered = readStatus()?.claudePermissionMode;
|
|
340
|
+
try {
|
|
341
|
+
turn = await attemptTurn(remembered);
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
// Claude Code can have bypassPermissions switched off by managed
|
|
345
|
+
// settings. Fall back to "auto" (no prompts, classifier-approved) once
|
|
346
|
+
// and remember it, so the bot keeps working without an operator.
|
|
347
|
+
const detail = err instanceof RuntimeError ? `${err.message}\n${err.stderrExcerpt}` : err instanceof Error ? err.message : String(err);
|
|
348
|
+
const aborted = err instanceof RuntimeError && err.aborted;
|
|
349
|
+
if (aborted || runtime.id !== "claude" || remembered || config.autoApprove === false || !isClaudeBypassDisabled(detail)) {
|
|
350
|
+
throw Object.assign(err instanceof Error ? err : new Error(String(err)), { resumedSession: Boolean(session) });
|
|
351
|
+
}
|
|
352
|
+
log("warn", `bypassPermissions is disabled for this Claude Code; falling back to permission mode "${FALLBACK_CLAUDE_PERMISSION_MODE}"`);
|
|
353
|
+
setStatus({ claudePermissionMode: FALLBACK_CLAUDE_PERMISSION_MODE });
|
|
354
|
+
try {
|
|
355
|
+
turn = await attemptTurn(FALLBACK_CLAUDE_PERMISSION_MODE);
|
|
356
|
+
}
|
|
357
|
+
catch (retryErr) {
|
|
358
|
+
throw Object.assign(retryErr instanceof Error ? retryErr : new Error(String(retryErr)), { resumedSession: Boolean(session) });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
modelHealth = "ok";
|
|
362
|
+
log("info", `model turn done in ${Math.round((now().getTime() - tTurnStart) / 1000)}s (${Math.round((tTurnStart - tReceived) / 1000)}s before it)`);
|
|
363
|
+
if (turn.sessionId) {
|
|
364
|
+
writeBotSession(dir, {
|
|
365
|
+
v: 1,
|
|
366
|
+
runtime: config.runtime,
|
|
367
|
+
sessionId: turn.sessionId,
|
|
368
|
+
turns: (session?.turns ?? 0) + 1,
|
|
369
|
+
updatedAt: now().toISOString(),
|
|
370
|
+
}, sessionScope);
|
|
371
|
+
}
|
|
372
|
+
return turn;
|
|
373
|
+
};
|
|
374
|
+
/** Failure bookkeeping shared by every model turn: window, health, status, log, stale session. */
|
|
375
|
+
const recordTurnFailure = (err, sessionScope) => {
|
|
376
|
+
const excerpt = err instanceof RuntimeError ? err.stderrExcerpt : err instanceof Error ? err.message : String(err);
|
|
377
|
+
const count = failures.record(now().getTime());
|
|
378
|
+
modelHealth = count >= FAILURE_LIMIT ? "failed" : "degraded";
|
|
379
|
+
setStatus({ restarts: (readStatus()?.restarts ?? 0) + 1, recentFailures: count, lastError: excerpt.slice(0, 400) });
|
|
380
|
+
log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window): ${excerpt}`);
|
|
381
|
+
// A resumed session that no longer exists must not poison every turn.
|
|
382
|
+
const resumed = Boolean(err?.resumedSession);
|
|
383
|
+
if (resumed && /session|resume|not found/i.test(excerpt))
|
|
384
|
+
clearBotSession(dir, sessionScope);
|
|
385
|
+
return { excerpt, count };
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* One model turn that posts its in-between messages as it goes. Writes the
|
|
389
|
+
* in-flight marker first; the caller clears it once the message is answered.
|
|
390
|
+
*/
|
|
391
|
+
const runTurnWithProgress = async (args) => {
|
|
392
|
+
const { messageId } = args.marker;
|
|
393
|
+
writeInflight(dir, { v: 1, ...args.marker, startedAt: now().toISOString() });
|
|
394
|
+
let running = true;
|
|
395
|
+
let posted = 0;
|
|
396
|
+
const poster = new ProgressPoster({
|
|
397
|
+
...deps.progress,
|
|
398
|
+
...(args.workingNotice === false ? { workingNoticeMs: 0 } : {}),
|
|
399
|
+
send: async (body) => {
|
|
400
|
+
try {
|
|
401
|
+
await args.send(body);
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
log("warn", `post for ${messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
posted += 1;
|
|
408
|
+
patchInflight(dir, messageId, { progressPosted: posted });
|
|
409
|
+
if (running && args.afterProgressPost)
|
|
410
|
+
await args.afterProgressPost().catch(() => undefined);
|
|
411
|
+
return true;
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
currentTurn = controller;
|
|
416
|
+
poster.start();
|
|
417
|
+
try {
|
|
418
|
+
const turn = await executeTurn(args.prompt, args.sessionScope, args.tReceived, {
|
|
419
|
+
onEvent: (event) => {
|
|
420
|
+
if (event.kind === "message")
|
|
421
|
+
poster.message(event.text);
|
|
422
|
+
},
|
|
423
|
+
signal: controller.signal,
|
|
424
|
+
});
|
|
425
|
+
running = false;
|
|
426
|
+
await poster.finish(turn.text);
|
|
427
|
+
return { outcome: { ok: true, reply: turn.text.trim() || "(no reply)" }, poster };
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
running = false;
|
|
431
|
+
await poster.abandon();
|
|
432
|
+
const excerpt = err instanceof RuntimeError ? err.stderrExcerpt : err instanceof Error ? err.message : String(err);
|
|
433
|
+
const aborted = (err instanceof RuntimeError && err.aborted) || controller.signal.aborted;
|
|
434
|
+
const started = !(err instanceof RuntimeError) || err.started;
|
|
435
|
+
return { outcome: { ok: false, started, aborted, excerpt, error: err }, poster };
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
if (currentTurn === controller)
|
|
439
|
+
currentTurn = null;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const failureReason = (outcome) => outcome.aborted ? STOPPED_BY_OWNER_REASON : `${runtime.id} stopped with an error: ${outcome.excerpt.slice(0, 160)}`;
|
|
443
|
+
const sendToTarget = (target, body, rootEventId) => {
|
|
444
|
+
const root = rootEventId ? { rootEventId } : {};
|
|
445
|
+
return target.kind === "dm"
|
|
446
|
+
? api.sendDm({ toPersonUid: target.peerUid, body, ...root })
|
|
447
|
+
: api.sendChannelMessage({ channelId: target.channelId, body, ...root });
|
|
448
|
+
};
|
|
449
|
+
/**
|
|
450
|
+
* A turn that was cut off by a restart (or whose answer never got posted):
|
|
451
|
+
* tell the person instead of running it again. Returns false when the post
|
|
452
|
+
* failed, so the marker stays for the next try.
|
|
453
|
+
*/
|
|
454
|
+
const recoverInflight = async (marker) => {
|
|
455
|
+
const isKickoff = marker.messageId === KICKOFF_MESSAGE_ID;
|
|
456
|
+
if (!isKickoff && isProcessed(readInboxState(dir), marker.messageId)) {
|
|
457
|
+
clearInflight(dir, marker.messageId);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
const body = marker.reply ?? didNotFinishText(RESTARTED_REASON, (marker.progressPosted ?? 0) > 0);
|
|
461
|
+
try {
|
|
462
|
+
await sendToTarget(marker.target, body, marker.rootEventId);
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
log("warn", `could not tell the owner about unfinished ${marker.messageId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
if (marker.target.kind === "dm" && marker.reactionEventId) {
|
|
469
|
+
await api
|
|
470
|
+
.setReaction({ peerUid: marker.target.peerUid, messageId: marker.reactionEventId, emoji: THINKING_EMOJI }, false)
|
|
471
|
+
.catch(() => false);
|
|
472
|
+
}
|
|
473
|
+
if (!isKickoff) {
|
|
474
|
+
markProcessed(dir, marker.messageId, now().toISOString());
|
|
475
|
+
await api.ackInbox(config.agentUid, marker.messageId).catch((err) => {
|
|
476
|
+
log("warn", `ack for recovered ${marker.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
clearInflight(dir, marker.messageId);
|
|
480
|
+
log("info", marker.reply ? `posted the saved reply for ${marker.messageId}` : `told the owner ${marker.messageId} did not finish (restart); not re-run`);
|
|
481
|
+
return true;
|
|
482
|
+
};
|
|
483
|
+
/**
|
|
484
|
+
* First-start kickoff: ONE model turn on config.kickoff in the owner DM
|
|
485
|
+
* session, as if the owner had sent it, answered as a DM (with its
|
|
486
|
+
* in-between messages). The thinking reaction goes on the intro message
|
|
487
|
+
* (there is no owner message to mark). A failure never crashes the loop.
|
|
488
|
+
*/
|
|
489
|
+
const runKickoff = async (introEventId) => {
|
|
490
|
+
const prompt = (config.kickoff ?? "").trim();
|
|
491
|
+
if (!prompt || stopping || state !== "running")
|
|
492
|
+
return;
|
|
493
|
+
const peerUid = config.ownerUid;
|
|
494
|
+
const tReceived = now().getTime();
|
|
495
|
+
log("info", `kickoff turn starting (${prompt.length} chars)`);
|
|
496
|
+
const thinking = introEventId
|
|
497
|
+
? await api.setReaction({ peerUid, messageId: introEventId, emoji: THINKING_EMOJI }, true).catch(() => false)
|
|
498
|
+
: false;
|
|
499
|
+
const clearThinking = async () => {
|
|
500
|
+
if (thinking && introEventId) {
|
|
501
|
+
await api.setReaction({ peerUid, messageId: introEventId, emoji: THINKING_EMOJI }, false).catch(() => false);
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
const target = { kind: "dm", peerUid };
|
|
505
|
+
const { outcome, poster } = await runTurnWithProgress({
|
|
506
|
+
marker: { messageId: KICKOFF_MESSAGE_ID, target, ...(thinking && introEventId ? { reactionEventId: introEventId } : {}) },
|
|
507
|
+
prompt,
|
|
508
|
+
sessionScope: "dm",
|
|
509
|
+
tReceived,
|
|
510
|
+
send: (body) => api.sendDm({ toPersonUid: peerUid, body }),
|
|
511
|
+
// The intro already said the bot is starting, and the app shows it thinking.
|
|
512
|
+
workingNotice: false,
|
|
513
|
+
});
|
|
514
|
+
await clearThinking();
|
|
515
|
+
if (outcome.ok) {
|
|
516
|
+
if (poster.postedCount === 0) {
|
|
517
|
+
patchInflight(dir, KICKOFF_MESSAGE_ID, { reply: outcome.reply });
|
|
518
|
+
log("warn", "kickoff reply failed to send; it is posted on the next start");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
clearInflight(dir, KICKOFF_MESSAGE_ID);
|
|
522
|
+
const st = readStatus();
|
|
523
|
+
setStatus({ repliesSent: (st?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
|
|
524
|
+
log("info", `kickoff reply sent (${outcome.reply.length} chars)`);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
let count = 0;
|
|
528
|
+
if (!outcome.aborted)
|
|
529
|
+
count = recordTurnFailure(outcome.error, "dm").count;
|
|
530
|
+
const body = outcome.started && (outcome.aborted || poster.postedCount > 0)
|
|
531
|
+
? `${didNotFinishText(failureReason(outcome), poster.postedCount > 0)} Send me any message and I'll pick up from there.`
|
|
532
|
+
: `Sorry — I couldn't get started on my own (${runtime.id} failed: ${outcome.excerpt.slice(0, 160)}). Send me any message and I'll pick up from there, or check that ${runtime.id} is signed in on this computer.`;
|
|
533
|
+
try {
|
|
534
|
+
await api.sendDm({ toPersonUid: peerUid, body });
|
|
535
|
+
clearInflight(dir, KICKOFF_MESSAGE_ID);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
/* the marker stays: the next start says it did not finish */
|
|
539
|
+
}
|
|
540
|
+
log("error", `kickoff turn ${outcome.aborted ? "stopped by the owner" : `failed (${count}/${FAILURE_LIMIT} in window)`}; not retried`);
|
|
541
|
+
if (!outcome.aborted && failures.exceeded(now().getTime())) {
|
|
542
|
+
await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const handleItem = async (item) => {
|
|
546
|
+
const id = item.messageId;
|
|
547
|
+
const inboxState = readInboxState(dir);
|
|
548
|
+
if (isProcessed(inboxState, id)) {
|
|
549
|
+
// Replied before a crash; only the ack was lost.
|
|
550
|
+
await api.ackInbox(config.agentUid, id);
|
|
551
|
+
clearInflight(dir, id);
|
|
552
|
+
log("info", `acked already-processed ${id}`);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// A turn on this message already started once and was cut off: say so,
|
|
556
|
+
// never run it again.
|
|
557
|
+
const inflight = readInflight(dir);
|
|
558
|
+
if (inflight?.messageId === id) {
|
|
559
|
+
await recoverInflight(inflight);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const sender = item.fromPersonUid ?? "";
|
|
563
|
+
if (isDmItem(item) && sender !== config.ownerUid) {
|
|
564
|
+
// Never reaches the model. Best-effort refusal (a stranger → bot DM is
|
|
565
|
+
// 404 on the server by design), then ack so it never comes back.
|
|
566
|
+
try {
|
|
567
|
+
if (sender)
|
|
568
|
+
await api.sendDm({ toPersonUid: sender, body: nonOwnerRefusalText(config.name) });
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
/* refusal delivery is best-effort */
|
|
572
|
+
}
|
|
573
|
+
markProcessed(dir, id, now().toISOString());
|
|
574
|
+
await api.ackInbox(config.agentUid, id);
|
|
575
|
+
log("info", `refused non-owner DM ${id}`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const target = resolveReplyTarget(item, config);
|
|
579
|
+
if (!target) {
|
|
580
|
+
markProcessed(dir, id, now().toISOString());
|
|
581
|
+
await api.ackInbox(config.agentUid, id);
|
|
582
|
+
log("info", `ignored inbox item ${id} (${item.channel || "dm"}${item.channelScope ? ` ${item.channelScope}` : ""}, not addressed to me)`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const text = (item.text ?? "").trim();
|
|
586
|
+
if (!text) {
|
|
587
|
+
markProcessed(dir, id, now().toISOString());
|
|
588
|
+
await api.ackInbox(config.agentUid, id);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const attempt = (attempts.get(id) ?? 0) + 1;
|
|
592
|
+
attempts.set(id, attempt);
|
|
593
|
+
const reactionId = item.eventId ?? id;
|
|
594
|
+
const tReceived = now().getTime();
|
|
595
|
+
const where = target.kind === "room" ? `room ${target.channelId}` : "dm";
|
|
596
|
+
log("info", `received ${id} (${text.length} chars, ${where})`);
|
|
597
|
+
// Rooms: an UNMENTIONED responder (owner's plain message in a group)
|
|
598
|
+
// claims first so the owner's several bots produce one answer, not one
|
|
599
|
+
// each. A bot that was @mentioned never claims — it was asked by name and
|
|
600
|
+
// must answer even if a sibling bot was faster. The same claimer
|
|
601
|
+
// re-acquires on a retry (server lease), so the claim is safe to repeat
|
|
602
|
+
// when a failed turn leaves the item un-acked.
|
|
603
|
+
if (target.kind === "room" && !target.mentioned) {
|
|
604
|
+
const claimed = await api.claimChannelUnit(target.channelId, target.unitId);
|
|
605
|
+
if (!claimed) {
|
|
606
|
+
markProcessed(dir, id, now().toISOString());
|
|
607
|
+
await api.ackInbox(config.agentUid, id);
|
|
608
|
+
attempts.delete(id);
|
|
609
|
+
log("info", `skipped ${id}: claimed by another responder`);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Thinking indicator: DMs use a reaction; rooms use the ephemeral
|
|
614
|
+
// agent-status wake (cleared client-side by a post, so it is re-posted
|
|
615
|
+
// after each in-between message). It stays on until the turn ends.
|
|
616
|
+
const thinking = target.kind === "dm"
|
|
617
|
+
? await api.setReaction({ peerUid: target.peerUid, messageId: reactionId, emoji: THINKING_EMOJI }, true)
|
|
618
|
+
: false;
|
|
619
|
+
if (target.kind === "room")
|
|
620
|
+
await api.postAgentStatus(target.channelId, ROOM_THINKING_STATUS, target.rootEventId);
|
|
621
|
+
const clearThinking = async () => {
|
|
622
|
+
if (thinking && target.kind === "dm") {
|
|
623
|
+
await api.setReaction({ peerUid: target.peerUid, messageId: reactionId, emoji: THINKING_EMOJI }, false);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
const sessionScope = target.kind === "room" ? target.sessionScope : "dm";
|
|
627
|
+
const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : text;
|
|
628
|
+
const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
|
|
629
|
+
const send = (body) => sendToTarget(markerTarget, body, item.rootEventId);
|
|
630
|
+
const { outcome, poster } = await runTurnWithProgress({
|
|
631
|
+
marker: {
|
|
632
|
+
messageId: id,
|
|
633
|
+
target: markerTarget,
|
|
634
|
+
...(item.rootEventId ? { rootEventId: item.rootEventId } : {}),
|
|
635
|
+
...(thinking ? { reactionEventId: reactionId } : {}),
|
|
636
|
+
},
|
|
637
|
+
prompt,
|
|
638
|
+
sessionScope,
|
|
639
|
+
tReceived,
|
|
640
|
+
send,
|
|
641
|
+
workingNotice: !isSetupBot,
|
|
642
|
+
...(target.kind === "room"
|
|
643
|
+
? { afterProgressPost: () => api.postAgentStatus(target.channelId, ROOM_THINKING_STATUS, target.rootEventId) }
|
|
644
|
+
: {}),
|
|
645
|
+
});
|
|
646
|
+
if (outcome.ok) {
|
|
647
|
+
if (poster.postedCount === 0) {
|
|
648
|
+
// The work finished but nothing reached the chat: keep the answer so
|
|
649
|
+
// the next poll (or a restart) posts it instead of running again.
|
|
650
|
+
patchInflight(dir, id, { reply: outcome.reply });
|
|
651
|
+
await clearThinking();
|
|
652
|
+
log("warn", `reply to ${id} could not be posted; it is retried on the next poll`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
markProcessed(dir, id, now().toISOString());
|
|
656
|
+
await api.ackInbox(config.agentUid, id);
|
|
657
|
+
clearInflight(dir, id);
|
|
658
|
+
attempts.delete(id);
|
|
659
|
+
await clearThinking();
|
|
660
|
+
const st = readStatus();
|
|
661
|
+
setStatus({ repliesSent: (st?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
|
|
662
|
+
log("info", `replied to ${id} (${outcome.reply.length} chars, ${where}, ${poster.postedCount} post${poster.postedCount === 1 ? "" : "s"})`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
await clearThinking();
|
|
666
|
+
if (!outcome.started) {
|
|
667
|
+
// The model never ran (e.g. the binary is missing): nothing happened
|
|
668
|
+
// outside this Mac, so retrying is safe.
|
|
669
|
+
clearInflight(dir, id);
|
|
670
|
+
const { excerpt } = recordTurnFailure(outcome.error, sessionScope);
|
|
671
|
+
if (failures.exceeded(now().getTime())) {
|
|
672
|
+
await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${excerpt}`);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
|
|
676
|
+
try {
|
|
677
|
+
await send(`Sorry — I couldn't answer that one (${runtime.id} kept failing: ${excerpt.slice(0, 160)}). Try again in a bit, or check that ${runtime.id} is signed in on this computer.`);
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
/* best-effort */
|
|
681
|
+
}
|
|
682
|
+
markProcessed(dir, id, now().toISOString());
|
|
683
|
+
await api.ackInbox(config.agentUid, id);
|
|
684
|
+
attempts.delete(id);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
await sleep(fullJitterDelayMs(failures.count(now().getTime()), { random: deps.random }));
|
|
688
|
+
return; // leave un-acked; retried on the next poll
|
|
689
|
+
}
|
|
690
|
+
// The model started: it may already have done things, so never re-run.
|
|
691
|
+
if (!outcome.aborted)
|
|
692
|
+
recordTurnFailure(outcome.error, sessionScope);
|
|
693
|
+
try {
|
|
694
|
+
await send(didNotFinishText(failureReason(outcome), poster.postedCount > 0));
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
// Leave it un-acked with the marker: the next poll says it did not finish.
|
|
698
|
+
log("warn", `could not post "did not finish" for ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
markProcessed(dir, id, now().toISOString());
|
|
702
|
+
await api.ackInbox(config.agentUid, id);
|
|
703
|
+
clearInflight(dir, id);
|
|
704
|
+
attempts.delete(id);
|
|
705
|
+
log("error", `turn on ${id} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; told the owner, not re-run`);
|
|
706
|
+
if (!outcome.aborted && failures.exceeded(now().getTime())) {
|
|
707
|
+
await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
// The kickoff runs alongside the heartbeat, before the first inbox poll, so
|
|
711
|
+
// an owner message sent meanwhile is answered after it in the same session.
|
|
712
|
+
const kickoff = kickoffAfterIntro
|
|
713
|
+
? runKickoff(kickoffAfterIntro.introEventId).catch((err) => {
|
|
714
|
+
log("error", `kickoff turn crashed: ${err instanceof Error ? err.message : String(err)}`);
|
|
715
|
+
})
|
|
716
|
+
: Promise.resolve();
|
|
717
|
+
const inboxLoop = (async () => {
|
|
718
|
+
// A turn cut off by the last restart is reported before anything new runs.
|
|
719
|
+
if (leftover) {
|
|
720
|
+
try {
|
|
721
|
+
await recoverInflight(leftover);
|
|
722
|
+
}
|
|
723
|
+
catch (err) {
|
|
724
|
+
log("warn", `recovering ${leftover.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
await kickoff;
|
|
728
|
+
while (!stopping && state === "running") {
|
|
729
|
+
if (hasPromotionHold(dir))
|
|
730
|
+
break;
|
|
731
|
+
try {
|
|
732
|
+
const items = await api.pullInbox(config.agentUid);
|
|
733
|
+
setStatus({ lastInboxPollAt: now().toISOString() });
|
|
734
|
+
for (const item of items) {
|
|
735
|
+
if (stopping || state !== "running" || hasPromotionHold(dir))
|
|
736
|
+
break;
|
|
737
|
+
try {
|
|
738
|
+
await handleItem(item);
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
log("warn", `inbox item ${item.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
log("warn", `inbox poll failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
747
|
+
}
|
|
748
|
+
if (stopping || state !== "running")
|
|
749
|
+
break;
|
|
750
|
+
await sleep(inboxIntervalMs);
|
|
751
|
+
}
|
|
752
|
+
})();
|
|
753
|
+
function readStatus() {
|
|
754
|
+
return readBotStatus(dir);
|
|
755
|
+
}
|
|
756
|
+
const stop = async (mode = "abort") => {
|
|
757
|
+
if (stopping) {
|
|
758
|
+
if (mode === "abort")
|
|
759
|
+
currentTurn?.abort();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
stopping = true;
|
|
763
|
+
// End the turn in progress; its message gets "did not finish", not silence.
|
|
764
|
+
// Promotion instead drains that turn, including its reply and ACK, before
|
|
765
|
+
// the coordinator snapshots memory and starts the cloud consumer.
|
|
766
|
+
if (mode === "abort")
|
|
767
|
+
currentTurn?.abort();
|
|
768
|
+
await Promise.allSettled([heartbeatLoop, inboxLoop]);
|
|
769
|
+
await finish("stopped", 0, mode === "drain" ? "promotion drain completed" : "stop requested");
|
|
770
|
+
};
|
|
771
|
+
const onSignal = () => {
|
|
772
|
+
void stop();
|
|
773
|
+
};
|
|
774
|
+
const events = deps.processEvents ?? process;
|
|
775
|
+
events.once("SIGTERM", onSignal);
|
|
776
|
+
events.once("SIGINT", onSignal);
|
|
777
|
+
events.once("SIGUSR2", () => { void stop("drain"); });
|
|
778
|
+
void inboxLoop.then(() => { if (hasPromotionHold(dir))
|
|
779
|
+
void stop("drain"); });
|
|
780
|
+
setStatus({ canDrainForPromotion: true });
|
|
781
|
+
void Promise.allSettled([heartbeatLoop, inboxLoop]).then(() => {
|
|
782
|
+
if (state === "running")
|
|
783
|
+
void finish("stopped", 0, "loops ended");
|
|
784
|
+
});
|
|
785
|
+
return { stop, done, status: readStatus };
|
|
786
|
+
}
|
|
787
|
+
//# sourceMappingURL=run.js.map
|