openmausbot 0.1.82 → 0.1.84

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 (54) hide show
  1. package/dist/assets/index-BbU5REzd.js +310 -0
  2. package/dist/assets/{index-CKysBq-V.css → index-CLGfYlx_.css} +1 -1
  3. package/dist/assets/{index-5HVwp5m2.js → index-DNa2umw-.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +20 -4
  6. package/dist-server/drivers/agents-proxy.js +30 -4
  7. package/dist-server/index.js +1716 -581
  8. package/dist-server/mcp-gate.js +2 -1
  9. package/dist-server/openmausbot.js +224 -63
  10. package/dist-server/pair-cli.js +224 -63
  11. package/dist-server/server/auto-approve.js +15 -1
  12. package/dist-server/server/auto-vm-claims.js +22 -0
  13. package/dist-server/server/browser-bundle-release.js +8 -7
  14. package/dist-server/server/browser-engine.js +1 -1
  15. package/dist-server/server/channel-queue.js +58 -0
  16. package/dist-server/server/checkpoints.js +9 -5
  17. package/dist-server/server/chief-of-staff.js +5 -2
  18. package/dist-server/server/computer-wait.js +33 -0
  19. package/dist-server/server/config.js +58 -3
  20. package/dist-server/server/contracts.js +0 -7
  21. package/dist-server/server/delta-context.js +258 -0
  22. package/dist-server/server/drivers/acp/gemini.js +11 -1
  23. package/dist-server/server/drivers/acp/qwen.js +16 -1
  24. package/dist-server/server/drivers/agents-proxy.js +32 -4
  25. package/dist-server/server/drivers/claude.js +26 -8
  26. package/dist-server/server/drivers/codex.js +79 -6
  27. package/dist-server/server/drivers/pi.js +2 -1
  28. package/dist-server/server/env-path.js +6 -1
  29. package/dist-server/server/incidents.js +104 -0
  30. package/dist-server/server/index.js +886 -134
  31. package/dist-server/server/local-routing.js +10 -4
  32. package/dist-server/server/notify.js +3 -1
  33. package/dist-server/server/peer-roster.js +37 -1
  34. package/dist-server/server/recent-work.js +4 -1
  35. package/dist-server/server/redact.js +5 -48
  36. package/dist-server/server/room-handoffs.js +79 -16
  37. package/dist-server/server/skill-fetch.js +1 -1
  38. package/dist-server/server/steer-queue.js +29 -0
  39. package/dist-server/server/store.js +43 -3
  40. package/dist-server/server/thread-retention.js +50 -0
  41. package/dist-server/server/turn-context.js +7 -0
  42. package/dist-server/server/turn-resources.js +6 -0
  43. package/dist-server/shared/approval-mode.js +8 -5
  44. package/dist-server/shared/inspector.js +1 -0
  45. package/dist-server/shared/json.js +1 -0
  46. package/dist-server/shared/notification.js +1 -0
  47. package/dist-server/shared/redact.js +61 -0
  48. package/dist-server/shared/routines.js +1 -0
  49. package/dist-server/shared/runtime-events.js +1 -0
  50. package/dist-server/shared/webhooks.js +1 -0
  51. package/dist-server/shared/wire.js +8 -0
  52. package/dist-server/vps-container-mcp.js +20 -4
  53. package/package.json +1 -1
  54. package/dist/assets/index-BLLsDK2F.js +0 -309
@@ -1,9 +1,15 @@
1
+ /** Hosts whose desktop the app can drive itself: macOS and Windows ship the
2
+ * CUA driver and keep "This computer" selectable in the UI; Linux is a beta
3
+ * behind an explicit per-bot choice. */
4
+ const HOSTS_WITH_LOCAL_CONTROL = new Set(["darwin", "win32", "linux"]);
5
+ /** Hosts where Auto may land on the person's own desktop unasked. */
6
+ const HOSTS_WITH_AUTO_LOCAL = new Set(["darwin", "win32"]);
1
7
  export function shouldMountLocalComputer({ requested, hostPlatform = process.platform, providerSupportsLocal, }) {
2
8
  if (!providerSupportsLocal)
3
9
  return false;
4
10
  if (requested === "local")
5
- return hostPlatform === "darwin" || hostPlatform === "linux";
6
- // Preserve the established macOS Auto behavior. Linux local control is a
7
- // beta and can only be selected explicitly per bot.
8
- return requested === undefined && hostPlatform === "darwin";
11
+ return HOSTS_WITH_LOCAL_CONTROL.has(hostPlatform);
12
+ // Preserve the established macOS Auto behavior, which Windows shares.
13
+ // Linux local control is a beta and can only be selected explicitly per bot.
14
+ return requested === undefined && HOSTS_WITH_AUTO_LOCAL.has(hostPlatform);
9
15
  }
@@ -60,7 +60,9 @@ export function buildNotification(kind, bot, threadId, detail, extra) {
60
60
  ? `${who}'s routine is waiting`
61
61
  : kind === "turn-failed"
62
62
  ? `${who} couldn't start`
63
- : `${who} finished`;
63
+ : kind === "incident"
64
+ ? `${who} hit a problem`
65
+ : `${who} finished`;
64
66
  // A "finished" with nothing to say is not worth a notification — the
65
67
  // badge in the sidebar already carries that much.
66
68
  if (kind === "done" && !body)
@@ -61,6 +61,37 @@ export function canReachPeer(from, target) {
61
61
  export function reachablePeers(bots, from) {
62
62
  return bots.filter(bot => canReachPeer(from, bot));
63
63
  }
64
+ /** What a bot wrote in a bot-id slot, resolved to a teammate.
65
+ *
66
+ * Models copy ids from list_bots most of the time, but a Chief reading its
67
+ * roster reaches for the name it sees there, and a name that names exactly
68
+ * one reachable teammate is not a mistake worth refusing: the refusal reads
69
+ * as a teammate that is gone, and the person is then told the platform lost
70
+ * their team (#1348). Names resolve only inside `reachablePeers` — the same
71
+ * set list_bots and the roster show — so a name can never reach a bot the id
72
+ * could not. An id is always taken as an id, hidden or unreachable included:
73
+ * the route says what is wrong with it. Two reachable teammates with one
74
+ * name is the person's naming, not the model's error, so it is refused with
75
+ * the way out instead of picking one. */
76
+ export function resolveTeammate(bots, from, raw) {
77
+ const wanted = raw.trim();
78
+ if (bots.some(bot => bot.id === wanted))
79
+ return { id: wanted, byName: false };
80
+ const fold = (value) => value.replace(/^@/, "").replace(/\s+/g, " ").trim().toLowerCase();
81
+ const name = fold(wanted);
82
+ // The caller's own argument is echoed, flattened and clipped like any
83
+ // other text that lands in a model's context, never a bot's persona.
84
+ const shown = peerName(wanted) || wanted;
85
+ if (!name)
86
+ return { error: `No bot with id "${shown}" — call list_bots and copy the exact id from the result` };
87
+ const matches = reachablePeers(bots, from).filter(bot => fold(bot.name) === name);
88
+ if (matches.length === 1)
89
+ return { id: matches[0].id, byName: true };
90
+ if (matches.length > 1) {
91
+ return { error: `${matches.length} reachable teammates are named "${shown}" — call list_bots and use the id of the one you mean` };
92
+ }
93
+ return { error: `No bot with id or name "${shown}" — call list_bots and copy the exact id from the result` };
94
+ }
64
95
  // The roster is interpolated into a TRUSTED bot's system prompt on every
65
96
  // turn, and its inputs (name/title/description) are user-editable and — via
66
97
  // team import — third-party-authored. Caps bound both the token spend and
@@ -120,7 +151,12 @@ export function renderRoster(team, opts) {
120
151
  const role = clip(bot.title ?? "", ROSTER_ROLE_MAX) || "General assistant";
121
152
  const about = opts.about ? clip(bot.description ?? "", ROSTER_ABOUT_MAX) : "";
122
153
  const availability = peerStatusWords(peerStatus(bot.activity, bot.busy));
123
- return `- ${name} — ${role}${about ? `: ${about}` : ""} (${availability})`;
154
+ // The id rides on every line because it is what the comms tools take. A
155
+ // Chief that only ever saw names in its prompt reached for the name it
156
+ // could see, was refused with "no longer exists", and told the person
157
+ // the platform had lost its team (#1348). Ids are the harness's own
158
+ // uuids, clipped anyway: bots.json is hand-editable.
159
+ return `- ${name} — ${role}${about ? `: ${about}` : ""} (${availability}) [id: ${clip(bot.id, ROSTER_NAME_MAX)}]`;
124
160
  });
125
161
  return (lines.join("\n") +
126
162
  (overflow > 0 ? `\n- …and ${overflow} more (use list_bots for the full roster).` : ""));
@@ -137,6 +137,9 @@ export function parseSince(value, now = Date.now()) {
137
137
  }
138
138
  if (/^\d{12,}$/.test(text))
139
139
  return Number(text);
140
- const parsed = Date.parse(value.trim());
140
+ // A bare YYYY-MM-DD is the one form Date.parse reads as UTC midnight. Read
141
+ // it as the start of that local day, like "today", the brief's times, and
142
+ // the memory/log/YYYY-MM-DD.md day names.
143
+ const parsed = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(text) ? `${text}T00:00` : value.trim());
141
144
  return Number.isFinite(parsed) ? parsed : null;
142
145
  }
@@ -22,60 +22,17 @@ function isSecretName(name) {
22
22
  return true;
23
23
  return /(^|[_.-])keys?$/.test(lower);
24
24
  }
25
- const REDACTION_MARKER = /^«redacted \d+ chars»$/;
26
- /** Keep repeated redaction byte-for-byte stable. Persisted payloads can pass
27
- * through both a content scrub and the store-wide scrub; re-masking our own
28
- * marker would change its reported length (and any hash over the payload). */
29
- const mask = (value) => (REDACTION_MARKER.test(value) ? value : `«redacted ${value.length} chars»`);
25
+ import { mask, redactSecretsInText } from "../shared/redact.js";
26
+ // Text-shaped secret redaction lives in shared/redact.ts now, so the client
27
+ // (task timeline) and the server scrub identically without the client
28
+ // bundling server code; re-exported here so existing importers keep working.
29
+ export { mask, redactSecretsInText } from "../shared/redact.js";
30
30
  // ── content-shaped secrets ────────────────────────────────────────────
31
31
  // What a bot's own reply, a tool title, or a permission card can carry —
32
32
  // and, since the rebuild replays activity into every handed-over context,
33
33
  // what would otherwise become permanent. High precision on purpose: a
34
34
  // generic "long hex/base64" heuristic would rewrite real code in the
35
35
  // transcript, so only shapes that are unmistakably credentials match.
36
- const KEY_PREFIXES = [
37
- /\bom[dg]_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g, // desktop device and model-only credentials
38
- /\bsk-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g, // anthropic / openai / stripe
39
- /\bxai-[A-Za-z0-9_-]{20,}/g, // xai (grok)
40
- /\bgsk_[A-Za-z0-9]{40,}/g, // groq
41
- /\bhf_[A-Za-z0-9]{30,}/g, // hugging face
42
- /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, // github classic
43
- /\bgithub_pat_[A-Za-z0-9_]{20,}/g, // github fine-grained
44
- /\bxox[abposr]-[A-Za-z0-9-]{20,}/g, // slack
45
- /\bAKIA[0-9A-Z]{16}\b/g, // aws access key id
46
- /\bAIza[0-9A-Za-z_-]{30,}/g, // google api key
47
- /\bnpm_[A-Za-z0-9]{20,}/g, // npm
48
- /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // jwt
49
- ];
50
- const BEARER = /(\bBearer\s+)([A-Za-z0-9._~+/=-]{12,})/g;
51
- const PEM_BLOCK = /(-----BEGIN [A-Z ]*PRIVATE KEY-----)([\s\S]*?)(-----END [A-Z ]*PRIVATE KEY-----)/g;
52
- /** key=value / key: value / key="value" where the key is secret-shaped.
53
- * The value must be a single token of some length; prose after a colon
54
- * ("password: leave blank…") has spaces and does not match. */
55
- const KEY_VALUE = /\b((?:[A-Za-z0-9_-]*_)?(?:api[_-]?key|apikey|secret|token|password|passwd|authorization|auth[_-]?token|access[_-]?key|private[_-]?key)s?)(["']?\s*[=:]\s*)(["']?)([A-Za-z0-9._~+/=-]{8,})\3/gi;
56
- /** `X_KEY=value`, `xai-key=value`: an assignment to a name that ENDS in key
57
- * is a credential whatever the value looks like, so no length floor. The
58
- * separator before `key` is what keeps `hotkey=` and `keyboard=` out. */
59
- const KEY_SUFFIX_ASSIGNMENT = /\b([A-Za-z][A-Za-z0-9_-]*[_-]key)s?(=)(["']?)([A-Za-z0-9._~+/=-]+)\3/gi;
60
- /** `--token abc`, `--password=abc`: the flag names a secret; the value is
61
- * whatever single token follows, never another flag. */
62
- const SECRET_FLAG = /(--(?:token|password|passwd|api-key|apikey|secret|access-key|auth-token)(?:=|\s+))(["']?)(?!-)([A-Za-z0-9._~+/=-]+)\2/gi;
63
- /** `scheme://user:secret@host` — the password in a URL's userinfo. */
64
- const URL_USERINFO = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@'"]+:)([^\s/@'"«»]+)(@)/gi;
65
- export function redactSecretsInText(text) {
66
- if (!text || text.length < 8)
67
- return text;
68
- let out = text;
69
- out = out.replace(PEM_BLOCK, (_m, open, body, close) => `${open}\n${mask(body.trim())}\n${close}`);
70
- for (const re of KEY_PREFIXES)
71
- out = out.replace(re, (m) => mask(m));
72
- out = out.replace(BEARER, (_m, lead, tok) => `${lead}${mask(tok)}`);
73
- out = out.replace(KEY_VALUE, (_m, key, sep, quote, value) => `${key}${sep}${quote}${mask(value)}${quote}`);
74
- out = out.replace(KEY_SUFFIX_ASSIGNMENT, (_m, key, sep, quote, value) => `${key}${sep}${quote}${mask(value)}${quote}`);
75
- out = out.replace(SECRET_FLAG, (_m, flag, quote, value) => `${flag}${quote}${mask(value)}${quote}`);
76
- out = out.replace(URL_USERINFO, (_m, lead, secret, at) => `${lead}${mask(secret)}${at}`);
77
- return out;
78
- }
79
36
  /** Deep copy with credential VALUES replaced. Handles the two shapes that
80
37
  * actually carry them: a plain object of env vars ({KEY: "v"}) and the ACP
81
38
  * wire shape (env: [{name, value}]). Anything unrecognised is copied as-is. */
@@ -12,7 +12,7 @@ const nodeSchema = z.object({
12
12
  approvalGranted: z.boolean().default(false),
13
13
  kind: z.enum(["work", "assignment"]).default("work"),
14
14
  });
15
- export const ROOM_HANDOFF_LIMITS = { depth: 4, requests: 24, executions: 48, lifetimeMs: 30 * 60_000, minRunwayMs: 10 * 60_000, queueMs: 60 * 60_000 };
15
+ export const ROOM_HANDOFF_LIMITS = { depth: 4, requests: 24, executions: 48, lifetimeMs: 30 * 60_000, minRunwayMs: 10 * 60_000, queueMs: 60 * 60_000, hardCapMs: 4 * 60 * 60_000 };
16
16
  /** Renders elapsed milliseconds as whole minutes, or seconds under one minute. */
17
17
  const duration = (ms) => ms >= 60_000 ? `${Math.floor(ms / 60_000)}m` : `${Math.floor(ms / 1000)}s`;
18
18
  const terminal = (n) => ["completed", "failed", "cancelled"].includes(n.status);
@@ -27,10 +27,14 @@ export class RoomHandoffs {
27
27
  file;
28
28
  hooks;
29
29
  now;
30
- constructor(file, hooks, now = Date.now) {
30
+ limits;
31
+ /** Per-root pause accounting for the tree lifetime clock. */
32
+ pauses = new Map();
33
+ constructor(file, hooks, now = Date.now, limits = {}) {
31
34
  this.file = file;
32
35
  this.hooks = hooks;
33
36
  this.now = now;
37
+ this.limits = { ...ROOM_HANDOFF_LIMITS, ...limits };
34
38
  try {
35
39
  const saved = z.array(nodeSchema).max(10_000).parse(JSON.parse(readFileSync(file, "utf8")));
36
40
  const ids = new Map(saved.map(n => [n.id, n]));
@@ -67,15 +71,54 @@ export class RoomHandoffs {
67
71
  }
68
72
  return path;
69
73
  }
74
+ /** The tree lifetime clock pauses while any node in the tree is actively
75
+ * executing: the budget bounds coordination sprawl, not the runtime of
76
+ * dispatched work. Accounting is in-memory; a restart fails every
77
+ * interrupted node anyway, so no pause span survives one. */
78
+ trackExecutionPauses() {
79
+ const now = this.now();
80
+ const executing = new Set();
81
+ for (const n of this.nodes.values()) {
82
+ if (!terminal(n) && n.status === "running")
83
+ executing.add(n.rootId);
84
+ }
85
+ for (const rootId of executing) {
86
+ const pause = this.pauses.get(rootId) ?? { accumulatedMs: 0 };
87
+ pause.since ??= now;
88
+ this.pauses.set(rootId, pause);
89
+ }
90
+ for (const [rootId, pause] of this.pauses) {
91
+ if (pause.since != null && !executing.has(rootId)) {
92
+ pause.accumulatedMs += now - pause.since;
93
+ pause.since = undefined;
94
+ }
95
+ const root = this.nodes.get(rootId);
96
+ if (!root || terminal(root))
97
+ this.pauses.delete(rootId);
98
+ }
99
+ }
100
+ pausedMs(root) {
101
+ const pause = this.pauses.get(root.id);
102
+ if (!pause)
103
+ return 0;
104
+ return pause.accumulatedMs + (pause.since != null ? this.now() - pause.since : 0);
105
+ }
106
+ /** The lifetime budget consumed so far: wall clock minus paused time. */
107
+ effectiveAgeMs(root) {
108
+ return Math.max(0, this.now() - root.createdAt - this.pausedMs(root));
109
+ }
70
110
  /** The earliest moment this node may be failed for lifetime: the tree
71
111
  * ceiling, a running node's own start plus a minimum runway, or, for work
72
- * parked in a busy teammate's queue, its own queue window (#1238). */
112
+ * parked in a busy teammate's queue, its own queue window (#1238). The
113
+ * wall-clock hard cap clamps every extension: a tree that never stops
114
+ * executing still dies, so runway extensions cannot compound forever. */
73
115
  deadline(n) {
74
116
  const anchor = n.status === "running" ? n.startedAt ?? n.createdAt : n.createdAt;
117
+ const root = this.root(n);
75
118
  const ceiling = n.status === "queued" && n.executions === 0
76
- ? anchor + ROOM_HANDOFF_LIMITS.queueMs
77
- : this.root(n).createdAt + ROOM_HANDOFF_LIMITS.lifetimeMs;
78
- return Math.max(ceiling, anchor + ROOM_HANDOFF_LIMITS.minRunwayMs);
119
+ ? anchor + this.limits.queueMs
120
+ : root.createdAt + this.limits.lifetimeMs + this.pausedMs(root);
121
+ return Math.min(Math.max(ceiling, anchor + this.limits.minRunwayMs), root.createdAt + this.limits.hardCapMs);
79
122
  }
80
123
  /** An ancestor past its ceiling is not failed while a descendant is still
81
124
  * running inside its own runway; cancelling would cascade into that work. */
@@ -98,9 +141,16 @@ export class RoomHandoffs {
98
141
  * never started reports the queue window it waited out, not the tree's. */
99
142
  lifetimeError(n) {
100
143
  if (n.status === "queued" && n.executions === 0) {
101
- return `Room handoff queue budget exhausted: never started while waiting for a busy teammate after ${duration(this.now() - n.createdAt)} of the ${duration(ROOM_HANDOFF_LIMITS.queueMs)} queue window`;
144
+ return `Room handoff queue budget exhausted: never started while waiting for a busy teammate after ${duration(this.now() - n.createdAt)} of the ${duration(this.limits.queueMs)} queue window`;
102
145
  }
103
- return `Room handoff lifetime budget exhausted: node was ${n.status} after ${duration(this.now() - this.root(n).createdAt)} of the ${duration(ROOM_HANDOFF_LIMITS.lifetimeMs)} tree lifetime`;
146
+ const root = this.root(n);
147
+ return `Room handoff lifetime budget exhausted: node was ${n.status} after ${duration(this.effectiveAgeMs(root))} of the ${duration(this.limits.lifetimeMs)} tree lifetime`;
148
+ }
149
+ /** The wall-clock ceiling ignores pauses: it is what stops a tree whose
150
+ * execution never pauses long enough to age its lifetime budget. */
151
+ hardCapError(n) {
152
+ const root = this.root(n);
153
+ return `Room handoff hard cap exhausted: node was ${n.status} after ${duration(this.now() - root.createdAt)} of the ${duration(this.limits.hardCapMs)} wall-clock cap`;
104
154
  }
105
155
  enqueue(source, generation, parentId, target, key, text, approvalGranted = false, rework = false, sourceText = "") {
106
156
  if (this.loadError)
@@ -133,17 +183,21 @@ export class RoomHandoffs {
133
183
  throw new Error("A room request cannot return to an ancestor room; results are returned automatically");
134
184
  // The path includes the source root, so its work-node count is the
135
185
  // proposed edge depth: four edges are allowed; the fifth is refused.
136
- if (kind === "work" && path.filter(n => n.kind === "work").length > ROOM_HANDOFF_LIMITS.depth)
186
+ if (kind === "work" && path.filter(n => n.kind === "work").length > this.limits.depth)
137
187
  throw new Error("Room handoff depth limit reached");
138
188
  const root = fresh ? parent : this.root(parent);
139
189
  const count = [...this.nodes.values()].filter(n => n.rootId === parent.rootId && n.parentId).length;
140
- if (count >= ROOM_HANDOFF_LIMITS.requests)
190
+ if (count >= this.limits.requests)
141
191
  throw new Error("Room handoff budget exhausted");
142
192
  // Refuse work the tree's lifetime budget cannot honestly serve: a node
143
193
  // accepted in the root's last minutes would be doomed at enqueue time.
144
- const remaining = ROOM_HANDOFF_LIMITS.lifetimeMs - (this.now() - root.createdAt);
145
- if (remaining < ROOM_HANDOFF_LIMITS.minRunwayMs) {
146
- throw new Error(`Room handoff budget exhausted: only ${duration(Math.max(remaining, 0))} of the ${duration(ROOM_HANDOFF_LIMITS.lifetimeMs)} tree lifetime remains`);
194
+ // The wall-clock hard cap ignores pauses, so the runway actually
195
+ // available is the shorter of the two remainders.
196
+ const lifetimeRemaining = this.limits.lifetimeMs - this.effectiveAgeMs(root);
197
+ const hardCapRemaining = root.createdAt + this.limits.hardCapMs - this.now();
198
+ const remaining = Math.min(lifetimeRemaining, hardCapRemaining);
199
+ if (remaining < this.limits.minRunwayMs) {
200
+ throw new Error(`Room handoff budget exhausted: only ${duration(Math.max(remaining, 0))} of the ${duration(this.limits.lifetimeMs)} tree lifetime remains`);
147
201
  }
148
202
  // Retain a bounded audit history without evicting active requests.
149
203
  if (this.nodes.size >= 1000) {
@@ -253,14 +307,18 @@ export class RoomHandoffs {
253
307
  tick() {
254
308
  if (this.loadError)
255
309
  return;
310
+ this.trackExecutionPauses();
256
311
  // Validate and expire deepest nodes first so each one is failed with its
257
312
  // own status; an ancestor's cancellation then only sweeps what is left.
313
+ // The hard cap overrides the runner and follow-up protections: it is the
314
+ // bound that stops a tree whose execution never pauses.
258
315
  for (const n of [...this.nodes.values()].reverse()) {
259
316
  if (terminal(n))
260
317
  continue;
261
318
  const error = this.hooks.validate(n, n.parentId ? this.nodes.get(n.parentId) : undefined);
262
- if (error || (this.now() > this.deadline(n) && !this.protectsRunner(n) && !this.owesFollowUp(n))) {
263
- this.cancelTree(n, error ?? this.lifetimeError(n), "failed");
319
+ const hardCapped = this.now() >= this.root(n).createdAt + this.limits.hardCapMs;
320
+ if (error || hardCapped || (this.now() > this.deadline(n) && !this.protectsRunner(n) && !this.owesFollowUp(n))) {
321
+ this.cancelTree(n, error ?? (hardCapped ? this.hardCapError(n) : this.lifetimeError(n)), "failed");
264
322
  }
265
323
  }
266
324
  for (const n of this.nodes.values()) {
@@ -292,7 +350,7 @@ export class RoomHandoffs {
292
350
  continue;
293
351
  const root = this.root(n);
294
352
  const executionCost = 1;
295
- if (root.executions + executionCost > ROOM_HANDOFF_LIMITS.executions) {
353
+ if (root.executions + executionCost > this.limits.executions) {
296
354
  this.cancelTree(n, "Room execution budget exhausted", "failed");
297
355
  continue;
298
356
  }
@@ -301,6 +359,11 @@ export class RoomHandoffs {
301
359
  root.executions += executionCost;
302
360
  n.status = "running";
303
361
  n.startedAt = this.now();
362
+ // Open the pause at the moment execution starts, not at the next tick:
363
+ // the lifetime clock must not charge the gap before observation.
364
+ const pause = this.pauses.get(root.id) ?? { accumulatedMs: 0 };
365
+ pause.since ??= this.now();
366
+ this.pauses.set(root.id, pause);
304
367
  this.publish(n, root);
305
368
  const controller = new AbortController();
306
369
  this.controllers.set(n.id, controller);
@@ -61,7 +61,7 @@ export function parseSkillSource(input) {
61
61
  return { error: "paste a GitHub repository, folder, or SKILL.md URL" };
62
62
  if (/^https?:\/\/raw\.githubusercontent\.com\/.+\/SKILL\.md$/i.test(text))
63
63
  return { rawUrl: text };
64
- const blob = text.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+SKILL\.md)$/i);
64
+ const blob = text.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/((?:.*\/)?SKILL\.md)$/i);
65
65
  if (blob) {
66
66
  return { rawUrl: `https://raw.githubusercontent.com/${blob[1]}/${blob[2]}/${blob[3]}/${blob[4]}` };
67
67
  }
@@ -180,6 +180,35 @@ export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
180
180
  }
181
181
  return false;
182
182
  }
183
+ /** Atomically lift a thread's whole queue out of the map for a live steer.
184
+ * The entry leaves first so a settle that starts draining while the adapter
185
+ * is still thinking can never also dispatch the same words as a follow-up
186
+ * turn. The caller must either restore the held queue or settle its rows. */
187
+ export function holdSteeredQueue(botId, threadId, queueId) {
188
+ const entry = queues.get(threadId);
189
+ if (!entry || entry.botId !== botId || !entry.items.some((item) => item.messageId === queueId))
190
+ return null;
191
+ queues.delete(threadId);
192
+ changed();
193
+ return { botId, threadId, items: entry.items };
194
+ }
195
+ /** Put a held queue back after the steer was refused. Words queued while the
196
+ * hold was open keep their place behind the restored items. */
197
+ export function restoreHeldSteeredQueue(held) {
198
+ const existing = queues.get(held.threadId);
199
+ if (existing && existing.botId !== held.botId)
200
+ throw new Error("queued task belongs to another bot");
201
+ queues.set(held.threadId, {
202
+ botId: held.botId,
203
+ items: existing ? [...held.items, ...existing.items] : held.items,
204
+ });
205
+ changed();
206
+ }
207
+ /** Mark a held queue's durable rows delivered: the words were folded into the
208
+ * running turn, so a restart must not replay them as a fresh follow-up. */
209
+ export function settleHeldSteeredQueue(held) {
210
+ settleChatFollowups(held.items.map((item) => item.messageId), null);
211
+ }
183
212
  /** Test helper: how many messages remain queued for a thread. */
184
213
  export function _queuedCount(threadId) {
185
214
  return queues.get(threadId)?.items.length ?? 0;
@@ -9,7 +9,7 @@ import { writeFileAtomic } from "./atomic.js";
9
9
  import { ensureSections, readSections, changeEmptySection } from "./section-context.js";
10
10
  import { removeBotFolder, soulFile, soulHash, writeSoulMirror } from "./bot-folder.js";
11
11
  import { peerAllowKey } from "./peer-approval-key.js";
12
- import { DATA_DIR, loadBrowserProfileIdAliases } from "./config.js";
12
+ import { DATA_DIR, EVENTS_DIR, NATIVE_DIR, loadBrowserProfileIdAliases } from "./config.js";
13
13
  import * as mdb from "./message-db.js";
14
14
  import { workspaceDir } from "./workspace.js";
15
15
  import { newId } from "./contracts.js";
@@ -17,6 +17,7 @@ import { pickBotName } from "./names.js";
17
17
  import { redactSecretsInText } from "./redact.js";
18
18
  import { botAvatarProfile } from "../shared/bot-avatar.js";
19
19
  import { approvalModeFor, isApprovalMode } from "../shared/approval-mode.js";
20
+ export const groupWireProjectionIsExact = true;
20
21
  // Unicode's complete emoji sequences include flags, skin tones and ZWJ
21
22
  // combinations. Also allow unqualified single symbols (e.g. ♥), but not
22
23
  // standalone components such as a digit, skin tone or regional indicator.
@@ -24,6 +25,13 @@ const projectEmojiPattern = new RegExp("^(?!\\p{Emoji_Component}$)(?:\\p{RGI_Emo
24
25
  export function isProjectEmoji(value) {
25
26
  return typeof value === "string" && value.length <= 64 && projectEmojiPattern.exec(value)?.[0] === value;
26
27
  }
28
+ export const taskWireProjectionIsExact = true;
29
+ /** The typed wire projection for one task. Pairs with the assertion above:
30
+ * returning WireTask means an undeclared server field cannot ride silently. */
31
+ export function toWireTask(task) {
32
+ const { resumeCursors: _resumeCursors, lastInstanceId: _lastInstanceId, handedMessages: _handedMessages, ...wire } = task;
33
+ return wire;
34
+ }
27
35
  const TASK_PATCH_FIELDS = [
28
36
  "title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
29
37
  "unread", "rewound", "archivedAt", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
@@ -194,6 +202,13 @@ function redactBotAuthored(message) {
194
202
  }
195
203
  return out;
196
204
  }
205
+ /** What changed, emitted by the store itself right after each write. The
206
+ * server maps these onto its SSE frames in ONE place, so no mutation path
207
+ * can persist without the app hearing about it — the two-write-paths bug
208
+ * (persist without emit → UI drifts; emit without persist → a restart
209
+ * loses what the user just watched) is closed by construction. Bot and
210
+ * group changes carry only the id: the wire shape (cursor stripping) is
211
+ * the caller's business. */
197
212
  /** The states in which the bot cannot take a new message. */
198
213
  export const ACTIVITY_BUSY = new Set(["working", "waiting-on-you", "no-signal"]);
199
214
  /** What a task is called before its first message names it. */
@@ -212,6 +227,7 @@ export function titleFromMessage(text) {
212
227
  const line = text.trim().split("\n")[0].trim();
213
228
  return line.length > 48 ? `${line.slice(0, 47)}…` : line || UNTITLED_TASK;
214
229
  }
230
+ export const botWireProjectionIsExact = true;
215
231
  const BOTS_FILE = join(DATA_DIR, "bots.json");
216
232
  const GROUPS_FILE = join(DATA_DIR, "groups.json");
217
233
  const messagesFile = (threadId) => join(DATA_DIR, `messages-${threadId}.json`);
@@ -704,11 +720,18 @@ export class Store {
704
720
  this.emit({ type: "group", groupId: group.id });
705
721
  return group;
706
722
  }
707
- /** A thread's durable record: DB rows plus any legacy JSON leftovers. */
723
+ /** A thread's durable record: DB rows, legacy JSON leftovers, and the
724
+ * per-thread event logs. Every delete path funnels here — task, group,
725
+ * and bot deletion — so the logs cannot outlive the thread anywhere. */
708
726
  deleteThreadRecord(threadId) {
709
727
  this.threads.delete(threadId);
710
728
  mdb.deleteThread(threadId);
711
- for (const file of [messagesFile(threadId), `${messagesFile(threadId)}.imported`]) {
729
+ for (const file of [
730
+ messagesFile(threadId),
731
+ `${messagesFile(threadId)}.imported`,
732
+ join(EVENTS_DIR, `${threadId}.ndjson`),
733
+ join(NATIVE_DIR, `${threadId}.ndjson`),
734
+ ]) {
712
735
  try {
713
736
  unlinkSync(file);
714
737
  }
@@ -1057,6 +1080,13 @@ export class Store {
1057
1080
  t.activeLeafId = full.id;
1058
1081
  mdb.appendMessage(threadId, full);
1059
1082
  this.emit({ type: "message", threadId, message: full });
1083
+ // The message frame alone leaves every client on the OLD branch: a
1084
+ // client adopts a new message as its leaf only when it chains onto the
1085
+ // current leaf, and this one is a sibling of the edited message, not a
1086
+ // child of the reply. Say where the conversation now points, as
1087
+ // setActiveLeaf does, or the edit shows only after the next full bot
1088
+ // snapshot — in practice, once the reply has arrived.
1089
+ this.emit({ type: "thread", threadId, activeLeafId: full.id });
1060
1090
  return full;
1061
1091
  }
1062
1092
  /** Point the visible conversation at the branch containing `messageId`,
@@ -1480,6 +1510,16 @@ export class Store {
1480
1510
  task.lastInstanceId = instanceId;
1481
1511
  this.saveBots();
1482
1512
  }
1513
+ setHandedMessages(botId, threadId, instanceId, state) {
1514
+ const task = this.taskByThread(botId, threadId);
1515
+ if (!task || JSON.stringify(task.handedMessages?.[instanceId]) === JSON.stringify(state))
1516
+ return;
1517
+ // Other instances keep a record only while it still describes their session.
1518
+ const live = Object.entries(task.handedMessages ?? {})
1519
+ .filter(([id, record]) => id !== instanceId && record.session !== undefined && record.session === task.resumeCursors[id]);
1520
+ task.handedMessages = { ...Object.fromEntries(live), [instanceId]: state };
1521
+ this.saveBots();
1522
+ }
1483
1523
  /** Bank one settled turn onto its task. Called once per turn.completed;
1484
1524
  * the running per-driver token indicator is deliberately not used here
1485
1525
  * because its meaning differs by driver. */
@@ -0,0 +1,50 @@
1
+ // Retention for per-thread event logs (#1280).
2
+ //
3
+ // The events/ and native/ NDJSON logs are the bulk of on-disk data: they
4
+ // outgrow thread metadata by orders of magnitude and nothing bounded them.
5
+ // This sweep removes the log files of threads that have been idle — closed
6
+ // or archived — longer than a configured window. It never deletes
7
+ // transcripts, thread records, or workspace state, and it never touches a
8
+ // thread that is busy, unread, or carrying an open direct handoff. Group
9
+ // threads carry no close/archive stamp today, so they are out of scope
10
+ // until they grow one.
11
+ import { unlinkSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { EVENTS_DIR, NATIVE_DIR } from "./config.js";
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+ /** The newest close/archive stamp decides: a thread picked back up and
16
+ * closed again must wait out the full window from its newest stamp. */
17
+ function idleSince(candidate) {
18
+ const stamps = [candidate.closedAt, candidate.archivedAt].filter((value) => value !== null);
19
+ return stamps.length ? Math.max(...stamps) : null;
20
+ }
21
+ /** Remove the event logs of every candidate idle longer than retentionDays.
22
+ * Returns how many threads had logs removed. Missing files simply leave
23
+ * nothing to do — an already-swept thread is not counted again. */
24
+ export function sweepThreadEventLogs(candidates, retentionDays, now = Date.now()) {
25
+ const cutoff = now - retentionDays * DAY_MS;
26
+ let swept = 0;
27
+ for (const candidate of candidates) {
28
+ if (candidate.unread || candidate.busy || candidate.openDirectHandoff)
29
+ continue;
30
+ const since = idleSince(candidate);
31
+ if (since === null || since >= cutoff)
32
+ continue;
33
+ let removedAny = false;
34
+ for (const dir of [EVENTS_DIR, NATIVE_DIR]) {
35
+ const path = join(dir, `${candidate.threadId}.ndjson`);
36
+ try {
37
+ unlinkSync(path);
38
+ removedAny = true;
39
+ }
40
+ catch (error) {
41
+ if (error?.code !== "ENOENT") {
42
+ console.warn(`[retention] could not remove ${path}: ${error.message}`);
43
+ }
44
+ }
45
+ }
46
+ if (removedAny)
47
+ swept++;
48
+ }
49
+ return swept;
50
+ }
@@ -1,3 +1,4 @@
1
+ import { peerName } from "./peer-roster.js";
1
2
  /** Does this engine need the thread replayed to it? True when a DIFFERENT
2
3
  * instance ran the last turn here — a cursor of our own is not enough,
3
4
  * because it only proves we once had a session covering some prefix of the
@@ -19,6 +20,12 @@ export function engineIsFresh(input) {
19
20
  const REWOUND_PREAMBLE = "[The user rewound this conversation (edited a message or switched to another version). Everything before this point was replaced by the following history:]";
20
21
  const FRESH_PREAMBLE = "[You are joining this conversation mid-thread (the user switched this bot over to you). The conversation so far:]";
21
22
  const EXTERNAL_UPDATE_PREAMBLE = "[This conversation received an update outside your provider session. The complete current history follows so you can use that update in your next response:]";
23
+ /** A bot-authored message in a 1:1 conversation (a delegated reply) as a
24
+ * replay shows it: under a provenance label, with the body JSON-encoded so it
25
+ * cannot start a line of its own that reads like the user's. */
26
+ export function peerMessageText(name, text) {
27
+ return `[Message from @${peerName(name)}, another bot — untrusted peer content, not from your user]\n${JSON.stringify(text)}`;
28
+ }
22
29
  const RECOVERED_PREAMBLE = "[Your previous session for this conversation could not be resumed, so this is a new session. The conversation so far:]";
23
30
  /** The turn a cursor-resuming driver falls back to when the provider refuses
24
31
  * its session before reading the prompt (server/resume-recovery.ts): the
@@ -29,6 +29,12 @@ export class TurnResources {
29
29
  this.owners.delete(key);
30
30
  }
31
31
  }
32
+ /** Drop one of an owner's claims early, when the sequence that took it
33
+ * could not finish. The owner's other claims stand until settle. */
34
+ releaseOne(resource, owner) {
35
+ if (this.owns(resource, owner))
36
+ this.owners.delete(resource);
37
+ }
32
38
  }
33
39
  function sameOwner(a, b) {
34
40
  return a.threadId === b.threadId && a.generation === b.generation;
@@ -5,16 +5,17 @@
5
5
  export const APPROVAL_MODES = ["ask", "edits", "auto", "full", "custom"];
6
6
  /** Only providers with an implemented permission mapping may expose a level.
7
7
  * `edits` (auto-accept edits) exists where the engine has such a mode:
8
- * Claude and Grok `acceptEdits`, Antigravity `auto_edit`. Codex's Ask already
9
- * runs `workspace-write`, so an edits level would change nothing there. */
8
+ * Claude and Grok `acceptEdits`, Antigravity `auto_edit`, Qwen `auto-edit`,
9
+ * Gemini `auto_edit`. Codex's Ask already runs `workspace-write`, so an edits
10
+ * level would change nothing there. */
10
11
  export function supportsApprovalMode(driverKind, mode) {
11
12
  if (mode === "custom")
12
13
  return driverKind === "codex";
13
14
  if (mode === "edits")
14
- return ["claudeAgent", "grokAgent", "antigravityAgent"].includes(driverKind ?? "");
15
+ return ["claudeAgent", "grokAgent", "antigravityAgent", "qwenAgent", "geminiAgent"].includes(driverKind ?? "");
15
16
  if (mode !== "full")
16
17
  return true;
17
- return ["codex", "claudeAgent", "antigravityAgent", "cursorAgent", "grokAgent", "opencodeGo"].includes(driverKind ?? "");
18
+ return ["codex", "claudeAgent", "antigravityAgent", "cursorAgent", "grokAgent", "opencodeGo", "qwenAgent", "geminiAgent"].includes(driverKind ?? "");
18
19
  }
19
20
  /** A Full/Custom grant belongs to one provider's tool semantics. Other
20
21
  * modes carry across only when the destination actually implements them.
@@ -24,7 +25,9 @@ export function modelSwitchNeedsAsk(mode, fromDriver, toDriver) {
24
25
  ((mode === "full" || mode === "custom") && fromDriver !== toDriver);
25
26
  }
26
27
  export function hasNativeAutoReview(driverKind) {
27
- return ["codex", "claudeAgent", "cursorAgent", "grokAgent"].includes(driverKind ?? "");
28
+ // Qwen Code's `--approval-mode auto` is an LLM classifier that approves
29
+ // safe actions and blocks risky ones — a reviewer, not a rubber stamp.
30
+ return ["codex", "claudeAgent", "cursorAgent", "grokAgent", "qwenAgent"].includes(driverKind ?? "");
28
31
  }
29
32
  export function isApprovalMode(value) {
30
33
  return typeof value === "string" && APPROVAL_MODES.includes(value);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};