openmausbot 0.1.76 → 0.1.78
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-C6blGSq5.js → index-BqDJqf2C.js} +1 -1
- package/dist/assets/index-DjwAroIZ.js +305 -0
- package/dist/assets/index-Pbb6Ao0s.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +5 -1
- package/dist-server/drivers/agents-proxy.js +838 -30
- package/dist-server/enterprise/server/index.js +387 -29
- package/dist-server/index.js +4097 -2063
- package/dist-server/openmausbot.js +730 -523
- package/dist-server/pair-cli.js +730 -523
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/auto-approve.js +16 -0
- package/dist-server/server/bot-overview.js +6 -2
- package/dist-server/server/bot-package.js +17 -3
- package/dist-server/server/browser-live.js +3 -0
- package/dist-server/server/chief-of-staff.js +2 -2
- package/dist-server/server/config.js +16 -0
- package/dist-server/server/drivers/acp/core.js +3 -1
- package/dist-server/server/drivers/agents-proxy.js +132 -27
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +3 -2
- package/dist-server/server/drivers/codex.js +3 -1
- package/dist-server/server/drivers/pi.js +4 -0
- package/dist-server/server/enterprise.js +38 -0
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/fleet-agent.js +57 -13
- package/dist-server/server/fleet-cli.js +248 -31
- package/dist-server/server/fleet.js +88 -14
- package/dist-server/server/hosted-contract.js +16 -0
- package/dist-server/server/index.js +877 -197
- package/dist-server/server/message-file.js +4 -0
- package/dist-server/server/package-export.js +16 -14
- package/dist-server/server/peer-approval.js +1 -1
- package/dist-server/server/profile-requests.js +26 -4
- package/dist-server/server/provider-key-check.js +18 -1
- package/dist-server/server/request-auth.js +14 -5
- package/dist-server/server/room-handoffs.js +38 -1
- package/dist-server/server/routine-requests.js +86 -3
- package/dist-server/server/routines.js +31 -13
- package/dist-server/server/sessions.js +115 -8
- package/dist-server/server/setup-mode.js +11 -13
- package/dist-server/server/shared-computer-control.js +24 -0
- package/dist-server/server/shared-computers.js +131 -0
- package/dist-server/server/skill-learn.js +4 -4
- package/dist-server/server/skills.js +21 -0
- package/dist-server/server/steer-queue.js +2 -0
- package/dist-server/server/store.js +110 -8
- package/dist-server/server/system-prompt.js +7 -5
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-computers.js +108 -0
- package/dist-server/server/team-setup-requests.js +83 -31
- package/dist-server/server/tool-summary.js +53 -1
- package/dist-server/server/workspace-backup.js +1 -0
- package/dist-server/shared/approval-mode.js +4 -1
- package/dist-server/shared/cron-label.js +39 -0
- package/dist-server/shared/routine-schedule.js +89 -0
- package/dist-server/shared/team-backup.js +1 -1
- package/dist-server/shared/team-computer.js +1 -0
- package/dist-server/vps-container-mcp.js +5 -1
- package/enterprise/README.md +21 -3
- package/enterprise/server/index.js +387 -29
- package/package.json +1 -1
- package/dist/assets/index-BtT047dk.css +0 -1
- package/dist/assets/index-fKXFpB_J.js +0 -305
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
// endpoint, because EventSource cannot set headers. Failed exchanges are
|
|
10
10
|
// counted per source: five in a minute lock that source out for ten.
|
|
11
11
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
12
|
-
import {
|
|
12
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, unlinkSync } from "node:fs";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
15
16
|
export const SCOPES = ["admin", "client"];
|
|
16
17
|
export const PAIRING_CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
17
18
|
export const PAIRING_CODE_LENGTH = 12;
|
|
@@ -69,6 +70,8 @@ const sessionSchema = z.object({
|
|
|
69
70
|
/** Set when the session came from an account sign-in rather than a code. */
|
|
70
71
|
userId: z.string().max(256).optional(),
|
|
71
72
|
email: z.string().max(320).optional(),
|
|
73
|
+
/** Set only by the internal verified-portal issuance path, never by email or pairing input. */
|
|
74
|
+
membershipAuthority: z.literal("portal").optional(),
|
|
72
75
|
});
|
|
73
76
|
const fileSchema = z.object({ version: z.literal(1), sessions: z.array(sessionSchema) });
|
|
74
77
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
@@ -127,14 +130,29 @@ export class SessionRegistry {
|
|
|
127
130
|
lastSeenWrites = new Map();
|
|
128
131
|
now;
|
|
129
132
|
options;
|
|
133
|
+
openMarker;
|
|
134
|
+
closed = false;
|
|
130
135
|
// No parameter properties: the server runs this file under Node's
|
|
131
136
|
// strip-only TypeScript mode, which only erases types.
|
|
132
137
|
constructor(options) {
|
|
133
138
|
this.options = options;
|
|
134
139
|
this.now = options.now ?? Date.now;
|
|
135
|
-
this.
|
|
140
|
+
this.openMarker = `${options.file}.open`;
|
|
141
|
+
const unclean = existsSync(this.openMarker);
|
|
142
|
+
try {
|
|
143
|
+
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
144
|
+
// Establish the guard before accepting any persisted bearer. Even if
|
|
145
|
+
// every subsequent write fails, an unclean restart will see this marker.
|
|
146
|
+
writeFileAtomic(this.openMarker, "Session registry is open.\n", { mode: 0o600 });
|
|
147
|
+
this.syncDirectory();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Error("Could not establish safe session storage.");
|
|
151
|
+
}
|
|
152
|
+
this.load(!unclean);
|
|
153
|
+
this.revalidateEmailSessions();
|
|
136
154
|
}
|
|
137
|
-
load() {
|
|
155
|
+
load(restoreAccounts) {
|
|
138
156
|
if (!existsSync(this.options.file))
|
|
139
157
|
return;
|
|
140
158
|
let raw;
|
|
@@ -146,18 +164,50 @@ export class SessionRegistry {
|
|
|
146
164
|
}
|
|
147
165
|
const parsed = fileSchema.safeParse(raw);
|
|
148
166
|
if (parsed.success)
|
|
149
|
-
this.sessions = parsed.data.sessions;
|
|
167
|
+
this.sessions = parsed.data.sessions.filter(session => restoreAccounts || (session.email === undefined && session.userId === undefined));
|
|
168
|
+
}
|
|
169
|
+
syncDirectory() {
|
|
170
|
+
// Windows does not expose directory fsync. The open marker still guards
|
|
171
|
+
// normal process crashes; no stronger power-loss guarantee is claimed.
|
|
172
|
+
if (process.platform === "win32")
|
|
173
|
+
return;
|
|
174
|
+
const fd = openSync(dirname(this.options.file), "r");
|
|
175
|
+
try {
|
|
176
|
+
fsyncSync(fd);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
closeSync(fd);
|
|
180
|
+
}
|
|
150
181
|
}
|
|
151
182
|
/** Atomic write, owner-only, directory owner-only. */
|
|
152
183
|
persist() {
|
|
184
|
+
if (this.closed)
|
|
185
|
+
throw new Error("Session registry is closed.");
|
|
153
186
|
const dir = dirname(this.options.file);
|
|
154
187
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
writeFileAtomic(this.options.file, JSON.stringify({ version: 1, sessions: this.sessions }, null, 2) + "\n", { mode: 0o600 });
|
|
189
|
+
this.syncDirectory();
|
|
190
|
+
}
|
|
191
|
+
/** Call after HTTP/stream shutdown. A failed close leaves the open marker,
|
|
192
|
+
* so account sessions require fresh sign-in after restart. Paired devices
|
|
193
|
+
* remain durable. Never remove the marker before the reduced set is saved. */
|
|
194
|
+
close() {
|
|
195
|
+
if (this.closed)
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
this.revalidateEmailSessions();
|
|
199
|
+
this.persist();
|
|
200
|
+
unlinkSync(this.openMarker);
|
|
201
|
+
this.closed = true;
|
|
202
|
+
// A marker deletion lost on power failure causes extra sign-in only;
|
|
203
|
+
// the session file and its rename were fsynced before removal.
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
throw new Error("Could not safely close session storage.");
|
|
207
|
+
}
|
|
159
208
|
}
|
|
160
209
|
prune() {
|
|
210
|
+
this.revalidateEmailSessions();
|
|
161
211
|
const now = this.now();
|
|
162
212
|
this.pairings = this.pairings.filter((p) => p.expiresAt > now);
|
|
163
213
|
this.replays = this.replays.filter((r) => r.expiresAt > now);
|
|
@@ -293,6 +343,16 @@ export class SessionRegistry {
|
|
|
293
343
|
/** A session from a verified account sign-in (server/account-signin.ts)
|
|
294
344
|
* rather than a pairing code: same token, same term, same gates. */
|
|
295
345
|
issue(input) {
|
|
346
|
+
return this.issueAccount(input);
|
|
347
|
+
}
|
|
348
|
+
/** Internal hosted-bridge seam: call only after consuming a verified,
|
|
349
|
+
* workspace-bound PKCE grant. No HTTP route accepts this marker as input. */
|
|
350
|
+
issuePortal(input) {
|
|
351
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(input.grant) || !input.email)
|
|
352
|
+
throw new Error("A verified portal identity is required");
|
|
353
|
+
return this.issueAccount({ label: "Hosted workspace", email: input.email, userId: `portal:${input.grant}`, scopes: input.scopes }, "portal");
|
|
354
|
+
}
|
|
355
|
+
issueAccount(input, membershipAuthority) {
|
|
296
356
|
this.prune();
|
|
297
357
|
const now = this.now();
|
|
298
358
|
const token = `omb_sess_${randomBytes(32).toString("base64url")}`;
|
|
@@ -309,6 +369,8 @@ export class SessionRegistry {
|
|
|
309
369
|
record.userId = input.userId;
|
|
310
370
|
if (input.email)
|
|
311
371
|
record.email = input.email;
|
|
372
|
+
if (membershipAuthority)
|
|
373
|
+
record.membershipAuthority = membershipAuthority;
|
|
312
374
|
this.sessions.push(record);
|
|
313
375
|
this.lastSeenWrites.set(record.id, now);
|
|
314
376
|
this.persist();
|
|
@@ -326,9 +388,53 @@ export class SessionRegistry {
|
|
|
326
388
|
this.failures.delete(source);
|
|
327
389
|
}
|
|
328
390
|
// ── sessions ───────────────────────────────────────────────────────────
|
|
391
|
+
/** Email membership is live, not a thirty-day grant. Losing any issued
|
|
392
|
+
* scope revokes the token and its streams; signing in again obtains the
|
|
393
|
+
* new role. Promotions never widen existing credentials. Pairing sessions
|
|
394
|
+
* have no email and remain independent of the hosted sign-in list. */
|
|
395
|
+
revalidateEmailSessions() {
|
|
396
|
+
if (this.closed)
|
|
397
|
+
throw new Error("Session registry is closed.");
|
|
398
|
+
const eligible = this.sessions.filter(session => session.email !== undefined && !(this.options.portalMembership && session.membershipAuthority === "portal"));
|
|
399
|
+
if (!eligible.length)
|
|
400
|
+
return;
|
|
401
|
+
let resolveScopes = this.options.emailScopes;
|
|
402
|
+
try {
|
|
403
|
+
if (this.options.emailScopesSnapshot)
|
|
404
|
+
resolveScopes = this.options.emailScopesSnapshot();
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
resolveScopes = undefined; // Never fall back to stale membership after a failed snapshot.
|
|
408
|
+
}
|
|
409
|
+
const revoked = eligible.filter((session) => {
|
|
410
|
+
try {
|
|
411
|
+
const allowed = resolveScopes?.(session.email);
|
|
412
|
+
return !allowed || session.scopes.some((scope) => !allowed.includes(scope));
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return true; // missing or unreadable membership must fail closed
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
if (!revoked.length)
|
|
419
|
+
return;
|
|
420
|
+
const ids = new Set(revoked.map((session) => session.id));
|
|
421
|
+
this.sessions = this.sessions.filter((session) => !ids.has(session.id));
|
|
422
|
+
for (const session of revoked)
|
|
423
|
+
this.forget(session.id);
|
|
424
|
+
try {
|
|
425
|
+
this.persist();
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Revocation stays effective in memory and streams close. The boot
|
|
429
|
+
// marker prevents old account bearers from reviving after an unclean
|
|
430
|
+
// restart, even if membership is later restored and all writes failed.
|
|
431
|
+
console.error("Could not persist email session revocation.");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
329
434
|
authenticate(token) {
|
|
330
435
|
if (!token)
|
|
331
436
|
return null;
|
|
437
|
+
this.revalidateEmailSessions();
|
|
332
438
|
const hash = sha256(token);
|
|
333
439
|
const now = this.now();
|
|
334
440
|
const record = this.sessions.find((s) => sameDigest(s.tokenHash, hash));
|
|
@@ -349,6 +455,7 @@ export class SessionRegistry {
|
|
|
349
455
|
* Nothing expired is revived. Writes at most once per half-term, so it
|
|
350
456
|
* adds nothing to the last-seen traffic. Returns whether it renewed. */
|
|
351
457
|
renew(sessionId) {
|
|
458
|
+
this.revalidateEmailSessions();
|
|
352
459
|
const record = this.sessions.find((s) => s.id === sessionId);
|
|
353
460
|
const now = this.now();
|
|
354
461
|
if (!record || record.expiresAt <= now)
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
// Setup mode: optional coaching when the user asks for it with /setup.
|
|
2
2
|
// An empty profile is not a request to interview the user. The bot says
|
|
3
|
-
// what it intends, and then configures itself
|
|
4
|
-
// (propose_profile, propose_routine, skill_manage, request_credential)
|
|
5
|
-
//
|
|
3
|
+
// what it intends, and then configures itself through the harness tools
|
|
4
|
+
// (propose_profile, propose_routine, skill_manage, request_credential), which
|
|
5
|
+
// enforce the granted approval level. Mirrors skill-learn.ts:
|
|
6
6
|
// /setup is a turn-text rewrite plus a prompt block, never a hidden mode.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
// the
|
|
10
|
-
//
|
|
11
|
-
// typed, and nothing about the bot actually changes until a card is
|
|
12
|
-
// confirmed. And like /learn, only the current turn's text is rewritten —
|
|
8
|
+
// This coaching block grants no additional authority: the services still
|
|
9
|
+
// check the actual caller, target and effective conversation permissions.
|
|
10
|
+
// Like /learn, only the current turn's text is rewritten —
|
|
13
11
|
// the transcript keeps the raw "/setup …" user message verbatim, so replay
|
|
14
12
|
// and history read the same thing the user sent.
|
|
15
13
|
const SETUP_COMMAND = /^\/setup(?:\s+|$)([\s\S]*)$/i;
|
|
@@ -47,11 +45,11 @@ function folderClause(cwd) {
|
|
|
47
45
|
function buildSetupPrompt(profileAside, cwd) {
|
|
48
46
|
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." +
|
|
49
47
|
` 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)}.` +
|
|
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.
|
|
51
|
-
"
|
|
52
|
-
`
|
|
53
|
-
"
|
|
54
|
-
" Finish by saying exactly what remains for the user to do by hand — authorizing an app or account (OAuth), creating a third-party application or bot token, or
|
|
48
|
+
" 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. Ask for missing choices, not an extra yes for already-requested actions under granted Full Access." +
|
|
49
|
+
" First send one message describing the changes you are about to request, then make the tool calls. Follow each result: if applied, continue without another confirmation; if pending, end the turn and wait for its in-app decision. Do not repeat the list or claim success from the permission mode alone." +
|
|
50
|
+
` Use propose_profile for your identity, standing rules ${profileAside}, and the working folder (cwd), propose_routine only for a schedule the user requested, and request_credential for any missing token.` +
|
|
51
|
+
" Full Access does not supply answers, credentials, or broader permissions for another bot. A credential request still needs the user's secure entry. Report failed or cancelled changes honestly." +
|
|
52
|
+
" Finish by saying exactly what remains for the user to do by hand — authorizing an app or account (OAuth), creating a third-party application or bot token, or deciding a pending review — and point them to the Access section of the bot's settings for the app connections.");
|
|
55
53
|
}
|
|
56
54
|
/** The setup block naming skill_manage, for a turn with skill authoring on. */
|
|
57
55
|
export const SETUP_PROMPT = buildSetupPrompt(SKILL_MANAGE_ASIDE);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A local desktop call competes with ordinary bot turns for the same screen.
|
|
2
|
+
* Leases expire if Electron crashes; human takeover always wins renewal. */
|
|
3
|
+
export class SharedComputerControl {
|
|
4
|
+
leases = new Map();
|
|
5
|
+
resources;
|
|
6
|
+
held;
|
|
7
|
+
constructor(resources, held) { this.resources = resources; this.held = held; }
|
|
8
|
+
acquire(id) {
|
|
9
|
+
const owner = { threadId: `shared:${id}`, generation: id };
|
|
10
|
+
if (this.held() || !this.resources.claim("computer:host", owner)) {
|
|
11
|
+
this.release(id);
|
|
12
|
+
throw Object.assign(new Error("This computer is in use locally or held by a person. Wait, then observe it again before acting."), { status: 409 });
|
|
13
|
+
}
|
|
14
|
+
clearTimeout(this.leases.get(id));
|
|
15
|
+
this.leases.set(id, setTimeout(() => this.release(id), 40_000));
|
|
16
|
+
}
|
|
17
|
+
release(id) {
|
|
18
|
+
clearTimeout(this.leases.get(id));
|
|
19
|
+
this.leases.delete(id);
|
|
20
|
+
this.resources.release({ threadId: `shared:${id}`, generation: id });
|
|
21
|
+
}
|
|
22
|
+
close() { for (const id of this.leases.keys())
|
|
23
|
+
this.release(id); }
|
|
24
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const folder = z.object({ id: z.string().uuid(), name: z.string().min(1).max(120), write: z.boolean() }).strict();
|
|
4
|
+
export const sharedComputerRegistration = z.object({
|
|
5
|
+
id: z.string().uuid(), name: z.string().min(1).max(120), environmentId: z.string().uuid(),
|
|
6
|
+
folders: z.array(folder).max(20), terminal: z.boolean(), computer: z.boolean(),
|
|
7
|
+
}).strict();
|
|
8
|
+
export const sharedComputerOperation = z.object({
|
|
9
|
+
computer_id: z.string().uuid(),
|
|
10
|
+
action: z.enum(["list_files", "read_file", "write_file", "run_command", "computer_tools", "computer_call"]),
|
|
11
|
+
folder_id: z.string().uuid().optional(), path: z.string().max(2048).optional(),
|
|
12
|
+
content: z.string().max(350_000).optional(), encoding: z.enum(["utf8", "base64"]).optional(),
|
|
13
|
+
expected_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
14
|
+
command: z.string().max(8000).optional(), tool_name: z.string().max(100).optional(),
|
|
15
|
+
arguments: z.record(z.string(), z.unknown()).optional(),
|
|
16
|
+
}).strict();
|
|
17
|
+
const failure = (message, status = 409) => Object.assign(new Error(message), { status });
|
|
18
|
+
/** In-memory rendezvous only. Authority over local resources stays on the
|
|
19
|
+
* desktop. Restart/disconnect NEVER replays an operation with an unknown outcome. */
|
|
20
|
+
export class SharedComputers {
|
|
21
|
+
computers = new Map();
|
|
22
|
+
sessionLive;
|
|
23
|
+
constructor(sessionLive) { this.sessionLive = sessionLive; }
|
|
24
|
+
register(registration, owner, secret) {
|
|
25
|
+
if (!/^[a-f0-9]{64}$/.test(secret))
|
|
26
|
+
throw failure("Invalid computer credential", 400);
|
|
27
|
+
const old = this.computers.get(registration.id);
|
|
28
|
+
if (old) {
|
|
29
|
+
this.authorize(registration.id, owner, secret);
|
|
30
|
+
old.registration = registration;
|
|
31
|
+
old.seen = Date.now();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// Reap dead devices before applying the bounded registration limit.
|
|
35
|
+
for (const [id, entry] of this.computers)
|
|
36
|
+
if (!this.online(entry) && entry.jobs.size === 0)
|
|
37
|
+
this.computers.delete(id);
|
|
38
|
+
if (this.computers.size >= 20)
|
|
39
|
+
throw failure("Too many shared computers");
|
|
40
|
+
this.computers.set(registration.id, { registration, owner, secret, seen: Date.now(), jobs: new Map() });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
online(entry) { return this.sessionLive(entry.owner) && Date.now() - entry.seen < 40_000; }
|
|
44
|
+
authorize(id, owner, secret) {
|
|
45
|
+
const entry = this.computers.get(id);
|
|
46
|
+
if (!entry || entry.owner !== owner || !this.sessionLive(owner) || !/^[a-f0-9]{64}$/.test(secret) || !timingSafeEqual(Buffer.from(secret), Buffer.from(entry.secret)))
|
|
47
|
+
throw failure("Shared computer is disconnected or not authorized", 403);
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
list() { return [...this.computers.values()].filter(entry => this.online(entry)).map(entry => entry.registration); }
|
|
51
|
+
async poll(id, owner, secret) {
|
|
52
|
+
const entry = this.authorize(id, owner, secret);
|
|
53
|
+
if (entry.wake)
|
|
54
|
+
throw failure("A computer poll is already running");
|
|
55
|
+
entry.seen = Date.now();
|
|
56
|
+
const next = () => {
|
|
57
|
+
for (const job of entry.jobs.values()) {
|
|
58
|
+
if (!job.active()) {
|
|
59
|
+
this.finish(entry, job, failure("The requesting turn ended"));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!job.sent) {
|
|
63
|
+
job.sent = true;
|
|
64
|
+
return { id: job.id, operation: job.operation };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
const queued = next();
|
|
70
|
+
if (queued)
|
|
71
|
+
return queued;
|
|
72
|
+
await new Promise(resolve => {
|
|
73
|
+
const timer = setTimeout(() => { entry.wake = undefined; resolve(); }, 20_000);
|
|
74
|
+
entry.wake = () => { clearTimeout(timer); entry.wake = undefined; resolve(); };
|
|
75
|
+
});
|
|
76
|
+
this.authorize(id, owner, secret);
|
|
77
|
+
entry.seen = Date.now();
|
|
78
|
+
return next();
|
|
79
|
+
}
|
|
80
|
+
liveJob(id, owner, secret, jobId) {
|
|
81
|
+
const entry = this.authorize(id, owner, secret);
|
|
82
|
+
entry.seen = Date.now();
|
|
83
|
+
const job = entry.jobs.get(jobId);
|
|
84
|
+
return job?.sent === true && job.active();
|
|
85
|
+
}
|
|
86
|
+
complete(id, owner, secret, jobId, result) {
|
|
87
|
+
const entry = this.authorize(id, owner, secret);
|
|
88
|
+
const job = entry.jobs.get(jobId);
|
|
89
|
+
if (!job || !job.sent)
|
|
90
|
+
throw failure("This computer operation expired; it will not be replayed");
|
|
91
|
+
this.finish(entry, job, job.active() ? null : failure("The requesting turn ended"), result);
|
|
92
|
+
}
|
|
93
|
+
disconnect(id, owner, secret) {
|
|
94
|
+
const entry = this.authorize(id, owner, secret);
|
|
95
|
+
this.computers.delete(id);
|
|
96
|
+
entry.wake?.();
|
|
97
|
+
for (const job of entry.jobs.values())
|
|
98
|
+
this.finish(entry, job, failure("Computer disconnected. An in-flight action may have completed; inspect before retrying."));
|
|
99
|
+
}
|
|
100
|
+
request(operation, active) {
|
|
101
|
+
const entry = this.computers.get(operation.computer_id);
|
|
102
|
+
if (!entry || !this.online(entry))
|
|
103
|
+
return Promise.reject(failure("This shared computer is offline. Keep its desktop app open; do not substitute files on the server."));
|
|
104
|
+
if (entry.jobs.size >= 1)
|
|
105
|
+
return Promise.reject(failure("This shared computer is busy. Wait for its current action to finish."));
|
|
106
|
+
if (!active())
|
|
107
|
+
return Promise.reject(failure("The requesting turn ended", 401));
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const id = randomUUID();
|
|
110
|
+
const job = { id, operation, active, sent: false, resolve, reject, timer: setTimeout(() => this.finish(entry, job, failure("Computer action timed out. Its outcome is unknown; inspect before retrying.")), 45_000) };
|
|
111
|
+
entry.jobs.set(id, job);
|
|
112
|
+
entry.wake?.();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
close() {
|
|
116
|
+
for (const entry of this.computers.values()) {
|
|
117
|
+
entry.wake?.();
|
|
118
|
+
for (const job of entry.jobs.values())
|
|
119
|
+
this.finish(entry, job, failure("Workspace stopped. Inspect any in-flight action before retrying."));
|
|
120
|
+
}
|
|
121
|
+
this.computers.clear();
|
|
122
|
+
}
|
|
123
|
+
finish(entry, job, error, result) {
|
|
124
|
+
clearTimeout(job.timer);
|
|
125
|
+
entry.jobs.delete(job.id);
|
|
126
|
+
if (error)
|
|
127
|
+
job.reject(error);
|
|
128
|
+
else
|
|
129
|
+
job.resolve(result);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// `/learn` — turn a described workflow, URL, folder, or "what we just did"
|
|
2
|
-
// into a
|
|
3
|
-
// the harness
|
|
2
|
+
// into a reusable SKILL.md. The live agent authors the skill with skill_manage;
|
|
3
|
+
// the harness applies it under granted Full Access or stages a pending review.
|
|
4
4
|
//
|
|
5
5
|
// There is no separate distillation engine. This module only builds the
|
|
6
6
|
// prompt and recognises the slash command, so it works on every engine
|
|
@@ -56,13 +56,13 @@ Quality bar:
|
|
|
56
56
|
export function buildLearnPrompt(userRequest) {
|
|
57
57
|
const req = userRequest.trim() ||
|
|
58
58
|
"the workflow we just went through in this conversation — review the steps taken and distill them into a reusable skill";
|
|
59
|
-
return (`${LEARN_PROMPT_MARKER} The user wants you to learn a reusable skill from the request below,
|
|
59
|
+
return (`${LEARN_PROMPT_MARKER} The user wants you to learn a reusable skill from the request below, using the granted access level.\n\n` +
|
|
60
60
|
`THE REQUEST:\n${req}\n\n` +
|
|
61
61
|
"Do this:\n" +
|
|
62
62
|
"1. Inventory every source the user named, using the tools you already have — file tools for local paths, web fetch for URLs, and this conversation if they referred to something you just did. If the request is ambiguous about scope, make a reasonable choice and note it; do not stall.\n" +
|
|
63
63
|
"2. Check existing skills with skills_list. If one already covers this topic, leave it alone unless the user explicitly asked to revise that named learned/editable skill. For an explicit revision, read only the exact SKILL.md path listed for that skill in your system prompt (the native .agents/skills/<exact-name>/SKILL.md link is a fallback), preserve every still-valid step, re-verify what changed, then call skill_manage with action=\"update\" and skill_name set to that exact name. If you cannot read or verify the current skill, stop instead of replacing it from memory. For a genuinely new skill, use action=\"create\".\n" +
|
|
64
64
|
"3. Pass source as the exact URL or folder you used, or \"conversation\" when the conversation is the source.\n" +
|
|
65
|
-
"4. skill_manage
|
|
65
|
+
"4. Follow the skill_manage result: with granted Full Access it may apply immediately. After an applied result, continue the requested work without another confirmation. If review is pending, a create stays inactive and an update leaves the current version untouched; end the turn and wait for the in-app decision. Never claim success from the permission mode alone, and report failed or cancelled changes honestly.\n\n" +
|
|
66
66
|
AUTHORING_STANDARDS +
|
|
67
67
|
"\n\nWhen done, tell the user the skill name and a one-line summary of what it captured.");
|
|
68
68
|
}
|
|
@@ -1191,6 +1191,27 @@ export function applyStagedSkillWrite(botId, id, options = {}) {
|
|
|
1191
1191
|
writeStaged(botId, store);
|
|
1192
1192
|
return installed;
|
|
1193
1193
|
}
|
|
1194
|
+
/** Full Access uses the same exact-content apply path. Once installed, a
|
|
1195
|
+
* receipt or staging-cleanup failure must not tell the caller to apply again. */
|
|
1196
|
+
export function applySkillWriteWithReceipt(botId, staged, recordApplied) {
|
|
1197
|
+
let installed;
|
|
1198
|
+
try {
|
|
1199
|
+
const result = applyStagedSkillWrite(botId, staged.id, {
|
|
1200
|
+
expectedSha256: staged.sha256,
|
|
1201
|
+
onApplied: (skill) => {
|
|
1202
|
+
installed = skill;
|
|
1203
|
+
recordApplied(skill);
|
|
1204
|
+
},
|
|
1205
|
+
});
|
|
1206
|
+
return "error" in result ? result : { result };
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
if (!installed)
|
|
1210
|
+
throw error;
|
|
1211
|
+
return { result: installed, settlementPending: true,
|
|
1212
|
+
message: "Skill change applied. Recording its receipt or cleaning up staging could not finish; do not apply it again." };
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1194
1215
|
/** The skills block appended to a bot's system prompt: enabled skills only,
|
|
1195
1216
|
* index lines only — the same progressive-disclosure shape the spec asks
|
|
1196
1217
|
* agents for. Bodies never ride the prompt; the bot reads the file when a
|
|
@@ -71,6 +71,7 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
|
|
|
71
71
|
sendId: options.sendId,
|
|
72
72
|
reason: options.reason,
|
|
73
73
|
unattended: options.unattended,
|
|
74
|
+
peerAsk: options.peerAsk,
|
|
74
75
|
};
|
|
75
76
|
saveChatFollowup({ id, kind: "bot", ownerId: botId, threadId, payload: item });
|
|
76
77
|
entry.items.push(item);
|
|
@@ -132,6 +133,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
|
|
|
132
133
|
replyToId: item.replyToId,
|
|
133
134
|
sendId: item.sendId,
|
|
134
135
|
queueId: item.messageId,
|
|
136
|
+
peerAsk: item.peerAsk,
|
|
135
137
|
}));
|
|
136
138
|
}
|
|
137
139
|
const last = appended.at(-1);
|
|
@@ -199,6 +199,14 @@ export const ACTIVITY_BUSY = new Set(["working", "waiting-on-you", "no-signal"])
|
|
|
199
199
|
/** What a task is called before its first message names it. */
|
|
200
200
|
export const UNTITLED_TASK = "New task";
|
|
201
201
|
export const UNTITLED_THREAD = "New thread";
|
|
202
|
+
/** How a thread title is stored: one trim, one cut. Every title arrives
|
|
203
|
+
* through this — the name a bot passes to createTask and the name a person
|
|
204
|
+
* types in the sidebar alike — which is what makes "is this title still
|
|
205
|
+
* the one the machine made?" a question you can answer by comparing. */
|
|
206
|
+
const TASK_TITLE_MAX = 80;
|
|
207
|
+
export function threadTitleFrom(title) {
|
|
208
|
+
return title?.trim().slice(0, TASK_TITLE_MAX) || UNTITLED_THREAD;
|
|
209
|
+
}
|
|
202
210
|
/** A task's name, taken from the first thing you asked it to do. */
|
|
203
211
|
export function titleFromMessage(text) {
|
|
204
212
|
const line = text.trim().split("\n")[0].trim();
|
|
@@ -378,15 +386,27 @@ export class Store {
|
|
|
378
386
|
// bots.json write. Revoke it before schedulers, listeners, or HTTP can
|
|
379
387
|
// start any new work.
|
|
380
388
|
if (b.approvalGrant !== undefined) {
|
|
381
|
-
b.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
389
|
+
const threadOnly = b.approvalGrant.threadOnly === true;
|
|
390
|
+
if (threadOnly) {
|
|
391
|
+
// A crash may land between saving the target and clearing its
|
|
392
|
+
// journal. Revoke that target only, never unrelated threads.
|
|
393
|
+
const target = b.tasks?.find(task => task.threadId === b.approvalGrant?.threadId);
|
|
394
|
+
if (target) {
|
|
395
|
+
target.approvalMode = "ask";
|
|
396
|
+
target.autoApprove = false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (!threadOnly) {
|
|
400
|
+
b.approvalMode = "ask";
|
|
401
|
+
b.autoApprove = false;
|
|
402
|
+
for (const task of b.tasks ?? []) {
|
|
403
|
+
if (task.approvalMode === "full" || task.approvalMode === "custom") {
|
|
404
|
+
task.approvalMode = "ask";
|
|
405
|
+
task.autoApprove = false;
|
|
406
|
+
}
|
|
388
407
|
}
|
|
389
408
|
}
|
|
409
|
+
delete b.approvalGrant;
|
|
390
410
|
botsMigrated = true;
|
|
391
411
|
}
|
|
392
412
|
const avatar = botAvatarProfile(b);
|
|
@@ -1617,6 +1637,7 @@ export class Store {
|
|
|
1617
1637
|
return {
|
|
1618
1638
|
...bot,
|
|
1619
1639
|
threadId: task.threadId,
|
|
1640
|
+
approvalGrant: bot.approvalGrant?.threadOnly && bot.approvalGrant.threadId !== threadId ? undefined : bot.approvalGrant,
|
|
1620
1641
|
modelSelection: structuredClone(task.modelSelection ?? bot.modelSelection),
|
|
1621
1642
|
resumeCursors: structuredClone(task.resumeCursors),
|
|
1622
1643
|
approvalMode: task.approvalMode ?? (task.autoApprove === undefined ? bot.approvalMode : undefined),
|
|
@@ -1697,7 +1718,7 @@ export class Store {
|
|
|
1697
1718
|
return null;
|
|
1698
1719
|
const task = {
|
|
1699
1720
|
threadId: newId(),
|
|
1700
|
-
title: title
|
|
1721
|
+
title: threadTitleFrom(title),
|
|
1701
1722
|
createdAt: Date.now(),
|
|
1702
1723
|
...(projectId ? { projectId } : {}),
|
|
1703
1724
|
...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
|
|
@@ -1750,6 +1771,87 @@ export class Store {
|
|
|
1750
1771
|
this.emit({ type: "bot", botId });
|
|
1751
1772
|
return task;
|
|
1752
1773
|
}
|
|
1774
|
+
/** Where a bot-to-bot send outside a room lands: the PAIR CONVERSATION
|
|
1775
|
+
* for (sender, recipient) — the recipient's task stamped `openedBy` this
|
|
1776
|
+
* sender with kind "pair".
|
|
1777
|
+
*
|
|
1778
|
+
* Its scope is global for those two bots: deliberately not per source
|
|
1779
|
+
* thread and not per assignment, so a teammate you work with all day is
|
|
1780
|
+
* one readable row in the recipient's sidebar that remembers what was
|
|
1781
|
+
* asked last time, instead of one row per message. Nothing about the
|
|
1782
|
+
* caller's current turn takes part in choosing it — no dispatch
|
|
1783
|
+
* generation, no request key — and never the recipient's selected
|
|
1784
|
+
* thread, which belongs to the person.
|
|
1785
|
+
*
|
|
1786
|
+
* Two things bend that rule, both deliberately:
|
|
1787
|
+
*
|
|
1788
|
+
* adoption — a recipient still carrying threads this sender opened
|
|
1789
|
+
* before pair conversations existed (one per assignment, each titled
|
|
1790
|
+
* with a sliced brief) has its most recently active one stamped as the
|
|
1791
|
+
* pair conversation instead of gaining yet another row, so the sprawl
|
|
1792
|
+
* stops on upgrade day. Nothing is deleted or closed. A start_thread
|
|
1793
|
+
* handoff is left alone: the sender named that job itself and tracks
|
|
1794
|
+
* it by its own delegation id.
|
|
1795
|
+
*
|
|
1796
|
+
* concurrency — a second assignment arriving while the pair
|
|
1797
|
+
* conversation is still working (`working`, which the caller answers
|
|
1798
|
+
* from live turn state) gets its own work thread, so two jobs never
|
|
1799
|
+
* interleave in one transcript. `label` names that thread; the caller
|
|
1800
|
+
* closes it once its result has been reported. A pair conversation
|
|
1801
|
+
* never auto-closes. */
|
|
1802
|
+
resolvePairConversation(sender, recipientId, options) {
|
|
1803
|
+
if (!this.bot(recipientId))
|
|
1804
|
+
return null;
|
|
1805
|
+
const title = `@${sender.name}`;
|
|
1806
|
+
const opener = (kind, at = Date.now()) => ({ botId: sender.id, name: sender.name, kind, at });
|
|
1807
|
+
const fromSender = this.tasks(recipientId).filter((task) => task.openedBy?.botId === sender.id);
|
|
1808
|
+
let pair = fromSender.find((task) => task.openedBy?.kind === "pair");
|
|
1809
|
+
if (!pair) {
|
|
1810
|
+
const lastActivity = (task) => this.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.openedBy?.at ?? task.createdAt;
|
|
1811
|
+
const adopted = fromSender
|
|
1812
|
+
.filter((task) => !task.openedBy?.kind && !task.openedBy?.delegationId && !task.closedBy)
|
|
1813
|
+
.sort((a, b) => lastActivity(b) - lastActivity(a))[0];
|
|
1814
|
+
if (adopted) {
|
|
1815
|
+
// Keep the hour it was really opened: list_threads and the sidebar
|
|
1816
|
+
// order by it, and adoption is not a new conversation.
|
|
1817
|
+
this.setTaskOpenedBy(recipientId, adopted.threadId, opener("pair", adopted.openedBy?.at ?? adopted.createdAt));
|
|
1818
|
+
// The title changes only when nobody typed it. The rule: rename it
|
|
1819
|
+
// when it still equals what createTask made of the assignment that
|
|
1820
|
+
// opened the thread — and that assignment is still the thread's
|
|
1821
|
+
// first message, "@Recipient <brief>" — so the comparison is
|
|
1822
|
+
// threadTitleFrom(that brief). Anything else is a name a person
|
|
1823
|
+
// chose, and a thread with no request to read (its handoff never
|
|
1824
|
+
// ran) cannot be checked, so both keep the title they have.
|
|
1825
|
+
if (adopted.title === this.openingRequestTitle(recipientId, adopted.threadId)) {
|
|
1826
|
+
this.renameTask(recipientId, adopted.threadId, title);
|
|
1827
|
+
}
|
|
1828
|
+
pair = adopted;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
if (pair && !options.working(pair.threadId)) {
|
|
1832
|
+
// A conversation the sender closed after reading a result is picked
|
|
1833
|
+
// back up, never replaced: closing is only the sidebar's idle state.
|
|
1834
|
+
if (pair.closedBy)
|
|
1835
|
+
this.setTaskClosedBy(recipientId, pair.threadId, null);
|
|
1836
|
+
return { task: pair, created: false };
|
|
1837
|
+
}
|
|
1838
|
+
// The brief is never a title. An 80-character slice of an assignment
|
|
1839
|
+
// is the row nobody can read, and a durable conversation outlives the
|
|
1840
|
+
// one brief that opened it.
|
|
1841
|
+
const task = this.createTask(recipientId, pair ? `${title} · ${options.label || "parallel work"}` : title, false, undefined, opener(pair ? "work" : "pair"));
|
|
1842
|
+
return task ? { task, created: true } : null;
|
|
1843
|
+
}
|
|
1844
|
+
/** The title a peer-opened thread was born with: what createTask made of
|
|
1845
|
+
* the request that opened it, which is still the first message in it,
|
|
1846
|
+
* addressed "@Recipient <brief>". null when there is no such message to
|
|
1847
|
+
* read — an unrun handoff proves nothing about who named the row. */
|
|
1848
|
+
openingRequestTitle(recipientId, threadId) {
|
|
1849
|
+
const first = this.messagesFor(threadId)[0]?.text?.trim();
|
|
1850
|
+
if (!first)
|
|
1851
|
+
return null;
|
|
1852
|
+
const addressed = `@${this.bot(recipientId)?.name ?? ""} `;
|
|
1853
|
+
return threadTitleFrom(first.startsWith(addressed) ? first.slice(addressed.length) : first);
|
|
1854
|
+
}
|
|
1753
1855
|
switchTask(botId, threadId) {
|
|
1754
1856
|
const bot = this.bot(botId);
|
|
1755
1857
|
const task = bot?.tasks?.find((t) => t.threadId === threadId);
|