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.
- package/dist/assets/index-BbU5REzd.js +310 -0
- package/dist/assets/{index-CKysBq-V.css → index-CLGfYlx_.css} +1 -1
- package/dist/assets/{index-5HVwp5m2.js → index-DNa2umw-.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +20 -4
- package/dist-server/drivers/agents-proxy.js +30 -4
- package/dist-server/index.js +1716 -581
- package/dist-server/mcp-gate.js +2 -1
- package/dist-server/openmausbot.js +224 -63
- package/dist-server/pair-cli.js +224 -63
- package/dist-server/server/auto-approve.js +15 -1
- package/dist-server/server/auto-vm-claims.js +22 -0
- package/dist-server/server/browser-bundle-release.js +8 -7
- package/dist-server/server/browser-engine.js +1 -1
- package/dist-server/server/channel-queue.js +58 -0
- package/dist-server/server/checkpoints.js +9 -5
- package/dist-server/server/chief-of-staff.js +5 -2
- package/dist-server/server/computer-wait.js +33 -0
- package/dist-server/server/config.js +58 -3
- package/dist-server/server/contracts.js +0 -7
- package/dist-server/server/delta-context.js +258 -0
- package/dist-server/server/drivers/acp/gemini.js +11 -1
- package/dist-server/server/drivers/acp/qwen.js +16 -1
- package/dist-server/server/drivers/agents-proxy.js +32 -4
- package/dist-server/server/drivers/claude.js +26 -8
- package/dist-server/server/drivers/codex.js +79 -6
- package/dist-server/server/drivers/pi.js +2 -1
- package/dist-server/server/env-path.js +6 -1
- package/dist-server/server/incidents.js +104 -0
- package/dist-server/server/index.js +886 -134
- package/dist-server/server/local-routing.js +10 -4
- package/dist-server/server/notify.js +3 -1
- package/dist-server/server/peer-roster.js +37 -1
- package/dist-server/server/recent-work.js +4 -1
- package/dist-server/server/redact.js +5 -48
- package/dist-server/server/room-handoffs.js +79 -16
- package/dist-server/server/skill-fetch.js +1 -1
- package/dist-server/server/steer-queue.js +29 -0
- package/dist-server/server/store.js +43 -3
- package/dist-server/server/thread-retention.js +50 -0
- package/dist-server/server/turn-context.js +7 -0
- package/dist-server/server/turn-resources.js +6 -0
- package/dist-server/shared/approval-mode.js +8 -5
- package/dist-server/shared/inspector.js +1 -0
- package/dist-server/shared/json.js +1 -0
- package/dist-server/shared/notification.js +1 -0
- package/dist-server/shared/redact.js +61 -0
- package/dist-server/shared/routines.js +1 -0
- package/dist-server/shared/runtime-events.js +1 -0
- package/dist-server/shared/webhooks.js +1 -0
- package/dist-server/shared/wire.js +8 -0
- package/dist-server/vps-container-mcp.js +20 -4
- package/package.json +1 -1
- 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
|
|
6
|
-
// Preserve the established macOS Auto behavior
|
|
7
|
-
// beta and can only be selected explicitly per bot.
|
|
8
|
-
return requested === undefined && hostPlatform
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 +
|
|
77
|
-
:
|
|
78
|
-
return Math.max(ceiling, anchor +
|
|
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(
|
|
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
|
-
|
|
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 >
|
|
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 >=
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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 >
|
|
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\/([^/]+)\/(
|
|
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
|
|
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 [
|
|
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
|
|
9
|
-
* runs `workspace-write`, so an edits
|
|
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
|
-
|
|
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 {};
|