openmausbot 0.1.66 → 0.1.68

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 (53) hide show
  1. package/dist/assets/index-CbaejjQA.js +278 -0
  2. package/dist/assets/{index-kW5L1QeO.js → index-HqooKFTS.js} +1 -1
  3. package/dist/assets/index-aJ5EWZOQ.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/computer-proxy.js +14 -3
  6. package/dist-server/container-mcp.js +44 -19
  7. package/dist-server/drivers/agents-proxy.js +59 -3
  8. package/dist-server/enterprise/server/index.js +14587 -0
  9. package/dist-server/index.js +2675 -1055
  10. package/dist-server/local-computer-proxy.js +433 -0
  11. package/dist-server/local-computer.js +48 -5
  12. package/dist-server/mcp-server.js +38 -17
  13. package/dist-server/openmausbot.js +1560 -745
  14. package/dist-server/pair-cli.js +1560 -745
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/account-signin.js +103 -0
  17. package/dist-server/server/brand.js +7 -0
  18. package/dist-server/server/caddy.js +203 -0
  19. package/dist-server/server/cli.js +185 -22
  20. package/dist-server/server/computer-proxy.js +11 -2
  21. package/dist-server/server/config.js +22 -1
  22. package/dist-server/server/control-client.js +7 -1
  23. package/dist-server/server/delegations.js +56 -11
  24. package/dist-server/server/drivers/agents-proxy.js +75 -5
  25. package/dist-server/server/drivers/claude-login-auth.js +334 -0
  26. package/dist-server/server/drivers/claude.js +8 -0
  27. package/dist-server/server/environment.js +1 -0
  28. package/dist-server/server/harness/registry.js +7 -1
  29. package/dist-server/server/index.js +965 -363
  30. package/dist-server/server/local-computer-proxy.js +28 -0
  31. package/dist-server/server/local-computer.js +17 -0
  32. package/dist-server/server/mcp-bridge.js +27 -11
  33. package/dist-server/server/peer-approval.js +19 -6
  34. package/dist-server/server/proxy-paths.js +1 -0
  35. package/dist-server/server/routines.js +36 -9
  36. package/dist-server/server/service-cli.js +63 -0
  37. package/dist-server/server/service-unit.js +129 -0
  38. package/dist-server/server/sessions.js +42 -1
  39. package/dist-server/server/steer-queue.js +12 -9
  40. package/dist-server/server/store.js +260 -34
  41. package/dist-server/server/system-prompt.js +1 -0
  42. package/dist-server/server/team-backup.js +1 -0
  43. package/dist-server/server/tunnel.js +27 -1
  44. package/dist-server/server/turn-dispatch-guard.js +6 -0
  45. package/dist-server/server/turn-resources.js +50 -0
  46. package/dist-server/server/workspace.js +53 -3
  47. package/dist-server/vps-container-mcp.js +44 -19
  48. package/enterprise/LICENSE +32 -0
  49. package/enterprise/README.md +89 -0
  50. package/enterprise/server/index.js +14587 -0
  51. package/package.json +2 -1
  52. package/dist/assets/index-BftlaHAF.css +0 -1
  53. package/dist/assets/index-DuR3H-Hf.js +0 -278
@@ -0,0 +1,28 @@
1
+ // Near-side gate for the host CUA process. The descriptor and turn token
2
+ // travel in environment variables, never command-line arguments or logs.
3
+ import { runMcpBridge } from "./mcp-bridge.js";
4
+ import { augmentedPath } from "./env-path.js";
5
+ const { OMB_CUA_COMMAND: command, OMB_CUA_ARGS: encodedArgs, OMB_CONTROL_URL: url, OMB_CONTROL_TOKEN: token, ...childEnv } = process.env;
6
+ let args;
7
+ try {
8
+ const parsed = JSON.parse(encodedArgs ?? "");
9
+ const endpoint = new URL(url ?? "");
10
+ if (!command?.trim() || command.includes("\0") ||
11
+ !Array.isArray(parsed) || !parsed.every((arg) => typeof arg === "string" && !arg.includes("\0")) ||
12
+ !token || !["http:", "https:"].includes(endpoint.protocol) ||
13
+ !["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname) || endpoint.username || endpoint.password) {
14
+ throw new Error("invalid connection");
15
+ }
16
+ args = parsed;
17
+ }
18
+ catch {
19
+ process.stderr.write("invalid local computer proxy connection\n");
20
+ process.exit(2);
21
+ }
22
+ runMcpBridge({
23
+ command: command,
24
+ args,
25
+ env: { ...childEnv, PATH: augmentedPath() },
26
+ label: "Local Cua Driver",
27
+ gate: { url: url, token: token },
28
+ });
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
+ import { SPAWNED_PROXIES } from "./proxy-paths.js";
4
5
  export const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"];
5
6
  // Keep this exact field set synchronized with DRIVER_FILE_IDENTITY_KEYS in
6
7
  // electron/cua-linux.cjs; Electron publishes it and the server revalidates it.
@@ -14,6 +15,22 @@ export const DRIVER_FILE_IDENTITY_KEYS = [
14
15
  "mtimeNs",
15
16
  "ctimeNs",
16
17
  ];
18
+ /** Keep the platform descriptor intact, but acquire computer authority only
19
+ * when the first actual tool call reaches the shared stdio gate. */
20
+ export function gatedLocalComputer(connection, control) {
21
+ return {
22
+ ...connection,
23
+ command: process.execPath,
24
+ args: ["--experimental-strip-types", SPAWNED_PROXIES.localComputer],
25
+ env: {
26
+ ...connection.env,
27
+ OMB_CUA_COMMAND: connection.command,
28
+ OMB_CUA_ARGS: JSON.stringify(connection.args),
29
+ OMB_CONTROL_URL: control.url,
30
+ OMB_CONTROL_TOKEN: control.token,
31
+ },
32
+ };
33
+ }
17
34
  function exactKeys(value, keys) {
18
35
  const expected = new Set(keys);
19
36
  return Object.keys(value).length === expected.size && Object.keys(value).every((key) => expected.has(key));
@@ -1,5 +1,4 @@
1
- // The one stdio bridge behind both MCP entry points
2
- // (container-mcp.ts for the Local VM, vps-container-mcp.ts for the BYO VPS).
1
+ // The shared stdio bridge for the host computer, Local VM, and BYO VPS.
3
2
  //
4
3
  // It is almost transparent — bytes in, bytes out — with two deliberate
5
4
  // near-side exceptions:
@@ -152,7 +151,7 @@ export function createGateInterceptor(options) {
152
151
  options.forward(line);
153
152
  return;
154
153
  }
155
- const held = await options.isHeld().catch(() => false);
154
+ const held = await options.isHeld().catch(() => true);
156
155
  if (!held) {
157
156
  options.forward(line);
158
157
  return;
@@ -160,9 +159,10 @@ export function createGateInterceptor(options) {
160
159
  options.refuse(JSON.stringify({
161
160
  jsonrpc: "2.0",
162
161
  id: frame.id ?? null,
163
- result: { content: [{ type: "text", text: refusalText }], isError: true },
162
+ result: { content: [{ type: "text", text: options.getRefusalReason?.() ?? refusalText }], isError: true },
164
163
  }));
165
164
  });
165
+ return queue;
166
166
  };
167
167
  }
168
168
  /** The near-side MCP method filter. `ping` is answered here so the bundled
@@ -175,8 +175,9 @@ export function createMcpBridgeInterceptor(options) {
175
175
  forward: options.forward,
176
176
  refuse: options.answer,
177
177
  refusalText: options.gate.refusalText,
178
+ getRefusalReason: options.gate.getRefusalReason,
178
179
  })
179
- : options.forward;
180
+ : (line) => { options.forward(line); };
180
181
  return (line) => {
181
182
  let frame = null;
182
183
  try {
@@ -192,13 +193,13 @@ export function createMcpBridgeInterceptor(options) {
192
193
  }
193
194
  return;
194
195
  }
195
- afterPing(line);
196
+ return afterPing(line);
196
197
  };
197
198
  }
198
199
  export function runMcpBridge(options) {
199
200
  const child = spawn(options.command, options.args, {
200
201
  shell: false,
201
- env: { ...process.env, PATH: augmentedPath() },
202
+ env: options.env ?? { ...process.env, PATH: augmentedPath() },
202
203
  stdio: ["pipe", "pipe", "pipe"],
203
204
  });
204
205
  // docker may exit before it drains stdin; pipe() leaves this error unhandled.
@@ -207,24 +208,39 @@ export function runMcpBridge(options) {
207
208
  const client = options.gate
208
209
  ? createControlClient({ url: options.gate.url, token: options.gate.token })
209
210
  : null;
211
+ let refusalReason;
210
212
  const answer = (line) => process.stdout.write(line + "\n");
211
213
  const forward = (line) => child.stdin.write(line + "\n");
212
- const inbound = createLineSplitter(createMcpBridgeInterceptor({
214
+ const intercept = createMcpBridgeInterceptor({
213
215
  answer,
214
216
  forward,
215
217
  ...(options.gate
216
218
  ? {
217
219
  gate: {
218
- isHeld: async () => (await client.state(true)).held,
220
+ isHeld: async () => {
221
+ refusalReason = undefined;
222
+ const state = await client.state(true);
223
+ refusalReason = state.blockedReason;
224
+ return state.held;
225
+ },
226
+ getRefusalReason: () => refusalReason,
219
227
  },
220
228
  }
221
229
  : {}),
222
- }));
230
+ });
231
+ let pendingInput = Promise.resolve();
232
+ const inbound = createLineSplitter((line) => {
233
+ const completion = intercept(line);
234
+ if (completion)
235
+ pendingInput = completion;
236
+ });
223
237
  const onStdin = (chunk) => inbound.push(chunk);
224
238
  process.stdin.on("data", onStdin);
225
239
  process.stdin.on("end", () => {
226
240
  inbound.flush();
227
- child.stdin.end();
241
+ // A final tools/call can still be awaiting the control endpoint. Do not
242
+ // close the far end before the serialized gate has forwarded it.
243
+ void pendingInput.finally(() => child.stdin.end());
228
244
  });
229
245
  // Injected responses and refusals must never land inside one of the
230
246
  // child's half-written frames, so the child's stdout is re-emitted at
@@ -31,9 +31,17 @@ function settleCard(pending, behavior, source) {
31
31
  });
32
32
  // answered (by whoever): the turn is working again — the same hand-back
33
33
  // the request.resolved fold does for a provider's card
34
- const waiting = pending.bus.store.bot(pending.fromBotId);
35
- if (waiting?.activity === "waiting-on-you")
36
- pending.bus.store.setActivity(waiting.id, "working");
34
+ const store = pending.bus.store;
35
+ const task = store.projectBotForTask(pending.fromBotId, pending.threadId);
36
+ if (task) {
37
+ if (task.activity === "waiting-on-you")
38
+ store.setTaskActivity(task.id, pending.threadId, "working");
39
+ }
40
+ else if (store.groupByThread(pending.threadId)) {
41
+ const waiting = store.bot(pending.fromBotId);
42
+ if (waiting?.activity === "waiting-on-you")
43
+ store.setActivity(waiting.id, "working");
44
+ }
37
45
  }
38
46
  /** requestId → pending ask. Lives only in memory — restarting the
39
47
  * server cancels every in-flight approval, like provider permissions do. */
@@ -79,10 +87,15 @@ function pushApprovalCard(bus, from, target, message, action, requestId, sourceT
79
87
  function announceCard(bus, from, card, sourceThreadId) {
80
88
  // the bot is not working now — it is waiting on a person. A delegation
81
89
  // drained after its turn has already settled is idle, and stays so.
82
- const live = bus.store.bot(from.id);
83
- if (live?.busy)
84
- bus.store.setActivity(from.id, "waiting-on-you");
90
+ const task = bus.store.projectBotForTask(from.id, sourceThreadId);
85
91
  const room = bus.store.groupByThread(sourceThreadId);
92
+ if (task) {
93
+ if (task.busy)
94
+ bus.store.setTaskActivity(from.id, sourceThreadId, "waiting-on-you");
95
+ }
96
+ else if (room && bus.store.bot(from.id)?.busy) {
97
+ bus.store.setActivity(from.id, "waiting-on-you");
98
+ }
86
99
  const group = room && !room.dm ? { id: room.id, name: room.name } : undefined;
87
100
  const title = card.card?.title ?? "";
88
101
  const detail = card.card?.subtitle ? `${title} — ${card.card.subtitle}` : title;
@@ -33,6 +33,7 @@ export function resolveProxy(relative) {
33
33
  export const SPAWNED_PROXIES = {
34
34
  browser: resolveProxy("browser-proxy"),
35
35
  computer: resolveProxy("computer-proxy"),
36
+ localComputer: resolveProxy("local-computer-proxy"),
36
37
  permission: resolveProxy("permission-proxy"),
37
38
  containerMcp: resolveProxy("container-mcp"),
38
39
  vpsContainerMcp: resolveProxy("vps-container-mcp"),
@@ -449,6 +449,10 @@ export class RoutineManager {
449
449
  isActiveThread(threadId) {
450
450
  return this.runs.some((run) => run.threadId === threadId && ["running", "waiting"].includes(run.status));
451
451
  }
452
+ runForThread(threadId) {
453
+ const run = this.runs.find((candidate) => candidate.threadId === threadId);
454
+ return run ? cloneRun(run) : null;
455
+ }
452
456
  create(input, request) {
453
457
  if (request) {
454
458
  const receipt = this.matchingRoutineRequestReceipt(request);
@@ -510,9 +514,15 @@ export class RoutineManager {
510
514
  if (this.targetState(clean) === "missing")
511
515
  throw new Error(this.missingTargetMessage(clean.target));
512
516
  const cancelledRuns = [];
517
+ const scheduleChanged = JSON.stringify(clean.schedule) !== JSON.stringify(routine.schedule);
518
+ const enabledChanged = clean.enabled !== routine.enabled;
513
519
  this.commitMutation(() => {
514
520
  Object.assign(routine, clean, {
515
- nextRunAt: clean.enabled ? this.initialOccurrence(clean.schedule, now) : null,
521
+ // Renaming/editing instructions must not skip an occurrence that
522
+ // became due since the last tick (or erase an offline catch-up).
523
+ nextRunAt: !clean.enabled ? null : scheduleChanged || enabledChanged
524
+ ? this.initialOccurrence(clean.schedule, now)
525
+ : routine.nextRunAt,
516
526
  // `updatedAt` doubles as the optimistic revision on durable routine
517
527
  // confirmation cards. Keep it monotonic even for two writes in one ms.
518
528
  updatedAt: Math.max(now, routine.updatedAt + 1),
@@ -868,7 +878,10 @@ export class RoutineManager {
868
878
  this.save();
869
879
  for (const missed of missedRuns)
870
880
  this.options.onRunFailed?.(missed);
871
- for (const run of [...this.runs].reverse()) {
881
+ // Oldest queued requests have priority. New manual/webhook arrivals
882
+ // must not continually overtake work that has already waited. Snapshot
883
+ // the queue because each dispatch can asynchronously add/cancel work.
884
+ for (const run of this.runs.slice()) {
872
885
  if (run.status !== "queued")
873
886
  continue;
874
887
  // A queued interval represents the latest useful check, not a backlog
@@ -949,9 +962,19 @@ export class RoutineManager {
949
962
  // completion and private coordinator envelope are only intermediate
950
963
  // protocol, never the routine receipt's result.
951
964
  if (run.target === "room-goal" &&
952
- (event.type === "turn.completed" || (event.type === "item.completed" && event.itemType === "assistant_text")))
965
+ (
966
+ // These outcomes ended the goal operation. Later room traffic is
967
+ // not a resume of that run; only the goal lifecycle can change its
968
+ // receipt. In-flight provider approvals have no goalStatus and
969
+ // continue to resolve normally below.
970
+ run.goalStatus === "needs-input" || run.goalStatus === "paused" ||
971
+ event.type === "turn.completed" || (event.type === "item.completed" && event.itemType === "assistant_text")))
953
972
  return null;
954
- if (event.type === "request.opened") {
973
+ if (event.type === "turn.started") {
974
+ run.status = "running";
975
+ run.attention = undefined;
976
+ }
977
+ else if (event.type === "request.opened") {
955
978
  run.status = "waiting";
956
979
  run.attention = redactSecretsInText(event.summary).trim().slice(0, 500) || undefined;
957
980
  }
@@ -971,16 +994,20 @@ export class RoutineManager {
971
994
  return null;
972
995
  }
973
996
  else if (event.type === "turn.completed") {
974
- run.cost = event.cost;
975
- run.denials = event.denials;
997
+ if (event.cost != null)
998
+ run.cost = (run.cost ?? 0) + event.cost;
999
+ if (event.denials?.length)
1000
+ run.denials = [...new Set([...(run.denials ?? []), ...event.denials])];
976
1001
  if (!event.ok) {
977
1002
  this.failRun(run, event.stopReason ?? run.error ?? "The bot did not complete this run");
978
1003
  queueMicrotask(() => void this.tick());
979
1004
  return cloneRun(run);
980
1005
  }
981
- run.status = "completed";
982
- run.attention = undefined;
983
- run.finishedAt = this.now();
1006
+ const pending = this.options.hasPendingDelegations?.(event.threadId) === true;
1007
+ run.status = pending ? "waiting" : "completed";
1008
+ run.attention = pending ? "Waiting for delegated work to finish" : undefined;
1009
+ if (!pending)
1010
+ run.finishedAt = this.now();
984
1011
  run.error = undefined;
985
1012
  }
986
1013
  else {
@@ -0,0 +1,63 @@
1
+ // The `openmausbot service` command, kept separate from cli.ts so it can be
2
+ // tested with explicit inputs: it renders the unit for this platform, writes
3
+ // it next to the data, and prints the commands that install it.
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { currentUser, launchdPlist, servicePlan, systemdUnit, unstableInstallWarning } from "./service-unit.js";
7
+ /** The `serve` arguments the service repeats, from the options given to `service install`. */
8
+ export function serviceServeArgs(input) {
9
+ const args = ["--port", String(input.port), "--data-dir", input.dataDir, "--no-pair"];
10
+ if (input.domain)
11
+ args.push("--domain", input.domain);
12
+ else if (input.tunnel)
13
+ args.push("--tunnel");
14
+ else if (input.tailscale)
15
+ args.push("--tailscale");
16
+ if (input.label)
17
+ args.push("--label", input.label);
18
+ return args;
19
+ }
20
+ export function runServiceCommand(input, io) {
21
+ const platform = input.platform ?? process.platform;
22
+ const home = input.home ?? homedir();
23
+ const plan = servicePlan(platform, input.dataDir, home);
24
+ if (!plan) {
25
+ io.error("services are written for Linux (systemd) and macOS (launchd); on Windows, use Task Scheduler to run `openmausbot serve` at startup");
26
+ return 1;
27
+ }
28
+ if (input.action === "uninstall") {
29
+ io.log(`to stop and remove the service:`);
30
+ for (const line of plan.deactivate)
31
+ io.log(` ${line}`);
32
+ return 0;
33
+ }
34
+ const warning = unstableInstallWarning(input.script);
35
+ if (warning) {
36
+ io.error(warning);
37
+ return 1;
38
+ }
39
+ const spec = {
40
+ node: input.node,
41
+ script: input.script,
42
+ serveArgs: serviceServeArgs(input),
43
+ dataDir: input.dataDir,
44
+ user: input.user ?? currentUser(),
45
+ home,
46
+ bindsLowPorts: Boolean(input.domain),
47
+ ...(input.label ? { label: input.label } : {}),
48
+ };
49
+ const rendered = platform === "darwin" ? launchdPlist(spec) : systemdUnit(spec);
50
+ mkdirSync(input.dataDir, { recursive: true, mode: 0o700 });
51
+ writeFileSync(plan.file, rendered, { mode: 0o644 });
52
+ io.log(`wrote ${plan.file}`);
53
+ io.log("");
54
+ io.log(platform === "darwin" ? "to install and start it (runs at login):" : "to install and start it (runs at boot, restarts if it stops):");
55
+ for (const line of plan.activate)
56
+ io.log(` ${line}`);
57
+ io.log("");
58
+ if (input.domain && platform === "linux")
59
+ io.log("the unit grants Caddy the capability for ports 80 and 443, so no setcap is needed under the service");
60
+ io.log(`logs: ${platform === "darwin" ? `${input.dataDir}/logs/service.log` : "journalctl -u openmausbot -f"}`);
61
+ io.log(`change options later by running \`openmausbot service install\` again with the new ones, then: ${platform === "darwin" ? plan.activate[1] : "sudo systemctl daemon-reload && sudo systemctl restart openmausbot"}`);
62
+ return 0;
63
+ }
@@ -0,0 +1,129 @@
1
+ // `openmausbot service install`: keep the server running across reboots.
2
+ // Renders a systemd unit (Linux) or a launchd agent (macOS) that runs the
3
+ // same `openmausbot serve …` the operator just used, and either installs it
4
+ // (when allowed to) or writes it next to the data and prints the two
5
+ // commands that install it. Pure rendering lives here so it is testable;
6
+ // the CLI decides where the file goes.
7
+ import { homedir, userInfo } from "node:os";
8
+ import { posix } from "node:path";
9
+ // Unit files describe a Linux or macOS machine, so their paths are POSIX
10
+ // whatever host renders them (the Windows CI runner included).
11
+ const { basename, dirname, join } = posix;
12
+ export const SYSTEMD_UNIT_NAME = "openmausbot.service";
13
+ export const LAUNCHD_LABEL = "com.openmausbot.serve";
14
+ function quoteSystemd(value) {
15
+ // systemd's ExecStart splits on whitespace and understands double quotes.
16
+ return /[\s"\\]/.test(value) ? `"${value.replace(/[\\"]/g, "\\$&")}"` : value;
17
+ }
18
+ function needsStripTypes(script) {
19
+ return script.endsWith(".ts");
20
+ }
21
+ export function serviceCommand(spec) {
22
+ return [spec.node, ...(needsStripTypes(spec.script) ? ["--experimental-strip-types"] : []), spec.script, "serve", ...spec.serveArgs];
23
+ }
24
+ export function systemdUnit(spec) {
25
+ const lines = [
26
+ "# Written by `openmausbot service install`. Re-run it to change the options.",
27
+ "[Unit]",
28
+ `Description=OpenMausBot${spec.label ? ` (${spec.label})` : ""}`,
29
+ "After=network-online.target",
30
+ "Wants=network-online.target",
31
+ "",
32
+ "[Service]",
33
+ "Type=simple",
34
+ `User=${spec.user}`,
35
+ `WorkingDirectory=${spec.home}`,
36
+ `Environment=HOME=${spec.home}`,
37
+ `Environment=OMB_DATA_DIR=${spec.dataDir}`,
38
+ `ExecStart=${serviceCommand(spec).map(quoteSystemd).join(" ")}`,
39
+ "Restart=always",
40
+ "RestartSec=3",
41
+ "KillMode=mixed",
42
+ "TimeoutStopSec=30",
43
+ ];
44
+ if (spec.bindsLowPorts) {
45
+ lines.push("# serve --domain: Caddy binds ports 80 and 443 without running as root.");
46
+ lines.push("AmbientCapabilities=CAP_NET_BIND_SERVICE");
47
+ lines.push("CapabilityBoundingSet=CAP_NET_BIND_SERVICE");
48
+ }
49
+ lines.push("", "[Install]", "WantedBy=multi-user.target", "");
50
+ return lines.join("\n");
51
+ }
52
+ function xml(value) {
53
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
54
+ }
55
+ export function launchdPlist(spec) {
56
+ const args = serviceCommand(spec).map((arg) => `\t\t<string>${xml(arg)}</string>`).join("\n");
57
+ return [
58
+ '<?xml version="1.0" encoding="UTF-8"?>',
59
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
60
+ '<plist version="1.0">',
61
+ "<dict>",
62
+ "\t<key>Label</key>",
63
+ `\t<string>${LAUNCHD_LABEL}</string>`,
64
+ "\t<key>ProgramArguments</key>",
65
+ "\t<array>",
66
+ args,
67
+ "\t</array>",
68
+ "\t<key>EnvironmentVariables</key>",
69
+ "\t<dict>",
70
+ "\t\t<key>HOME</key>",
71
+ `\t\t<string>${xml(spec.home)}</string>`,
72
+ "\t\t<key>OMB_DATA_DIR</key>",
73
+ `\t\t<string>${xml(spec.dataDir)}</string>`,
74
+ "\t\t<key>PATH</key>",
75
+ "\t\t<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>",
76
+ "\t</dict>",
77
+ "\t<key>WorkingDirectory</key>",
78
+ `\t<string>${xml(spec.home)}</string>`,
79
+ "\t<key>RunAtLoad</key>",
80
+ "\t<true/>",
81
+ "\t<key>KeepAlive</key>",
82
+ "\t<true/>",
83
+ "\t<key>StandardOutPath</key>",
84
+ `\t<string>${xml(join(spec.dataDir, "logs", "service.log"))}</string>`,
85
+ "\t<key>StandardErrorPath</key>",
86
+ `\t<string>${xml(join(spec.dataDir, "logs", "service.log"))}</string>`,
87
+ "</dict>",
88
+ "</plist>",
89
+ "",
90
+ ].join("\n");
91
+ }
92
+ /** An `npx` cache path is pruned without notice; a service must not point at it. */
93
+ export function unstableInstallWarning(script) {
94
+ const normalized = script.replace(/\\/g, "/");
95
+ if (/\/_npx\//.test(normalized) || /\/\.npm\/_npx\//.test(normalized)) {
96
+ return `this command runs from an npx cache (${dirname(script)}), which npm may delete at any time. Install it permanently first (npm install -g openmausbot) and run \`openmausbot service install\` from that install.`;
97
+ }
98
+ return null;
99
+ }
100
+ /** Where the rendered file goes and how to activate it, per platform. */
101
+ export function servicePlan(platform, dataDir, home = homedir()) {
102
+ if (platform === "linux") {
103
+ const installed = `/etc/systemd/system/${SYSTEMD_UNIT_NAME}`;
104
+ return {
105
+ file: join(dataDir, SYSTEMD_UNIT_NAME),
106
+ installed,
107
+ activate: [`sudo install -m 644 ${join(dataDir, SYSTEMD_UNIT_NAME)} ${installed}`, "sudo systemctl daemon-reload", `sudo systemctl enable --now ${basename(SYSTEMD_UNIT_NAME, ".service")}`],
108
+ deactivate: [`sudo systemctl disable --now ${basename(SYSTEMD_UNIT_NAME, ".service")}`, `sudo rm ${installed}`, "sudo systemctl daemon-reload"],
109
+ };
110
+ }
111
+ if (platform === "darwin") {
112
+ const installed = join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
113
+ return {
114
+ file: join(dataDir, `${LAUNCHD_LABEL}.plist`),
115
+ installed,
116
+ activate: [`mkdir -p ${dirname(installed)} && cp ${join(dataDir, `${LAUNCHD_LABEL}.plist`)} ${installed}`, `launchctl bootstrap gui/$(id -u) ${installed}`],
117
+ deactivate: [`launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`, `rm ${installed}`],
118
+ };
119
+ }
120
+ return null;
121
+ }
122
+ export function currentUser() {
123
+ try {
124
+ return userInfo().username;
125
+ }
126
+ catch {
127
+ return process.env.USER || process.env.USERNAME || "openmausbot";
128
+ }
129
+ }
@@ -66,6 +66,9 @@ const sessionSchema = z.object({
66
66
  createdAt: z.number(),
67
67
  lastSeenAt: z.number(),
68
68
  expiresAt: z.number(),
69
+ /** Set when the session came from an account sign-in rather than a code. */
70
+ userId: z.string().max(256).optional(),
71
+ email: z.string().max(320).optional(),
69
72
  });
70
73
  const fileSchema = z.object({ version: z.literal(1), sessions: z.array(sessionSchema) });
71
74
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
@@ -102,7 +105,7 @@ export function formatPairingCode(code) {
102
105
  return code.match(/.{1,4}/g)?.join("-") ?? code;
103
106
  }
104
107
  function publicSession(record) {
105
- return {
108
+ const view = {
106
109
  id: record.id,
107
110
  label: record.label,
108
111
  scopes: [...record.scopes],
@@ -110,6 +113,9 @@ function publicSession(record) {
110
113
  lastSeenAt: record.lastSeenAt,
111
114
  expiresAt: record.expiresAt,
112
115
  };
116
+ if (record.email)
117
+ view.email = record.email;
118
+ return view;
113
119
  }
114
120
  export class SessionRegistry {
115
121
  sessions = [];
@@ -284,6 +290,41 @@ export class SessionRegistry {
284
290
  this.replays.push({ codeHash: presented, attemptId, result, expiresAt: now + EXCHANGE_REPLAY_MS });
285
291
  return result;
286
292
  }
293
+ /** A session from a verified account sign-in (server/account-signin.ts)
294
+ * rather than a pairing code: same token, same term, same gates. */
295
+ issue(input) {
296
+ this.prune();
297
+ const now = this.now();
298
+ const token = `omb_sess_${randomBytes(32).toString("base64url")}`;
299
+ const record = {
300
+ id: randomUUID(),
301
+ tokenHash: sha256(token),
302
+ label: (input.label.trim() || "Unnamed device").slice(0, 80),
303
+ scopes: [...new Set(input.scopes)],
304
+ createdAt: now,
305
+ lastSeenAt: now,
306
+ expiresAt: now + Math.min(SESSION_TTL_MS, SESSION_MAX_AGE_MS),
307
+ };
308
+ if (input.userId)
309
+ record.userId = input.userId;
310
+ if (input.email)
311
+ record.email = input.email;
312
+ this.sessions.push(record);
313
+ this.lastSeenWrites.set(record.id, now);
314
+ this.persist();
315
+ return { token, session: publicSession(record) };
316
+ }
317
+ /** The pairing lockout, for other code-like exchanges on the same source. */
318
+ attemptAllowed(source) {
319
+ const lock = this.lockState(source);
320
+ return lock.locked ? { ok: false, retryAfterMs: lock.retryAfterMs } : { ok: true };
321
+ }
322
+ noteFailure(source) {
323
+ this.recordFailure(source);
324
+ }
325
+ clearFailures(source) {
326
+ this.failures.delete(source);
327
+ }
287
328
  // ── sessions ───────────────────────────────────────────────────────────
288
329
  authenticate(token) {
289
330
  if (!token)
@@ -32,28 +32,31 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
32
32
  prompt: options.prompt ?? text,
33
33
  replyToId: options.replyToId,
34
34
  sendId: options.sendId,
35
+ reason: options.reason,
35
36
  });
36
37
  queues.set(threadId, entry);
37
38
  return { id };
38
39
  }
39
- /** Drain every queue whose bot is idle: append the held lines (leaf is now
40
+ /** Drain every queue whose task is idle: append the held lines (leaf is now
40
41
  * the finished turn's last item), then one run per thread whose prompt is
41
42
  * the texts separated by a blank line. `userMessage` is the last appended line
42
43
  * so startTurn does not duplicate it; `excludeIds` is every drained line
43
44
  * so transcript-replay adapters do not also see earlier queued texts.
44
45
  * Entries leave the map BEFORE running so a settle racing another settle
45
46
  * can never fire the same queue twice. */
46
- export function drainSteeredMessages(store, run) {
47
+ export function drainSteeredMessages(store, run, isBlocked) {
47
48
  // deleting only the entry being visited is safe under Map iteration
48
49
  for (const [threadId, entry] of queues) {
49
- const bot = store.bot(entry.botId);
50
+ const bot = store.projectBotForTask
51
+ ? store.projectBotForTask(entry.botId, threadId)
52
+ : store.bot(entry.botId);
50
53
  if (!bot) {
51
- // the bot was deleted while messages waited — nothing left to steer
54
+ // the bot or task was deleted while messages waited
52
55
  queues.delete(threadId);
53
56
  continue;
54
57
  }
55
- if (bot.busy)
56
- continue; // still working — the next settle tries again
58
+ if (bot.busy || isBlocked?.(entry.botId, threadId))
59
+ continue;
57
60
  // committed to draining: the entry leaves the map before anything runs,
58
61
  // so a settle racing another settle can never fire the same queue twice
59
62
  queues.delete(threadId);
@@ -87,15 +90,15 @@ export function queuedSteeredMessage(botId, threadId, sendId) {
87
90
  if (!entry || entry.botId !== botId)
88
91
  return null;
89
92
  const item = entry.items.find((candidate) => candidate.sendId === sendId);
90
- return item ? { id: item.messageId, text: item.text, replyToId: item.replyToId } : null;
93
+ return item ? { id: item.messageId, text: item.text, replyToId: item.replyToId, ...(item.reason ? { reason: item.reason } : {}) } : null;
91
94
  }
92
95
  /** Drop one waiting send owned by this bot so it never drains. The queue id
93
96
  * is stable even if the bot switches away from the task while the request is
94
97
  * in flight. Returns false when it was already drained, belongs to another
95
98
  * bot, or a restart lost the in-memory auto-run intent. */
96
- export function cancelSteeredMessage(botId, messageId) {
99
+ export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
97
100
  for (const [threadId, entry] of queues) {
98
- if (entry.botId !== botId)
101
+ if (entry.botId !== botId || (expectedThreadId !== undefined && threadId !== expectedThreadId))
99
102
  continue;
100
103
  const items = entry.items.filter((item) => item.messageId !== messageId);
101
104
  if (items.length === entry.items.length)