@orboto/mcp 0.224.0 → 0.226.2

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.
@@ -67,7 +67,7 @@ describe('ORB-2584 - the channel needs a channel-enabled start', () => {
67
67
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
68
68
  const path = new URL(url.toString()).pathname;
69
69
  const map = {
70
- '/v1/agent/heartbeat': { sessionToken: 'mcp-x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null, wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' }, pid: 4100, startedAt: null, forPid: 5200, expiredAt: 'now' }, notes },
70
+ '/v1/agent/heartbeat': { sessionToken: 'mcp-x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null, wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' }, pid: 4100, startedAt: null, forPid: 5200, expiredAt: 'now' }, scopeContinued: { scope: { role: 'lead' }, pid: 4100, startedAt: null, forPid: 5200, conversation: 'conv-1', continuedAt: 'now' }, notes },
71
71
  '/users/me': { email: 'dev@example.invalid', fullName: 'Dev' },
72
72
  '/agent-instructions': { instructions: 'rules', rulesHash: 'h' },
73
73
  };
@@ -78,6 +78,6 @@ describe('ORB-2584 - the channel needs a channel-enabled start', () => {
78
78
  const text = res.content[0].text;
79
79
  for (const note of notes)
80
80
  expect(text).toContain(note);
81
- expect(res.structuredContent).toMatchObject({ session: { wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' } } } });
81
+ expect(res.structuredContent).toMatchObject({ session: { wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' } }, scopeContinued: { conversation: 'conv-1' } } });
82
82
  });
83
83
  });
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { rememberDeclaredScope } from './session-scope-memory.js';
2
4
  /** Must stay equal to ProjectKeyEnv in cli/internal/cmd/project_scope.go (drift test). */
3
5
  export const PROJECT_KEY_ENV = 'ORBOTO_PROJECT_KEY';
@@ -19,15 +21,29 @@ export function envProjectScope(raw = process.env.ORBOTO_PROJECT_KEY, role = pro
19
21
  export function scopeDeclared(scope) {
20
22
  return !!scope && (!!scope.role || (scope.projectKeys?.length ?? 0) > 0 || (scope.ticketKeys?.length ?? 0) > 0);
21
23
  }
22
- /** Declares the environment's project on the session row unless the row already carries a scope; answers the row. */
23
- export async function declareEnvProjectScope(client, instanceToken, raw = process.env.ORBOTO_PROJECT_KEY, role = process.env.ORBOTO_SESSION_ROLE) {
24
+ /** ORB-2593 - true while the `orboto claude` supervisor of this session waits for the conversation a bare `--resume` picks: no default scope is declared before it. */
25
+ export function supervisorScopePending(env = process.env) {
26
+ const dir = (env.ORBOTO_CLAUDE_SUPERVISOR_DIR ?? '').trim();
27
+ const launcher = Number((env.ORBOTO_LAUNCHER_PID ?? '').trim());
28
+ if (!dir || !Number.isInteger(launcher) || launcher <= 1)
29
+ return false;
30
+ try {
31
+ const state = JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8'));
32
+ return state.pid === launcher && state.scopePending === true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /** Declares the environment's project on the session row unless the row already carries a scope or the supervisor waits for a picked conversation; answers the row. */
39
+ export async function declareEnvProjectScope(client, instanceToken, raw = process.env.ORBOTO_PROJECT_KEY, role = process.env.ORBOTO_SESSION_ROLE, pending = supervisorScopePending()) {
24
40
  const scope = envProjectScope(raw, role);
25
- if (!scope)
41
+ if (!scope || pending)
26
42
  return null;
27
43
  const current = await client.get('/v1/agent/session', { instanceToken }).catch(() => null);
28
44
  if (current && scopeDeclared(current.scope))
29
45
  return current;
30
- const row = await client.post('/v1/agent/heartbeat', { scope }, { instanceToken }).catch(() => null);
46
+ const row = await client.post('/v1/agent/heartbeat', { scope, scopeDefault: true }, { instanceToken }).catch(() => null);
31
47
  if (row && typeof row.sessionId === 'string')
32
48
  rememberDeclaredScope(instanceToken, row.scope ?? scope);
33
49
  return row;
@@ -41,7 +41,7 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
41
41
  await declareEnvProjectScope(client, 'agent-abc', 'ACME');
42
42
  expect(client.calls).toEqual([
43
43
  { method: 'GET', path: '/v1/agent/session', instanceToken: 'agent-abc' },
44
- { method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] } }, instanceToken: 'agent-abc' },
44
+ { method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }, instanceToken: 'agent-abc' },
45
45
  ]);
46
46
  expect(rememberedScope('agent-abc')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
47
47
  });
@@ -79,7 +79,7 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
79
79
  });
80
80
  const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
81
81
  const res = await makeSessionStartHandler(client)({}, { sessionId: 'scope1' });
82
- expect(beats).toEqual([{}, { scope: { role: 'worker', projectKeys: ['ACME'] } }]);
82
+ expect(beats).toEqual([{}, { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }]);
83
83
  expect(res.content[0].text).toContain('projects ACME');
84
84
  expect(rememberedScope('mcp-scope1')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
85
85
  });
@@ -99,7 +99,7 @@ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
99
99
  expect(envProjectScope(undefined, 'lead')).toBeNull();
100
100
  const client = fakeClient({ sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null });
101
101
  await declareEnvProjectScope(client, 'agent-control', undefined, 'coordinator');
102
- expect(client.calls[1]).toEqual({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'coordinator' } }, instanceToken: 'agent-control' });
102
+ expect(client.calls[1]).toEqual({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'coordinator' }, scopeDefault: true }, instanceToken: 'agent-control' });
103
103
  expect(rememberedScope('agent-control')).toEqual({ role: 'coordinator' });
104
104
  });
105
105
  it('orboto_session_start without a scope declares the control session the environment names', async () => {
@@ -124,7 +124,27 @@ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
124
124
  });
125
125
  const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
126
126
  await makeSessionStartHandler(client)({}, { sessionId: 'control1' });
127
- expect(beats).toEqual([{}, { scope: { role: 'coordinator' } }]);
127
+ expect(beats).toEqual([{}, { scope: { role: 'coordinator' }, scopeDefault: true }]);
128
128
  expect(rememberedScope('mcp-control1')).toEqual({ role: 'coordinator' });
129
129
  });
130
130
  });
131
+ describe('ORB-2593 - a picker-pending orboto claude start', () => {
132
+ it('declares no default scope while the supervisor waits for the picked conversation, and declares it once the pick resolved', async () => {
133
+ const { mkdtempSync, writeFileSync } = await import('node:fs');
134
+ const { tmpdir } = await import('node:os');
135
+ const { join } = await import('node:path');
136
+ const { supervisorScopePending } = await import('./project-scope.js');
137
+ const dir = mkdtempSync(join(tmpdir(), 'orboto-supervisor-'));
138
+ const env = { ORBOTO_CLAUDE_SUPERVISOR_DIR: dir, ORBOTO_LAUNCHER_PID: '4000' };
139
+ writeFileSync(join(dir, 'state.json'), JSON.stringify({ pid: 4000, scopePending: true }));
140
+ expect(supervisorScopePending(env)).toBe(true);
141
+ expect(supervisorScopePending({ ...env, ORBOTO_LAUNCHER_PID: '4001' })).toBe(false);
142
+ const pending = fakeClient({ sessionId: 'x', scope: null });
143
+ expect(await declareEnvProjectScope(pending, 'agent-abc', 'ACME', undefined, supervisorScopePending(env))).toBeNull();
144
+ expect(pending.calls).toEqual([]);
145
+ writeFileSync(join(dir, 'state.json'), JSON.stringify({ pid: 4000 }));
146
+ const resolved = fakeClient({ sessionId: 'x', scope: null });
147
+ await declareEnvProjectScope(resolved, 'agent-abc', 'ACME', undefined, supervisorScopePending(env));
148
+ expect(resolved.calls.filter((c) => c.method === 'POST')).toEqual([{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }, instanceToken: 'agent-abc' }]);
149
+ });
150
+ });
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { hermeticHome, hermeticSpawnEnv } from './test/hermetic-cli-env.js';
8
9
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
9
10
  const cleanup = [];
10
11
  afterEach(async () => { for (const fn of cleanup.splice(0).reverse())
@@ -43,7 +44,7 @@ async function fixture() {
43
44
  await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused a nonlocal request'); return original(input, init); };`);
44
45
  const run = (args = ['session-start', '--force-rules']) => new Promise((ok, no) => {
45
46
  const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
46
- cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: `http://127.0.0.1:${address.port}`, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
47
+ cwd: dir, env: hermeticSpawnEnv({ home: hermeticHome('mcp-wrapper'), baseUrl: `http://127.0.0.1:${address.port}`, token: 'orb_fixture', env: { ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' } }),
47
48
  stdio: ['ignore', 'pipe', 'pipe'],
48
49
  });
49
50
  let out = '', err = '';
@@ -20,6 +20,10 @@ describe('ORB-2544 - the origin header', () => {
20
20
  expect(params.get('harness')).toBe('claude-code');
21
21
  expect(params.get('client')).toBe('orboto-mcp');
22
22
  });
23
+ it('ORB-2593 - the handed origin keeps the launch, the channel server verdict, the conversation and the checkout', () => {
24
+ const params = new URLSearchParams(stdioOrigin({ ORBOTO_ORIGIN: 'pid=99&harness=claude-code&launch=orboto-claude&server=running&conv=conv-1111&checkout=0123456789abcdef' }));
25
+ expect([params.get('launch'), params.get('server'), params.get('conv'), params.get('checkout')]).toEqual(['orboto-claude', 'running', 'conv-1111', '0123456789abcdef']);
26
+ });
23
27
  it('ORB-2571 - a stdio proxy names the harness orboto <harness> started and its model, else the harness markers', () => {
24
28
  expect(stdioHarness({ ORBOTO_HARNESS: 'kimi', CLAUDECODE: '1' })).toBe('kimi');
25
29
  expect(stdioHarness({ ORBOTO_HARNESS: 'antigravity' })).toBe('agy');
@@ -0,0 +1,20 @@
1
+ import { expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ const src = join(process.cwd(), 'src');
5
+ function* testFiles(dir) {
6
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
7
+ const full = join(dir, entry.name);
8
+ if (entry.isDirectory())
9
+ yield* testFiles(full);
10
+ else if (entry.name.endsWith('.test.ts') && entry.name !== 'child-window-test-timeout.test.ts')
11
+ yield full;
12
+ }
13
+ }
14
+ it('every mcp test using childWindow() sets its own ciWindow test timeout above it', () => {
15
+ const offenders = [...testFiles(src)].filter((file) => {
16
+ const text = readFileSync(file, 'utf8');
17
+ return text.includes('childWindow()') && !/\}, ciWindow\(\d[\d_]*\)\);/.test(text);
18
+ });
19
+ expect(offenders).toEqual([]);
20
+ });
@@ -0,0 +1,8 @@
1
+ /** ORB-2231 - a wait window that grows fivefold on the loaded shared CI runner; mirrors apps/api/src/test/ci-window.ts and imports no vitest on purpose. */
2
+ export function ciWindow(ms) {
3
+ return process.env.CI ? ms * 5 : ms;
4
+ }
5
+ /** ORB-2587 - the hang guard of one child process: 30 s, 5x on CI; the mcp runner timeout is 30 s unscaled, so a test using it sets its own ciWindow(...) test timeout. */
6
+ export function childWindow() {
7
+ return ciWindow(30_000);
8
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /** ORB-2590 - the environment of every test that spawns the Go CLI or the skill wrapper: an allowlist, never process.env, so a developer's ~/.orboto bindings, ORBOTO_* and the parent harness's CLAUDE* markers cannot reach the child. */
5
+ /** Runtime essentials and settings that do not identify the developer: the CLI reads CI (browser timeouts), a child may need the proxy, certificate and locale variables to reach the network. */
6
+ export const INHERITED_NAMES = [
7
+ 'PATH', 'SystemRoot', 'SYSTEMROOT', 'WINDIR', 'ComSpec', 'PATHEXT', 'CI',
8
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
9
+ 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'LANG', 'LC_ALL', 'TZ',
10
+ ];
11
+ const homes = new Set();
12
+ /** A fresh empty HOME for one test; removed when the test process exits. */
13
+ export function hermeticHome(label = 'spawn') {
14
+ const dir = mkdtempSync(join(tmpdir(), `orboto-hermetic-${label}-`));
15
+ homes.add(dir);
16
+ return dir;
17
+ }
18
+ process.once('exit', () => { for (const dir of homes) {
19
+ try {
20
+ rmSync(dir, { recursive: true, force: true });
21
+ }
22
+ catch { /* best effort */ }
23
+ } });
24
+ /** HOME, USERPROFILE, XDG and temp dirs all inside `home`, the runtime essentials, an explicit instance, and only the `env` entries the test names. */
25
+ export function hermeticSpawnEnv({ home, baseUrl, token, profile, env = {} }, inherited = process.env) {
26
+ const out = {
27
+ HOME: home, USERPROFILE: home, TMPDIR: home, TMP: home, TEMP: home,
28
+ XDG_CONFIG_HOME: join(home, '.config'), XDG_STATE_HOME: join(home, '.local', 'state'), XDG_CACHE_HOME: join(home, '.cache'),
29
+ };
30
+ for (const name of INHERITED_NAMES) {
31
+ const value = inherited[name];
32
+ if (value)
33
+ out[name] = value;
34
+ }
35
+ if (baseUrl !== undefined)
36
+ out.ORBOTO_BASE_URL = baseUrl;
37
+ if (token !== undefined)
38
+ out.ORBOTO_TOKEN = token;
39
+ if (profile !== undefined)
40
+ out.ORBOTO_PROFILE = profile;
41
+ return { ...out, ...env };
42
+ }
@@ -121,9 +121,13 @@ export function noRecipientText(err) {
121
121
  return null;
122
122
  const p = body.errorParams ?? {};
123
123
  const live = (p.liveSessions ?? []).map((s) => `${s.addresses[0] ?? s.email} (${s.email}, ${s.shortId})`);
124
+ const ways = 'a role address in `to` (integrator@KEY), `toSessionRef` with an instance short id, or `queue: true` with `project` to keep it until a session declares the project';
125
+ if (p.reason === 'ambiguous_account') {
126
+ return `not sent: ${p.address ?? 'that account'} has ${live.length} live sessions you may address and a request needs one of them. Sessions: ${live.join('; ')}. Send again with \`toSessionRef\` set to the instance short id of one, or address a role (${ways}).`;
127
+ }
124
128
  return `not sent: no live session can receive a request to ${p.address ?? '?'} (${p.reason ?? 'no_recipient'}). `
125
- + (live.length ? `Live sessions: ${live.join('; ')}. ` : 'No live session declared the project. ')
126
- + 'Address one of them, or send again with queue: true and project to keep it until a session declares the project.';
129
+ + (live.length ? `Live sessions: ${live.join('; ')}. ` : 'No live session answers there. ')
130
+ + `Ways on: ${ways}.`;
127
131
  }
128
132
  /** ORB-2542 - the 409 `owner_mismatch` as a tool error naming the owner and both ways on. */
129
133
  export function ownerMismatchText(err) {
@@ -159,14 +163,15 @@ export function reminderTooSoonText(err) {
159
163
  }
160
164
  export const agentNotifyToolConfig = {
161
165
  title: 'Notify an agent or user',
162
- description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
166
+ description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). outcome: one sentence, 10 to 1000 characters; body: the plain text of the message (any length, lands in payload.body; payload may carry other JSON); threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request to an account email without project or toSessionRef goes to its one live session, with several it is refused listing them (pick one with toSessionRef). A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
163
167
  inputSchema: z.object({
164
168
  targetEmail: z.string().email().optional(),
165
169
  to: z.string().min(3).max(200).optional(),
166
170
  queue: z.boolean().optional(),
167
171
  kind: z.enum(['info', 'request', 'complete', 'error']).default('info'),
168
172
  subject: z.string().min(1).max(200),
169
- outcome: z.string().min(10).max(1000).optional(),
173
+ outcome: z.string().optional(),
174
+ body: z.string().optional(),
170
175
  payload: z.record(z.string(), z.unknown()).optional(),
171
176
  threadId: z.string().uuid().optional(),
172
177
  project: z.string().min(1).max(64).optional(),
@@ -213,17 +218,41 @@ export function makeAgentBroadcastHandler(client) {
213
218
  };
214
219
  };
215
220
  }
216
- /** ORB-2223 - the message-kind definition this versioned sender enforces; the API only warns. */
221
+ export const NOTIFY_OUTCOME_MIN = 10;
222
+ export const NOTIFY_OUTCOME_MAX = 1000;
223
+ export const NOTIFY_BODY_MAX_BYTES = 900 * 1024;
224
+ /** ORB-2555 - a complete example call for a kind, the answer to every refusal. */
225
+ export function notifyExample(kind, to) {
226
+ const target = to ?? 'integrator@KEY';
227
+ switch (kind) {
228
+ case 'request': return `orboto_agent_notify { to: "${target}", kind: "request", subject: "<one line>", outcome: "<what must be true when it is done>", body: "<details, any length>" }`;
229
+ case 'complete': return `orboto_agent_notify { to: "${target}", kind: "complete", subject: "<one line>", outcome: "<what is now true>", threadId: "<message id answered>" }`;
230
+ case 'error': return `orboto_agent_notify { to: "${target}", kind: "error", subject: "<one line>", body: "<what failed and what you tried>" }`;
231
+ default: return `orboto_agent_notify { to: "${target}", kind: "info", subject: "<one line>", body: "<text>" }`;
232
+ }
233
+ }
234
+ /** ORB-2223 - the message-kind definition this versioned sender enforces; the API only warns. ORB-2555 - every refusal names the flag and carries a complete example. */
217
235
  export function notifyDefinitionError(args) {
218
236
  const kind = args.kind ?? 'info';
237
+ const example = (text) => `Invalid arguments: ${text} Example: ${notifyExample(kind, args.to)}`;
219
238
  if (!args.to && !args.targetEmail)
220
- return 'Invalid arguments: name a recipient - `to` (integrator@KEY, an email or a session id) or `targetEmail`.';
221
- if ((kind === 'request' || kind === 'complete') && !args.outcome?.trim()) {
222
- return `Invalid arguments: \`outcome\` is required for a ${kind} - the outcome sentence (what must be true when it is done, or what was delivered).`;
239
+ return example('name a recipient - `to` (integrator@KEY, an email or a session id) or `targetEmail`.');
240
+ const outcome = args.outcome?.trim() ?? '';
241
+ if ((kind === 'request' || kind === 'complete') && !outcome) {
242
+ return example(`\`outcome\` is required for a ${kind} - the outcome sentence (what must be true when it is done, or what was delivered), ${NOTIFY_OUTCOME_MIN} to ${NOTIFY_OUTCOME_MAX} characters.`);
223
243
  }
224
- if (kind === 'request' && !args.project && !args.toSessionRef && !(args.to && !args.to.includes('.'))) {
225
- return 'Invalid arguments: a request needs a target - a role address in `to` (integrator@KEY), `project` (key or UUID) or `toSessionRef`.';
244
+ if (outcome && outcome.length < NOTIFY_OUTCOME_MIN)
245
+ return example(`\`outcome\` has ${outcome.length} characters, at least ${NOTIFY_OUTCOME_MIN} are needed.`);
246
+ if (outcome.length > NOTIFY_OUTCOME_MAX) {
247
+ return example(`\`outcome\` has ${outcome.length} characters, at most ${NOTIFY_OUTCOME_MAX} are allowed: keep the outcome to one sentence and put the long text into \`body\` (any length, it lands in payload.body).`);
226
248
  }
249
+ if (args.body !== undefined && !args.body.trim())
250
+ return example('`body` is empty - give the text or leave it out.');
251
+ if (args.body !== undefined && args.payload && 'body' in args.payload)
252
+ return example('`body` and `payload.body` are both set - use one of them.');
253
+ const bodyBytes = args.body === undefined ? 0 : Buffer.byteLength(args.body.replace(/[\r\n]+$/, ''));
254
+ if (bodyBytes > NOTIFY_BODY_MAX_BYTES)
255
+ return example(`\`body\` has ${bodyBytes} bytes, at most ${NOTIFY_BODY_MAX_BYTES} (900 KiB) are allowed: split it into several messages or attach a file with the CLI.`);
227
256
  return null;
228
257
  }
229
258
  export function makeAgentNotifyHandler(client) {
@@ -234,8 +263,9 @@ export function makeAgentNotifyHandler(client) {
234
263
  const senderRef = mcpInstanceToken(args.senderRef, extra);
235
264
  let res;
236
265
  try {
237
- const { urgent, ...rest } = args;
238
- res = await client.post('/v1/agent/notify', { ...rest, ...(urgent ? { priority: NOTIFY_PRIORITIES[1] } : {}), senderRef, ownerCheck: 'refuse' }, { instanceToken: mcpInstanceToken(undefined, extra) });
266
+ const { urgent, body, ...rest } = args;
267
+ const payload = body !== undefined ? { ...(rest.payload ?? {}), body: body.replace(/[\r\n]+$/, '') } : rest.payload;
268
+ res = await client.post('/v1/agent/notify', { ...rest, ...(payload ? { payload } : {}), ...(urgent ? { priority: NOTIFY_PRIORITIES[1] } : {}), senderRef, ownerCheck: 'refuse' }, { instanceToken: mcpInstanceToken(undefined, extra) });
239
269
  }
240
270
  catch (err) {
241
271
  const refused = err instanceof OrbotoApiError ? noRecipientText(err) ?? ownerMismatchText(err) ?? reminderTooSoonText(err) : null;
@@ -141,11 +141,13 @@ describe('orboto_agent_notify', () => {
141
141
  const refused = await handler({ targetEmail: 'bob@example.com', kind: 'request', subject: 'Please review ORB-42' });
142
142
  expect(refused.isError).toBe(true);
143
143
  expect(refused.content[0].text).toContain('`outcome` is required for a request');
144
- const noTarget = await handler({ targetEmail: 'bob@example.com', kind: 'request', subject: 'Please review ORB-42', outcome: 'ORB-42 has a review verdict' });
145
- expect(noTarget.content[0].text).toContain('`project` (key or UUID) or `toSessionRef`');
146
144
  const noOutcome = await handler({ targetEmail: 'bob@example.com', kind: 'complete', subject: 'Done' });
147
145
  expect(noOutcome.content[0].text).toContain('`outcome` is required for a complete');
148
146
  expect(capturedBody).toHaveLength(0);
147
+ const bareEmail = await handler({ targetEmail: 'bob@example.com', kind: 'request', subject: 'Please review ORB-42', outcome: 'ORB-42 has a review verdict' });
148
+ expect(bareEmail.isError).toBeUndefined();
149
+ expect(capturedBody).toHaveLength(1);
150
+ capturedBody.length = 0;
149
151
  const result = await handler({
150
152
  targetEmail: 'bob@example.com',
151
153
  kind: 'request',
@@ -348,6 +350,84 @@ describe('orboto_agent_notify role addresses (ORB-2264)', () => {
348
350
  expect(agentNotifyToolConfig.description).toContain('integrator@KEY');
349
351
  });
350
352
  });
353
+ describe('orboto_agent_notify body and refusals (ORB-2555)', () => {
354
+ const sentBodies = () => {
355
+ const bodies = [];
356
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
357
+ bodies.push(JSON.parse(String(init.body)));
358
+ return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000001', toSessionId: null, routedTo: 'account' }) };
359
+ });
360
+ return bodies;
361
+ };
362
+ it('maps body into payload.body next to other payload keys and carries text beyond the outcome cap', async () => {
363
+ const bodies = sentBodies();
364
+ const long = 'detail line\n'.repeat(400);
365
+ await makeAgentNotifyHandler(client)({ to: 'worker@ORB', kind: 'complete', subject: 'report', outcome: 'the report is delivered in full', body: long, payload: { ticketKey: 'ORB-1' } });
366
+ expect(bodies[0].payload).toEqual({ ticketKey: 'ORB-1', body: long.trimEnd() });
367
+ expect(bodies[0].body).toBeUndefined();
368
+ await makeAgentNotifyHandler(client)({ to: 'worker@ORB', subject: 'plain', body: 'text' });
369
+ expect(bodies[1].payload).toEqual({ body: 'text' });
370
+ });
371
+ it('trims trailing newlines of the body and counts its limit in bytes like the CLI', async () => {
372
+ const bodies = sentBodies();
373
+ const handler = makeAgentNotifyHandler(client);
374
+ await handler({ to: 'worker@ORB', subject: 's', body: 'text\r\n\n' });
375
+ expect(bodies[0].payload).toEqual({ body: 'text' });
376
+ const tooBig = await handler({ to: 'worker@ORB', subject: 's', body: 'ä'.repeat(500 * 1024) });
377
+ expect(tooBig.content[0].text).toContain('bytes, at most 921600 (900 KiB)');
378
+ expect(bodies).toHaveLength(1);
379
+ });
380
+ it('refuses an outcome over 1000 characters with the remedy and a complete example, sending nothing', async () => {
381
+ const bodies = sentBodies();
382
+ const result = await makeAgentNotifyHandler(client)({ to: 'integrator@ORB', kind: 'complete', subject: 'report', outcome: 'x'.repeat(1001) });
383
+ expect(result.isError).toBe(true);
384
+ const text = result.content[0].text;
385
+ expect(text).toContain('`outcome` has 1001 characters, at most 1000');
386
+ expect(text).toContain('put the long text into `body`');
387
+ expect(text).toContain('Example: orboto_agent_notify { to: "integrator@ORB", kind: "complete"');
388
+ expect(bodies).toHaveLength(0);
389
+ });
390
+ it('names the missing field with an example per kind', async () => {
391
+ const bodies = sentBodies();
392
+ const handler = makeAgentNotifyHandler(client);
393
+ const text = async (args) => (await handler(args)).content[0].text;
394
+ expect(await text({ to: 'integrator@ORB', kind: 'request', subject: 's' })).toContain('outcome: "<what must be true when it is done>", body: "<details, any length>"');
395
+ expect(await text({ to: 'integrator@ORB', kind: 'error', subject: 's', outcome: 'short' })).toContain('`outcome` has 5 characters, at least 10');
396
+ expect(await text({ to: 'integrator@ORB', subject: 's', body: ' ' })).toContain('`body` is empty');
397
+ expect(await text({ to: 'integrator@ORB', subject: 's', body: 'x', payload: { body: 'y' } })).toContain('`body` and `payload.body` are both set');
398
+ expect(await text({ subject: 's' })).toContain('Example: orboto_agent_notify { to: "integrator@KEY", kind: "info"');
399
+ expect(bodies).toHaveLength(0);
400
+ });
401
+ it('turns the ambiguous account 409 into a tool error listing the sessions and asking for toSessionRef', async () => {
402
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
403
+ ok: false, status: 409, statusText: 'Conflict', json: async () => ({}),
404
+ text: async () => JSON.stringify({
405
+ errorKey: 'errors.agents.no_recipient',
406
+ errorParams: { address: 'claude@orboto.io', reason: 'ambiguous_account', liveSessions: [
407
+ { shortId: 'f072e1c5', email: 'claude@orboto.io', addresses: ['worker@ORB'] }, { shortId: 'a072e1c5', email: 'claude@orboto.io', addresses: ['review@ORB'] },
408
+ ] },
409
+ }),
410
+ }));
411
+ const result = await makeAgentNotifyHandler(client)({ targetEmail: 'claude@orboto.io', kind: 'request', subject: 's', outcome: 'ORB-1 is reviewed with findings' });
412
+ const text = result.content[0].text;
413
+ expect(result.isError).toBe(true);
414
+ expect(text).toContain('has 2 live sessions');
415
+ expect(text).toContain('worker@ORB (claude@orboto.io, f072e1c5)');
416
+ expect(text).toContain('`toSessionRef`');
417
+ expect(text).toContain('integrator@KEY');
418
+ });
419
+ it('names every way on when the account has no live session', async () => {
420
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
421
+ ok: false, status: 409, statusText: 'Conflict', json: async () => ({}),
422
+ text: async () => JSON.stringify({ errorKey: 'errors.agents.no_recipient', errorParams: { address: 'claude@orboto.io', reason: 'no_recipient', project: null, liveSessions: [] } }),
423
+ }));
424
+ const result = await makeAgentNotifyHandler(client)({ targetEmail: 'claude@orboto.io', kind: 'request', subject: 's', outcome: 'ORB-1 is reviewed with findings' });
425
+ const text = result.content[0].text;
426
+ expect(text).toContain('a role address in `to` (integrator@KEY)');
427
+ expect(text).toContain('`toSessionRef`');
428
+ expect(text).toContain('`queue: true` with `project`');
429
+ });
430
+ });
351
431
  describe('orboto_agent_notify dedupe and reminders (ORB-2563)', () => {
352
432
  afterEach(() => { vi.restoreAllMocks(); });
353
433
  it('mirrors the priorities and sender lines of the shared schema', () => {
@@ -9,6 +9,8 @@ import { createServer } from 'node:http';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
+ import { childWindow, ciWindow } from '../test/ci-window.js';
13
+ import { hermeticHome, hermeticSpawnEnv } from '../test/hermetic-cli-env.js';
12
14
  const DOC = 'd0c00000-2222-4222-8222-222222222222';
13
15
  const REV = 'bbbbbbbb-0000-4000-8000-000000000000';
14
16
  it('doc edit appends by default, --replace names the replaced revision, doc restore posts the restore', async () => {
@@ -45,8 +47,8 @@ it('doc edit appends by default, --replace names the replaced revision, doc rest
45
47
  await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused nonlocal request'); return original(input, init); };`);
46
48
  const run = (args) => new Promise((ok, no) => {
47
49
  const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
48
- cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: origin, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
49
- timeout: 20_000,
50
+ cwd: dir, env: hermeticSpawnEnv({ home: hermeticHome('mcp-wrapper'), baseUrl: origin, token: 'orb_fixture', env: { ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' } }),
51
+ timeout: childWindow(),
50
52
  });
51
53
  let out = '', err = '';
52
54
  child.stdout.on('data', (chunk) => { out += chunk.toString(); });
@@ -78,4 +80,4 @@ it('doc edit appends by default, --replace names the replaced revision, doc rest
78
80
  await new Promise((ok, no) => server.close((err) => err ? no(err) : ok()));
79
81
  await rm(dir, { recursive: true, force: true });
80
82
  }
81
- }, 60_000);
83
+ }, ciWindow(60_000));
@@ -4,6 +4,8 @@ import { createServer } from 'node:http';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
+ import { childWindow, ciWindow } from '../test/ci-window.js';
8
+ import { hermeticHome, hermeticSpawnEnv } from '../test/hermetic-cli-env.js';
7
9
  it('real wrapper preserves piped diff text and version-prefixed check/record values', async () => {
8
10
  const dir = await mkdtemp(join(tmpdir(), 'orboto-1964-verify-temp-'));
9
11
  const fingerprint = 'sha256-diff-v2:' + 'a'.repeat(64);
@@ -35,8 +37,8 @@ it('real wrapper preserves piped diff text and version-prefixed check/record val
35
37
  await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused nonlocal request'); return original(input, init); };`);
36
38
  const run = (args, stdin = '') => new Promise((ok, no) => {
37
39
  const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
38
- cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: origin, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
39
- timeout: 20_000,
40
+ cwd: dir, env: hermeticSpawnEnv({ home: hermeticHome('mcp-wrapper'), baseUrl: origin, token: 'orb_fixture', env: { ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' } }),
41
+ timeout: childWindow(),
40
42
  });
41
43
  let out = '', err = '';
42
44
  child.stdout.on('data', (chunk) => { out += chunk.toString(); });
@@ -59,4 +61,4 @@ it('real wrapper preserves piped diff text and version-prefixed check/record val
59
61
  await new Promise((ok, no) => server.close((err) => err ? no(err) : ok()));
60
62
  await rm(dir, { recursive: true, force: true });
61
63
  }
62
- }, 60_000);
64
+ }, ciWindow(60_000));
@@ -8,6 +8,8 @@ import { createServer } from 'node:http';
8
8
  import { spawn } from 'node:child_process';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
+ import { childWindow, ciWindow } from '../test/ci-window.js';
12
+ import { hermeticHome, hermeticSpawnEnv } from '../test/hermetic-cli-env.js';
11
13
  const LINE = 'semantic search unavailable, showing full-text results';
12
14
  it('search, search-docs, ask-docs and check-similar print the degraded line on stderr', async () => {
13
15
  const dir = await mkdtemp(join(tmpdir(), 'orboto-2543-wrapper-'));
@@ -45,8 +47,8 @@ it('search, search-docs, ask-docs and check-similar print the degraded line on s
45
47
  await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused nonlocal request'); return original(input, init); };`);
46
48
  const run = (args) => new Promise((ok, no) => {
47
49
  const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
48
- cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: origin, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
49
- timeout: 20_000,
50
+ cwd: dir, env: hermeticSpawnEnv({ home: hermeticHome('mcp-wrapper'), baseUrl: origin, token: 'orb_fixture', env: { ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' } }),
51
+ timeout: childWindow(),
50
52
  });
51
53
  let out = '', err = '';
52
54
  child.stdout.on('data', (chunk) => { out += chunk.toString(); });
@@ -67,4 +69,4 @@ it('search, search-docs, ask-docs and check-similar print the degraded line on s
67
69
  await new Promise((ok, no) => server.close((err) => err ? no(err) : ok()));
68
70
  await rm(dir, { recursive: true, force: true });
69
71
  }
70
- }, 60_000);
72
+ }, ciWindow(60_000));
@@ -222,7 +222,7 @@ export function makeSessionStartHandler(client, opts = {}) {
222
222
  }
223
223
  const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? {
224
224
  sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt,
225
- wake: r.wake, scopeExpired: r.scopeExpired ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
225
+ wake: r.wake, scopeExpired: r.scopeExpired ?? null, scopeContinued: r.scopeContinued ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
226
226
  } : null);
227
227
  const host = localSessionHost(extra);
228
228
  let registration = await client.post('/v1/agent/heartbeat', { ...(input.scope !== undefined ? { scope: input.scope } : {}), ...(host ? { clientInfo: { host } } : {}) }, { instanceToken })
@@ -383,6 +383,7 @@ export function makeSessionStartHandler(client, opts = {}) {
383
383
  ref: instanceToken, id: registration?.sessionId ?? null, scope: registration?.scope ?? null,
384
384
  reconnects: registration?.reconnects ?? 0, reconnectedAt: registration?.lastReconnectAt ?? null,
385
385
  ...(registration?.wake ? { wake: registration.wake } : {}), scopeExpired: registration?.scopeExpired ?? null,
386
+ scopeContinued: registration?.scopeContinued ?? null,
386
387
  },
387
388
  gitHealth: {
388
389
  unhealthy: gitHealthWithConnections
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.224.0",
3
+ "version": "0.226.2",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",