openmausbot 0.1.73 → 0.1.75

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 (42) hide show
  1. package/dist/assets/{index-BYbK7ber.js → index-BTlyRwLl.js} +1 -1
  2. package/dist/assets/index-CeMbvsgx.js +305 -0
  3. package/dist/assets/index-DH7Zax6L.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +9 -0
  6. package/dist-server/index.js +1402 -1048
  7. package/dist-server/mcp-gate.js +58 -16
  8. package/dist-server/openmausbot.js +543 -271
  9. package/dist-server/pair-cli.js +543 -271
  10. package/dist-server/permission-proxy.js +119 -14
  11. package/dist-server/server/auto-approve.js +44 -205
  12. package/dist-server/server/channel-queue.js +20 -2
  13. package/dist-server/server/config.js +36 -1
  14. package/dist-server/server/drivers/acp/core.js +58 -4
  15. package/dist-server/server/drivers/acp/grok.js +15 -2
  16. package/dist-server/server/drivers/antigravity-acp.js +5 -4
  17. package/dist-server/server/drivers/antigravity.js +9 -4
  18. package/dist-server/server/drivers/claude.js +113 -13
  19. package/dist-server/server/drivers/codex.js +37 -15
  20. package/dist-server/server/drivers/retry.js +4 -0
  21. package/dist-server/server/engine-install.js +9 -5
  22. package/dist-server/server/index.js +265 -233
  23. package/dist-server/server/message-db.js +70 -3
  24. package/dist-server/server/permission-proxy.js +90 -15
  25. package/dist-server/server/procs.js +82 -23
  26. package/dist-server/server/routines.js +67 -8
  27. package/dist-server/server/setup-mode.js +6 -7
  28. package/dist-server/server/steer-queue.js +27 -7
  29. package/dist-server/server/store.js +19 -0
  30. package/dist-server/server/team-manifest.js +1 -1
  31. package/dist-server/server/thread-events.js +10 -1
  32. package/dist-server/server/webhooks.js +4 -1
  33. package/dist-server/server/workspace-backup.js +24 -9
  34. package/dist-server/server/workspace.js +13 -0
  35. package/dist-server/shared/approval-mode.js +11 -10
  36. package/dist-server/shared/ask-question.js +172 -0
  37. package/dist-server/shared/provider-safety.js +6 -0
  38. package/dist-server/vps-container-mcp.js +9 -0
  39. package/package.json +1 -1
  40. package/dist/assets/index-BLJ7byG3.js +0 -296
  41. package/dist/assets/index-De2TRLxu.css +0 -1
  42. package/dist-server/server/auto-review.js +0 -79
@@ -10,7 +10,7 @@
10
10
  // Legacy JSON thread files import lazily: the first read of a thread with
11
11
  // no rows pulls the old file in, after which the DB is the source of
12
12
  // truth (the JSON file is left behind as a one-time backup).
13
- import { chmodSync, closeSync, existsSync, openSync, readFileSync, renameSync } from "node:fs";
13
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { DatabaseSync } from "node:sqlite";
16
16
  import { DATA_DIR } from "./config.js";
@@ -19,6 +19,7 @@ const DB_FILE = () => join(DATA_DIR, "messages.db");
19
19
  let handle = null;
20
20
  let handlePath = null;
21
21
  function open() {
22
+ mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
22
23
  const file = DB_FILE();
23
24
  // Transcripts can contain private conversations and tool output. Create
24
25
  // the database with owner-only permissions and also repair an existing
@@ -47,6 +48,16 @@ function open() {
47
48
  thread_id TEXT PRIMARY KEY,
48
49
  active_leaf_id TEXT
49
50
  );
51
+ CREATE TABLE IF NOT EXISTS chat_followups (
52
+ id TEXT PRIMARY KEY,
53
+ kind TEXT NOT NULL,
54
+ owner_id TEXT NOT NULL,
55
+ thread_id TEXT NOT NULL,
56
+ send_id TEXT,
57
+ status TEXT NOT NULL,
58
+ payload TEXT NOT NULL
59
+ );
60
+ CREATE INDEX IF NOT EXISTS chat_followups_receipt ON chat_followups(kind, owner_id, thread_id, send_id);
50
61
  `);
51
62
  ensureRecallIndex(db);
52
63
  ensureMemoryIndex(db);
@@ -153,6 +164,59 @@ function db() {
153
164
  handlePath = DB_FILE();
154
165
  return handle;
155
166
  }
167
+ /** A 202/cancel/dispatch claim must reach disk before publishing its result.
168
+ * Use the existing transcript DB (and backup path), with FULL sync for these
169
+ * small transactions only; ordinary transcript writes keep their policy. */
170
+ function writeFollowups(write) {
171
+ const connection = db();
172
+ connection.exec("PRAGMA synchronous = FULL");
173
+ try {
174
+ connection.exec("BEGIN IMMEDIATE");
175
+ try {
176
+ write(connection);
177
+ connection.exec("COMMIT");
178
+ }
179
+ catch (error) {
180
+ connection.exec("ROLLBACK");
181
+ throw error;
182
+ }
183
+ }
184
+ finally {
185
+ connection.exec("PRAGMA synchronous = NORMAL");
186
+ }
187
+ }
188
+ export function saveChatFollowup(followup) {
189
+ writeFollowups((connection) => connection.prepare("INSERT INTO chat_followups(id, kind, owner_id, thread_id, send_id, status, payload) VALUES (?, ?, ?, ?, ?, 'pending', ?)").run(followup.id, followup.kind, followup.ownerId, followup.threadId, followup.payload.sendId ?? null, JSON.stringify(followup.payload)));
190
+ }
191
+ /** Null retires a dispatch whose canonical transcript is already durable.
192
+ * Cancellation tombstones remain so a retried sendId cannot resurrect it. */
193
+ export function settleChatFollowups(ids, status) {
194
+ if (!ids.length)
195
+ return;
196
+ writeFollowups((connection) => {
197
+ const statement = connection.prepare(status === null
198
+ ? "DELETE FROM chat_followups WHERE id = ?"
199
+ : status === "cancelled"
200
+ ? "UPDATE chat_followups SET status = ?, payload = '{\"text\":\"\"}' WHERE id = ?"
201
+ : "UPDATE chat_followups SET status = ? WHERE id = ?");
202
+ for (const id of ids) {
203
+ if (status === null)
204
+ statement.run(id);
205
+ else
206
+ statement.run(status, id);
207
+ }
208
+ });
209
+ }
210
+ export function chatFollowups(kind) {
211
+ const rows = (kind
212
+ ? db().prepare("SELECT * FROM chat_followups WHERE kind = ? ORDER BY rowid").all(kind)
213
+ : db().prepare("SELECT * FROM chat_followups ORDER BY rowid").all());
214
+ return rows.map((row) => ({ id: row.id, kind: row.kind, ownerId: row.owner_id, threadId: row.thread_id,
215
+ status: row.status, payload: JSON.parse(row.payload) }));
216
+ }
217
+ export function cancelledChatFollowup(kind, ownerId, threadId, sendId) {
218
+ return Boolean(db().prepare("SELECT 1 FROM chat_followups WHERE kind = ? AND owner_id = ? AND thread_id = ? AND send_id = ? AND status = 'cancelled'").get(kind, ownerId, threadId, sendId));
219
+ }
156
220
  const rowToMessage = (row) => JSON.parse(row.json);
157
221
  /** Read one thread, importing its legacy JSON file on first touch. */
158
222
  export function readThread(threadId, legacyFile) {
@@ -268,8 +332,11 @@ export function setActiveLeaf(threadId, leafId) {
268
332
  .run(threadId, leafId);
269
333
  }
270
334
  export function deleteThread(threadId) {
271
- db().prepare("DELETE FROM messages WHERE thread_id = ?").run(threadId);
272
- db().prepare("DELETE FROM thread_state WHERE thread_id = ?").run(threadId);
335
+ writeFollowups((connection) => {
336
+ connection.prepare("DELETE FROM chat_followups WHERE thread_id = ?").run(threadId);
337
+ connection.prepare("DELETE FROM messages WHERE thread_id = ?").run(threadId);
338
+ connection.prepare("DELETE FROM thread_state WHERE thread_id = ?").run(threadId);
339
+ });
273
340
  }
274
341
  /** Case-insensitive substring search over text messages, newest first.
275
342
  * A LIKE scan, deliberately: local transcripts are megabytes at most, a
@@ -10,9 +10,18 @@
10
10
  // ask_user — the agent can pose a question mid-run and wait; the
11
11
  // human's words come back verbatim.
12
12
  //
13
+ // One tool arrives through `approve` that is not a permission at all: the
14
+ // CLI's own AskUserQuestion. It is the tool the model actually reaches for
15
+ // when it wants a person to choose, and acceptEdits will not run it unasked,
16
+ // so it lands here looking like "may I run a tool?" — which is how a question
17
+ // ended up on screen as an Allow/Deny box over a JSON blob. It is intercepted
18
+ // below and asked as what it is: one card carrying every question it posed,
19
+ // answered through the tool's own `answers` field.
20
+ //
13
21
  // stdout is the MCP channel — never console.log here.
14
22
  import { connect } from "node:net";
15
23
  import { randomUUID } from "node:crypto";
24
+ import { parseAskQuestions, questionAnswersByQuestion } from "../shared/ask-question.js";
16
25
  const socketPath = process.argv[2] ?? "";
17
26
  const waiting = new Map();
18
27
  const conn = connect(socketPath);
@@ -45,6 +54,77 @@ conn.on("data", (chunk) => {
45
54
  }
46
55
  });
47
56
  const send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
57
+ /** Hand one ask to the broker and wait for the human's answer. */
58
+ function askBroker(ask) {
59
+ return new Promise((resolve) => {
60
+ waiting.set(String(ask.id), resolve);
61
+ if (conn.destroyed)
62
+ return dead();
63
+ try {
64
+ conn.write(JSON.stringify(ask) + "\n");
65
+ }
66
+ catch {
67
+ dead();
68
+ }
69
+ });
70
+ }
71
+ // ── the CLI's own AskUserQuestion ──────────────────────────────────────
72
+ const ASK_USER_QUESTION = "AskUserQuestion";
73
+ const asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
74
+ /** One question, in the shape the broker and the option card understand. */
75
+ /**
76
+ * Ask the person every question at once, then answer the tool the way it
77
+ * documents.
78
+ *
79
+ * The answer is NOT a bare allow. A bare allow tells the CLI to go and run
80
+ * AskUserQuestion, and a headless run has no dialog to collect anything —
81
+ * the model gets "The user did not answer the questions." back and the
82
+ * click is thrown away. The `answers` object is the field the tool's own
83
+ * schema calls "User answers collected by the permission component":
84
+ * keyed by the question's text, one comma-joined string per question.
85
+ *
86
+ * One ask, not one per question: the card draws the whole set with a tab
87
+ * each, so a person sees what they are committing to before answering any
88
+ * of it. `questionAnswersByQuestion` maps the answer text back per question.
89
+ *
90
+ * There is deliberately no "fall through to the permission path" here. That
91
+ * path cannot answer this tool — allowing it throws the click away, as
92
+ * above — so the fallback offered a person an Allow/Deny box over raw JSON.
93
+ * That box is the bug this file exists to remove.
94
+ */
95
+ async function answerNativeQuestions(input) {
96
+ // Unanswerable entries are SKIPPED by the parser, not fatal: one bad entry
97
+ // must not cost the user the good questions beside it. Nothing left means
98
+ // the whole call was unanswerable, which becomes a denial.
99
+ const questions = parseAskQuestions(input);
100
+ if (!questions?.length) {
101
+ return JSON.stringify({
102
+ behavior: "deny",
103
+ message: "OpenMausBot: this AskUserQuestion call had no answerable question (each one needs question text), so nobody was shown it. Ask again with a well-formed call, or continue without it.",
104
+ });
105
+ }
106
+ const answer = await askBroker({
107
+ t: "ask",
108
+ id: randomUUID(),
109
+ kind: "question",
110
+ tool: ASK_USER_QUESTION,
111
+ input: { questions },
112
+ });
113
+ // A question is only ever denied when the broker is gone.
114
+ if (answer.behavior === "deny") {
115
+ return JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
116
+ }
117
+ // What lands in `answers` turns on WHO answered, not on whether there are
118
+ // words. The broker's own notes are words — the timeout's "nobody answered
119
+ // in time, use your best judgment" is a whole sentence — so filing anything
120
+ // non-blank hands the model system text in the slot reserved for what the
121
+ // person chose. Left out, the CLI reports the question as unanswered, which
122
+ // is the truth.
123
+ const answers = answer.source === "user" && typeof answer.message === "string"
124
+ ? questionAnswersByQuestion(answer.message, questions)
125
+ : {};
126
+ return JSON.stringify({ behavior: "allow", updatedInput: { ...asRecord(input), answers } });
127
+ }
48
128
  const TOOLS = [
49
129
  {
50
130
  name: "approve",
@@ -93,6 +173,12 @@ async function handle(msg) {
93
173
  if (msg.method === "tools/call") {
94
174
  const name = msg.params?.name;
95
175
  const args = msg.params?.arguments ?? {};
176
+ const reply = (text) => send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } });
177
+ // AskUserQuestion is a question wearing a permission's clothes. It never
178
+ // continues into the permission path below — see nativeQuestions.
179
+ if (name === "approve" && args.tool_name === ASK_USER_QUESTION) {
180
+ return reply(await answerNativeQuestions(args.input));
181
+ }
96
182
  const askId = randomUUID();
97
183
  const isQuestion = name === "ask_user";
98
184
  // the CLI may include its own suggested permission rules; on allow we
@@ -103,20 +189,9 @@ async function handle(msg) {
103
189
  : Array.isArray(args.suggestions)
104
190
  ? args.suggestions
105
191
  : null;
106
- const answer = await new Promise((resolve) => {
107
- waiting.set(askId, resolve);
108
- if (conn.destroyed)
109
- return dead();
110
- const ask = isQuestion
111
- ? { t: "ask", id: askId, kind: "question", tool: "ask_user", input: { question: args.question, choices: args.choices } }
112
- : { t: "ask", id: askId, tool: args.tool_name, input: args.input };
113
- try {
114
- conn.write(JSON.stringify(ask) + "\n");
115
- }
116
- catch {
117
- dead();
118
- }
119
- });
192
+ const answer = await askBroker(isQuestion
193
+ ? { t: "ask", id: askId, kind: "question", tool: "ask_user", input: { question: args.question, choices: args.choices } }
194
+ : { t: "ask", id: askId, tool: args.tool_name, input: args.input });
120
195
  let text = answer.message || "No answer was given — use your best judgment.";
121
196
  if (!isQuestion) {
122
197
  if (answer.behavior === "allow") {
@@ -129,7 +204,7 @@ async function handle(msg) {
129
204
  text = JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
130
205
  }
131
206
  }
132
- return send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } });
207
+ return reply(text);
133
208
  }
134
209
  if (String(msg.method ?? "").startsWith("notifications/"))
135
210
  return;
@@ -34,6 +34,10 @@ export function assertSafeCliArgv(resolved, platform = process.platform) {
34
34
  error.code = "ENAMETOOLONG";
35
35
  throw error;
36
36
  }
37
+ // Only spawnCli establishes a private POSIX group. A plain spawned child
38
+ // (for example inside an MCP gate) may share our own group: never signal it.
39
+ const cliGroups = new WeakSet();
40
+ const stopping = new WeakMap();
37
41
  export function spawnCli(cli, args, opts) {
38
42
  const resolved = resolveCli(cli, args);
39
43
  assertSafeCliArgv(resolved);
@@ -43,6 +47,8 @@ export function spawnCli(cli, args, opts) {
43
47
  // win32: taskkill /T does the reaping instead (see killCliTree)
44
48
  ...(process.platform === "win32" ? { windowsHide: true } : { detached: true }),
45
49
  }); // callers always pipe all three
50
+ if (process.platform !== "win32")
51
+ cliGroups.add(child);
46
52
  // A write to a dying child's stdin fails differently per platform, and one
47
53
  // of the ways is fatal. On POSIX the kill is synchronous, the stream is
48
54
  // already destroyed by the time anything writes, and the write throws into
@@ -80,11 +86,78 @@ export function describeSpawnFailure(err, cli) {
80
86
  };
81
87
  return { message: `spawn failed: ${err.message}`, setup: false };
82
88
  }
83
- /** Stop a CLI and every process it spawned (MCP proxies included). */
89
+ /** Stop an owned CLI group, including helpers after its root exits. timeoutMs
90
+ * is the TERM grace period; SIGKILL gets one further second to settle. Helpers
91
+ * that deliberately detach into another group are outside this ownership. */
84
92
  export function killCliTree(child, timeoutMs = 5_000) {
93
+ const existing = stopping.get(child);
94
+ if (existing)
95
+ return existing;
85
96
  const pid = child.pid;
86
- if (!pid || child.exitCode !== null || child.signalCode !== null)
97
+ if (!pid)
87
98
  return Promise.resolve(true);
99
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid)
100
+ return Promise.resolve(false);
101
+ const run = stopCliTree(child, pid, Number.isFinite(timeoutMs) ? Math.max(0, timeoutMs) : 5_000);
102
+ stopping.set(child, run);
103
+ // A failed attempt may be retried. Remember success so a delayed caller
104
+ // cannot signal a reused PID/group after this child's group disappeared.
105
+ void run.then((stopped) => { if (!stopped)
106
+ stopping.delete(child); });
107
+ return run;
108
+ }
109
+ async function stopCliTree(child, pid, timeoutMs) {
110
+ const exited = () => child.exitCode !== null || child.signalCode !== null;
111
+ if (process.platform !== "win32") {
112
+ const group = cliGroups.has(child);
113
+ const settled = () => {
114
+ if (!exited())
115
+ return false;
116
+ if (!group)
117
+ return true;
118
+ try {
119
+ process.kill(-pid, 0);
120
+ return false;
121
+ }
122
+ catch (error) {
123
+ // EPERM and other failures do not prove that the group is gone.
124
+ return error.code === "ESRCH";
125
+ }
126
+ };
127
+ const signal = (value) => {
128
+ try {
129
+ if (group)
130
+ process.kill(-pid, value);
131
+ else
132
+ child.kill(value);
133
+ }
134
+ catch {
135
+ try {
136
+ child.kill(value);
137
+ }
138
+ catch { /* report uncertainty below */ }
139
+ }
140
+ };
141
+ const wait = async (ms) => {
142
+ const deadline = Date.now() + ms;
143
+ while (!settled()) {
144
+ const remaining = deadline - Date.now();
145
+ if (remaining <= 0)
146
+ return false;
147
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, remaining)));
148
+ }
149
+ return true;
150
+ };
151
+ if (settled())
152
+ return true;
153
+ signal("SIGTERM");
154
+ if (await wait(timeoutMs))
155
+ return true;
156
+ signal("SIGKILL");
157
+ return wait(1_000);
158
+ }
159
+ if (exited())
160
+ return true;
88
161
  return new Promise((resolve) => {
89
162
  let timer;
90
163
  const done = (stopped) => {
@@ -96,32 +169,18 @@ export function killCliTree(child, timeoutMs = 5_000) {
96
169
  child.once("close", closed);
97
170
  timer = setTimeout(() => done(false), timeoutMs);
98
171
  timer.unref?.();
99
- if (process.platform === "win32") {
100
- execFile("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, (err) => {
101
- if (!err)
102
- return;
103
- try {
104
- // taskkill is unavailable or the tree lookup failed. At least stop
105
- // the process we own instead of leaving the entire turn running.
106
- child.kill();
107
- }
108
- catch {
109
- /* already gone */
110
- }
111
- });
112
- return;
113
- }
114
- try {
115
- process.kill(-pid, "SIGTERM");
116
- }
117
- catch {
172
+ execFile("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, (err) => {
173
+ if (!err)
174
+ return;
118
175
  try {
119
- child.kill("SIGTERM");
176
+ // taskkill is unavailable or the tree lookup failed. At least stop
177
+ // the process we own instead of leaving the entire turn running.
178
+ child.kill();
120
179
  }
121
180
  catch {
122
181
  /* already gone */
123
182
  }
124
- }
183
+ });
125
184
  });
126
185
  }
127
186
  /** Per-turn broker channel: unix socket on POSIX, named pipe on Windows
@@ -6,6 +6,14 @@ import { DATA_DIR } from "./config.js";
6
6
  import { writeFileAtomic } from "./atomic.js";
7
7
  import { redactSecretsInText } from "./redact.js";
8
8
  const persistedSourceThreadId = z.string().trim().min(1).optional().catch(undefined);
9
+ const webhookRunReceiptSchema = z.object({
10
+ webhookId: z.string().min(1).max(200),
11
+ deliveryId: z.string().min(1).max(200),
12
+ runId: z.string().min(1),
13
+ acceptedAt: z.number().finite().nonnegative(),
14
+ });
15
+ const WEBHOOK_RETRY_WINDOW_MS = 7 * 24 * 60 * 60_000;
16
+ const MAX_WEBHOOK_RECEIPTS = 20_000;
9
17
  function routineRequestOwnerKey(owner) {
10
18
  return JSON.stringify([owner.requestId, owner.messageId, owner.botId, owner.threadId]);
11
19
  }
@@ -426,6 +434,7 @@ export class RoutineManager {
426
434
  routines = [];
427
435
  runs = [];
428
436
  routineRequestReceipts = [];
437
+ webhookRunReceipts = [];
429
438
  timer = null;
430
439
  ticking = false;
431
440
  constructor(options) {
@@ -486,11 +495,29 @@ export class RoutineManager {
486
495
  typeof receipt?.resultId === "string" &&
487
496
  Number.isFinite(receipt?.appliedAt))
488
497
  : [];
498
+ this.webhookRunReceipts = Array.isArray(disk.webhookRunReceipts)
499
+ ? disk.webhookRunReceipts.flatMap((receipt) => {
500
+ const parsed = webhookRunReceiptSchema.safeParse(receipt);
501
+ return parsed.success ? [parsed.data] : [];
502
+ })
503
+ : [];
504
+ // Upgrade old run logs before history pruning can discard their IDs.
505
+ const known = new Set(this.webhookRunReceipts.map((r) => JSON.stringify([r.webhookId, r.deliveryId])));
506
+ for (const run of this.runs) {
507
+ if (!run.webhookId || !run.deliveryId || run.createdAt < this.now() - WEBHOOK_RETRY_WINDOW_MS)
508
+ continue;
509
+ const key = JSON.stringify([run.webhookId, run.deliveryId]);
510
+ if (known.has(key))
511
+ continue;
512
+ this.webhookRunReceipts.push({ webhookId: run.webhookId, deliveryId: run.deliveryId, runId: run.id, acceptedAt: run.createdAt });
513
+ known.add(key);
514
+ }
489
515
  }
490
516
  catch {
491
517
  this.routines = [];
492
518
  this.runs = [];
493
519
  this.routineRequestReceipts = [];
520
+ this.webhookRunReceipts = [];
494
521
  }
495
522
  // A local process cannot still own these turns after a full restart.
496
523
  const recovered = [];
@@ -828,11 +855,22 @@ export class RoutineManager {
828
855
  queueMicrotask(() => void this.tick());
829
856
  return cloneRun(run);
830
857
  }
831
- /** Queue an event-driven job without inventing a calendar schedule. Webhook
832
- * definitions live in their own store; the execution receipt deliberately
833
- * reuses this manager so busy-bot ordering, task creation and VM routing stay
834
- * identical for every unattended job. */
858
+ /** Look up an accepted delivery independently of run-log retention. */
859
+ webhookRunReceipt(webhookId, deliveryId) {
860
+ const receipt = this.webhookRunReceipts.find((candidate) => candidate.webhookId === webhookId && candidate.deliveryId === deliveryId &&
861
+ candidate.acceptedAt >= this.now() - WEBHOOK_RETRY_WINDOW_MS);
862
+ if (receipt)
863
+ return { id: receipt.runId };
864
+ // Never duplicate work that is still pending, even beyond the retry window.
865
+ const active = this.runs.find((run) => run.webhookId === webhookId && run.deliveryId === deliveryId &&
866
+ ["queued", "running", "waiting"].includes(run.status));
867
+ return active ? { id: active.id } : null;
868
+ }
869
+ /** Queue webhook work through the same dispatcher as scheduled routines. */
835
870
  enqueueWebhook(input) {
871
+ const existing = this.webhookRunReceipt(input.webhookId, input.deliveryId);
872
+ if (existing)
873
+ return existing;
836
874
  if (this.options.botState(input.botId) === "missing") {
837
875
  throw Object.assign(new Error("The assigned MAUS no longer exists"), { status: 410 });
838
876
  }
@@ -853,8 +891,26 @@ export class RoutineManager {
853
891
  attachments: [],
854
892
  createdAt: this.now(),
855
893
  };
894
+ const previousReceipts = this.webhookRunReceipts;
895
+ const receipts = previousReceipts.filter((receipt) => receipt.acceptedAt >= this.now() - WEBHOOK_RETRY_WINDOW_MS);
896
+ // Never evict a promised retry identity to admit fresh work.
897
+ if (receipts.length >= MAX_WEBHOOK_RECEIPTS) {
898
+ throw Object.assign(new Error("Webhook retry history is full; try again later"), { status: 429 });
899
+ }
900
+ receipts.push({ webhookId: input.webhookId, deliveryId: input.deliveryId, runId: run.id, acceptedAt: this.now() });
901
+ const previousRuns = this.runs.slice();
856
902
  this.runs.push(run);
857
- this.save();
903
+ this.webhookRunReceipts = receipts;
904
+ try {
905
+ this.save();
906
+ }
907
+ catch (error) {
908
+ // Restore pruned history too, retaining the live run objects that tick()
909
+ // may be awaiting rather than replacing them with cloned snapshots.
910
+ this.runs = previousRuns;
911
+ this.webhookRunReceipts = previousReceipts;
912
+ throw error;
913
+ }
858
914
  this.emitRun(run);
859
915
  queueMicrotask(() => void this.tick());
860
916
  return cloneRun(run);
@@ -1044,9 +1100,11 @@ export class RoutineManager {
1044
1100
  }
1045
1101
  // Oldest queued requests have priority. New manual/webhook arrivals
1046
1102
  // must not continually overtake work that has already waited. Snapshot
1047
- // the queue because each dispatch can asynchronously add/cancel work.
1048
- for (const run of this.runs.slice()) {
1049
- if (run.status !== "queued")
1103
+ // IDs retain order across awaits without holding stale objects after
1104
+ // another request rolls back a failed routine-definition write.
1105
+ for (const id of this.runs.map((run) => run.id)) {
1106
+ const run = this.runs.find((candidate) => candidate.id === id);
1107
+ if (!run || run.status !== "queued")
1050
1108
  continue;
1051
1109
  // A queued interval represents the latest useful check, not a backlog
1052
1110
  // item. If the bot stayed busy across later occurrences, align this
@@ -1430,6 +1488,7 @@ export class RoutineManager {
1430
1488
  routines: this.routines,
1431
1489
  runs: this.runs,
1432
1490
  routineRequestReceipts: this.routineRequestReceipts,
1491
+ webhookRunReceipts: this.webhookRunReceipts,
1433
1492
  }, null, 2), { mode: 0o600 });
1434
1493
  }
1435
1494
  }
@@ -1,5 +1,5 @@
1
- // Setup mode: the coaching block a bot gets when it has not been set up yet,
2
- // or when the user asks for it with /setup. The bot interviews the user, says
1
+ // Setup mode: optional coaching when the user asks for it with /setup.
2
+ // An empty profile is not a request to interview the user. The bot says
3
3
  // what it intends, and then configures itself only through proposal cards
4
4
  // (propose_profile, propose_routine, skill_manage, request_credential) — so
5
5
  // nothing changes without the user's approval. Mirrors skill-learn.ts:
@@ -29,11 +29,10 @@ export function expandSetupTurnText(userText) {
29
29
  ? `Set yourself up for this job: ${setup.request}`
30
30
  : "Set yourself up. Ask me what you need to know, then propose your configuration.";
31
31
  }
32
- /** A bot with neither standing instructions nor a description has not been
33
- * set up. /setup re-enters the mode for a configured bot. */
32
+ /** Only an explicit setup request enters coaching. Existing/blank bots must
33
+ * still do ordinary work, including delegated and scheduled requests. */
34
34
  export function setupModeActive(input) {
35
- const blank = !(input.soul ?? "").trim() && !(input.description ?? "").trim();
36
- return blank || parseSetupCommand(input.text) !== null;
35
+ return parseSetupCommand(input.text) !== null;
37
36
  }
38
37
  // skill_manage is only ever mounted alongside the other agent tools when
39
38
  // skill authoring is turned on for this turn (OMB_SKILL_AUTHORING_ENABLED);
@@ -46,7 +45,7 @@ function folderClause(cwd) {
46
45
  : "which folder on this computer it should work in (today it has none and works in a private workspace; offer to keep that, or ask for a path)";
47
46
  }
48
47
  function buildSetupPrompt(profileAside, cwd) {
49
- return ("\n\nThis bot has not been set up yet, or the user asked you to set yourself up. Your job this conversation is to set yourself up from what the user tells you." +
48
+ return ("\n\nThe user explicitly asked you to set yourself up. For this setup request, help configure the bot from what the user tells you." +
50
49
  ` First ask at most four questions that change what you would build: what the job is, when it should happen (on demand, on a schedule, or when something arrives), which apps or accounts it touches, and ${folderClause(cwd)}.` +
51
50
  " Then, before any tool call, tell the user in plain language what you intend: who you will be, what you will do and when, where you will work, what you will need from them, and what you will not do. Wait for a yes." +
52
51
  " When they say yes, first send one message that lists the cards you are about to raise, then make the tool calls — the cards must appear after that message, never before it. After the tool calls add at most one short line and do not repeat the list." +
@@ -4,11 +4,11 @@
4
4
  // here until the bot settles, then lands in the thread and runs as ONE
5
5
  // follow-up turn whose prompt is the queued texts separated by a blank line.
6
6
  //
7
- // The queue is memory-only and is NOT in `messages[]` while the current
7
+ // The durable queue is NOT in `messages[]` while the current
8
8
  // turn is running: appending immediately would make the queued line the
9
9
  // active leaf, so remaining tool/assistant events of *this* turn would
10
- // hang off a user line the model has not seen. Restart loses the queue
11
- // (same as delegations / approvals). The composer shows a pending chip
10
+ // hang off a user line the model has not seen. Restart restores only sends
11
+ // whose dispatch has not begun. The composer shows a pending chip
12
12
  // until drain appends the words.
13
13
  //
14
14
  // Unlike the delegation drain, an interrupted or failed turn does NOT
@@ -17,7 +17,20 @@
17
17
  // stop-then-steer (queue a correction, hit Stop, the correction runs) is
18
18
  // the feature.
19
19
  import { newId } from "./contracts.js";
20
+ import { chatFollowups, saveChatFollowup, settleChatFollowups } from "./message-db.js";
20
21
  const queues = new Map(); // threadId → waiting sends
22
+ export function restoreSteeredMessages() {
23
+ queues.clear();
24
+ for (const row of chatFollowups("bot")) {
25
+ if (row.status !== "pending")
26
+ continue;
27
+ const entry = queues.get(row.threadId) ?? { botId: row.ownerId, items: [] };
28
+ if (entry.botId !== row.ownerId)
29
+ throw new Error("queued task belongs to another bot");
30
+ entry.items.push({ ...row.payload, messageId: row.id, prompt: row.payload.prompt ?? row.payload.text });
31
+ queues.set(row.threadId, entry);
32
+ }
33
+ }
21
34
  const listeners = new Set();
22
35
  const changed = () => {
23
36
  for (const listener of listeners) {
@@ -50,7 +63,7 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
50
63
  // queues even if a corrupt caller reuses a thread id.
51
64
  if (entry.botId !== botId)
52
65
  throw new Error("queued task belongs to another bot");
53
- entry.items.push({
66
+ const item = {
54
67
  messageId: id,
55
68
  text,
56
69
  prompt: options.prompt ?? text,
@@ -58,7 +71,9 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
58
71
  sendId: options.sendId,
59
72
  reason: options.reason,
60
73
  unattended: options.unattended,
61
- });
74
+ };
75
+ saveChatFollowup({ id, kind: "bot", ownerId: botId, threadId, payload: item });
76
+ entry.items.push(item);
62
77
  queues.set(threadId, entry);
63
78
  changed();
64
79
  return { id };
@@ -93,6 +108,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
93
108
  : store.bot(entry.botId);
94
109
  if (!bot) {
95
110
  // the bot or task was deleted while messages waited
111
+ settleChatFollowups(entry.items.map((item) => item.messageId), "cancelled");
96
112
  queues.delete(threadId);
97
113
  changed();
98
114
  continue;
@@ -101,6 +117,8 @@ export function drainSteeredMessages(store, run, isBlocked) {
101
117
  continue;
102
118
  // committed to draining: the entry leaves the map before anything runs,
103
119
  // so a settle racing another settle can never fire the same queue twice
120
+ const ids = entry.items.map((item) => item.messageId);
121
+ settleChatFollowups(ids, "dispatching");
104
122
  queues.delete(threadId);
105
123
  changed();
106
124
  const appended = [];
@@ -124,10 +142,11 @@ export function drainSteeredMessages(store, run, isBlocked) {
124
142
  // message into one HTML block, which makes that attachment stop being a
125
143
  // native image when the combined follow-up is dispatched.
126
144
  const prompt = entry.items.map((item) => item.prompt).join("\n\n");
127
- void run(entry.botId, threadId, prompt, last, appended.map((message) => message.id),
145
+ const running = run(entry.botId, threadId, prompt, last, appended.map((message) => message.id),
128
146
  // one unattended line makes the whole drained turn unattended: a
129
147
  // person's words in the same queue cannot re-attend a bot's own
130
148
  entry.items.some((item) => item.unattended === true));
149
+ void Promise.resolve(running).then(() => settleChatFollowups(ids, null), () => settleChatFollowups(ids, "interrupted")).catch((error) => console.warn("steer-queue: could not settle durable follow-up", error));
131
150
  }
132
151
  }
133
152
  /** Find the receipt for a retry whose message is still waiting to drain. */
@@ -141,7 +160,7 @@ export function queuedSteeredMessage(botId, threadId, sendId) {
141
160
  /** Drop one waiting send owned by this bot so it never drains. The queue id
142
161
  * is stable even if the bot switches away from the task while the request is
143
162
  * in flight. Returns false when it was already drained, belongs to another
144
- * bot, or a restart lost the in-memory auto-run intent. */
163
+ * bot, or dispatch has already started. */
145
164
  export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
146
165
  for (const [threadId, entry] of queues) {
147
166
  if (entry.botId !== botId || (expectedThreadId !== undefined && threadId !== expectedThreadId))
@@ -149,6 +168,7 @@ export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
149
168
  const items = entry.items.filter((item) => item.messageId !== messageId);
150
169
  if (items.length === entry.items.length)
151
170
  continue;
171
+ settleChatFollowups([messageId], "cancelled");
152
172
  if (items.length === 0)
153
173
  queues.delete(threadId);
154
174
  else