@orboto/mcp 0.222.2 → 0.224.0

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.
@@ -0,0 +1,49 @@
1
+ /** ORB-2576 - the agent context of a stdio MCP server, read exactly as the CLI reads it (cli/internal/cmd/session_context.go). */
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ /** The session id Claude Code hands its stdio MCP servers; an HTTP server never sees it. */
6
+ export const CLAUDE_SESSION_ENV = 'CLAUDE_CODE_SESSION_ID';
7
+ /** The Claude Code hook events the CLI registers (claude_hook_settings.go OrbotoHookEvents). */
8
+ export const ORBOTO_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop', 'StopFailure', 'SessionStart', 'SubagentStart', 'SubagentStop', 'SessionEnd', 'Notification'];
9
+ /** The project settings file the hooks are registered in. */
10
+ export const PROJECT_CLAUDE_SETTINGS = '.claude/settings.local.json';
11
+ /** The turn-state file name the CLI hook writes for a session. */
12
+ export function turnStateName(sessionId) {
13
+ return `${sessionId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) || 'session'}.json`;
14
+ }
15
+ /** The orboto hook events a checkout's settings lack; null when the file cannot be read. */
16
+ export function missingTurnHooks(dir) {
17
+ let hooks = {};
18
+ try {
19
+ hooks = JSON.parse(readFileSync(join(dir, PROJECT_CLAUDE_SETTINGS), 'utf8')).hooks ?? {};
20
+ }
21
+ catch (err) {
22
+ if (err?.code !== 'ENOENT')
23
+ return null;
24
+ }
25
+ const covered = (groups) => Array.isArray(groups) && groups.some((g) => Array.isArray(g?.hooks)
26
+ && (g.hooks).some((h) => String(h?.command ?? '').includes('claude-hook')));
27
+ return ORBOTO_HOOK_EVENTS.filter((event) => !covered(hooks[event]));
28
+ }
29
+ /** The context key, or '' when no complete hook set confirms the current generation or a subagent shares the session. */
30
+ export function agentContextKey(env = process.env, home = homedir()) {
31
+ const id = (env[CLAUDE_SESSION_ENV] ?? '').trim();
32
+ if (!id)
33
+ return '';
34
+ let state;
35
+ try {
36
+ state = JSON.parse(readFileSync(join(home, '.orboto', 'claude-turn', turnStateName(id)), 'utf8'));
37
+ }
38
+ catch {
39
+ return '';
40
+ }
41
+ if (!state.state || typeof state.contextStartedAt !== 'string' || !state.contextStartedAt)
42
+ return '';
43
+ if (Array.isArray(state.activeSubagents) && state.activeSubagents.length > 0)
44
+ return '';
45
+ const missing = typeof state.cwd === 'string' ? missingTurnHooks(state.cwd) : null;
46
+ if (!missing || missing.length > 0)
47
+ return '';
48
+ return `claude-code ${id} ${state.contextStartedAt}`;
49
+ }
@@ -0,0 +1,45 @@
1
+ /** ORB-2576 - a stdio server reads the agent context the CLI hook recorded; anything else is an unknown context. */
2
+ import { describe, expect, it } from 'vitest';
3
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ORBOTO_HOOK_EVENTS, agentContextKey, missingTurnHooks, turnStateName } from './agent-context.js';
7
+ function hooks(dir, events = ORBOTO_HOOK_EVENTS) {
8
+ mkdirSync(join(dir, '.claude'), { recursive: true });
9
+ writeFileSync(join(dir, '.claude', 'settings.local.json'), JSON.stringify({ hooks: Object.fromEntries(events.map((e) => [e, [{ hooks: [{ type: 'command', command: 'orboto claude-hook' }] }]])) }));
10
+ }
11
+ function home(session, contextStartedAt, extra = {}) {
12
+ const dir = mkdtempSync(join(tmpdir(), 'orboto-mcp-context-'));
13
+ hooks(dir);
14
+ if (session) {
15
+ mkdirSync(join(dir, '.orboto', 'claude-turn'), { recursive: true });
16
+ writeFileSync(join(dir, '.orboto', 'claude-turn', turnStateName(session)), JSON.stringify({ sessionId: session, cwd: dir, state: 'idle', contextStartedAt, ...extra }));
17
+ }
18
+ return dir;
19
+ }
20
+ describe('agentContextKey (ORB-2576)', () => {
21
+ it('names the Claude Code session and the generation its SessionStart hook recorded', () => {
22
+ expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, home('sess-a', '2026-10-18T10:00:00Z'))).toBe('claude-code sess-a 2026-10-18T10:00:00Z');
23
+ });
24
+ it('is unknown without the session id, without the hook record, or without a generation', () => {
25
+ expect(agentContextKey({}, home('sess-a', '2026-10-18T10:00:00Z'))).toBe('');
26
+ expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, home())).toBe('');
27
+ expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, home('sess-a'))).toBe('');
28
+ });
29
+ it('a subagent inside a running session gets the rules in full: its session id is the parent\'s, so the context is unknown while it runs', () => {
30
+ expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, home('sess-a', '2026-10-18T10:00:00Z', { activeSubagents: ['agent-1'] }))).toBe('');
31
+ });
32
+ it('a hook set without the subagent events is an unknown context', () => {
33
+ const dir = home('sess-a', '2026-10-18T10:00:00Z');
34
+ hooks(dir, ORBOTO_HOOK_EVENTS.filter((e) => !e.startsWith('Subagent')));
35
+ expect(missingTurnHooks(dir)).toEqual(['SubagentStart', 'SubagentStop']);
36
+ expect(agentContextKey({ CLAUDE_CODE_SESSION_ID: 'sess-a' }, dir)).toBe('');
37
+ });
38
+ it('registers the same hook events as the CLI and names the turn file like it does', () => {
39
+ const go = readFileSync(new URL('../../../cli/internal/cmd/claude_hook.go', import.meta.url), 'utf8') + readFileSync(new URL('../../../cli/internal/cmd/claude_hook_settings.go', import.meta.url), 'utf8');
40
+ for (const event of ORBOTO_HOOK_EVENTS)
41
+ expect(go).toContain(`"${event}"`);
42
+ expect(turnStateName('a/b..c')).toBe('abc.json');
43
+ expect(turnStateName('///')).toBe('session.json');
44
+ });
45
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ORB-2584 - a stdio server is a wake channel only when its harness delivers
3
+ * one: `orboto claude` marks the start in ORBOTO_CLAUDE_CHANNEL (the Go
4
+ * `orboto mcp serve` checks that the marker names this harness's launcher),
5
+ * or the operator opts in with ORBOTO_MCP_CHANNEL=1. `orboto mcp serve` runs
6
+ * this server at the instance's version, so a launching CLI released before
7
+ * the marker, and a start with no CLI at all, keep the channel they always had
8
+ * (`legacy`). Anything else runs as a plain tool server.
9
+ */
10
+ export const CLAUDE_CHANNEL_ENV = 'ORBOTO_CLAUDE_CHANNEL';
11
+ /** Mirror of CHANNEL_START_CUTOVER_VERSION in @orboto/shared-schema agent-session-wake.ts. */
12
+ export const CHANNEL_START_CUTOVER_VERSION = '0.224.0';
13
+ function semver(raw) {
14
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(raw.trim());
15
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
16
+ }
17
+ /** Mirror of releasedBeforeChannelStart: a released version below the cut-over; a dev build, 0.0.0 or no version is current. */
18
+ export function releasedBeforeChannelStart(version) {
19
+ const parsed = version ? semver(version) : null;
20
+ const cut = semver(CHANNEL_START_CUTOVER_VERSION);
21
+ if (!parsed || parsed.every((n) => n === 0))
22
+ return false;
23
+ for (let i = 0; i < 3; i += 1)
24
+ if (parsed[i] !== cut[i])
25
+ return parsed[i] < cut[i];
26
+ return false;
27
+ }
28
+ /** The version of the CLI that launched this server, from the origin it handed over in ORBOTO_ORIGIN; null without one. */
29
+ function launchingCliVersion(env) {
30
+ const handed = (env.ORBOTO_ORIGIN ?? '').trim();
31
+ if (!handed)
32
+ return null;
33
+ return new URLSearchParams(handed).get('version')?.trim() ?? '';
34
+ }
35
+ /**
36
+ * The start that delivers this server's channel, or null. ORBOTO_MCP_CHANNEL=0 wins, then the launcher marker, then
37
+ * ORBOTO_MCP_CHANNEL=1; without them a launching CLI released before the marker (its ORBOTO_ORIGIN version, or only
38
+ * ORBOTO_MCP_CLI_OUTDATED from a CLI too old to hand an origin over) and a start without any CLI are `legacy`.
39
+ */
40
+ export function channelStartFromEnv(env = process.env) {
41
+ const flag = (env.ORBOTO_MCP_CHANNEL ?? '').trim();
42
+ if (flag === '0')
43
+ return null;
44
+ if (/^[1-9]\d{0,9}$/.test((env[CLAUDE_CHANNEL_ENV] ?? '').trim()))
45
+ return 'orboto-claude';
46
+ if (flag === '1')
47
+ return 'explicit';
48
+ const cli = launchingCliVersion(env);
49
+ if (cli !== null)
50
+ return releasedBeforeChannelStart(cli) ? 'legacy' : null;
51
+ return 'legacy';
52
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ORB-2584 - the stdio server is a wake channel only for a start that
3
+ * delivers one; the session-start digest prints the lines the server renders.
4
+ */
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { CHANNEL_START_CUTOVER_VERSION, channelStartFromEnv, CLAUDE_CHANNEL_ENV, releasedBeforeChannelStart } from './channel-start.js';
7
+ import { channelClientInfo, toolServerHeartbeat } from './claude-context.js';
8
+ import { OrbotoClient } from './orboto-client.js';
9
+ import { makeSessionStartHandler } from './tools/session-start.js';
10
+ import { whoLine } from './tools/agent-who.js';
11
+ import { AGENT_WAKE_LABELS } from './tools/agent-presence-schema.js';
12
+ import { readFileSync } from 'node:fs';
13
+ import { resolve } from 'node:path';
14
+ afterEach(() => { vi.restoreAllMocks(); });
15
+ describe('ORB-2584 - the channel needs a channel-enabled start', () => {
16
+ const origin = (version) => `host=build-host&pid=4100&client=orboto-cli&version=${version}&harness=claude-code`;
17
+ it('a plain claude launched by a current CLI runs a tool server; orboto claude marks it; ORBOTO_MCP_CHANNEL=0 always wins', () => {
18
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin(CHANNEL_START_CUTOVER_VERSION) })).toBeNull();
19
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin('0.224.3'), CLAUDECODE: '1' })).toBeNull();
20
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin(CHANNEL_START_CUTOVER_VERSION), [CLAUDE_CHANNEL_ENV]: '4242' })).toBe('orboto-claude');
21
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin(CHANNEL_START_CUTOVER_VERSION), [CLAUDE_CHANNEL_ENV]: 'yes' })).toBeNull();
22
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin(CHANNEL_START_CUTOVER_VERSION), ORBOTO_MCP_CHANNEL: '1' })).toBe('explicit');
23
+ expect(channelStartFromEnv({ ORBOTO_MCP_CHANNEL: '0', [CLAUDE_CHANNEL_ENV]: '4242' })).toBeNull();
24
+ expect(channelStartFromEnv({ ORBOTO_MCP_CHANNEL: '0' })).toBeNull();
25
+ });
26
+ it('a launching CLI released before the marker keeps the channel as legacy, read from ORBOTO_ORIGIN or, for a CLI too old to hand one over, ORBOTO_MCP_CLI_OUTDATED; a dev CLI is current', () => {
27
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin('0.223.0') })).toBe('legacy');
28
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin('0.223.1'), ORBOTO_MCP_CLI_OUTDATED: 'orboto CLI 0.223.1 is older than the instance (0.224.0)' })).toBe('legacy');
29
+ expect(channelStartFromEnv({ ORBOTO_MCP_CLI_OUTDATED: 'orboto CLI 0.220.4 is older than the instance (0.224.0)' })).toBe('legacy');
30
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin('0.224.0'), ORBOTO_MCP_CLI_OUTDATED: 'orboto CLI 0.224.0 is older than the instance (0.224.1)' })).toBeNull();
31
+ expect(channelStartFromEnv({ ORBOTO_ORIGIN: origin('dev') })).toBeNull();
32
+ });
33
+ it('a start without any CLI (a hand-configured npx entry) keeps the channel it had before the marker, as legacy', () => {
34
+ expect(channelStartFromEnv({})).toBe('legacy');
35
+ expect(channelStartFromEnv({ CLAUDECODE: '1' })).toBe('legacy');
36
+ });
37
+ it('the cut-over mirrors @orboto/shared-schema', () => {
38
+ const shared = readFileSync(resolve(process.cwd(), '../../packages/shared-schema/src/agent-session-wake.ts'), 'utf8');
39
+ expect(shared).toContain(`export const CHANNEL_START_CUTOVER_VERSION = '${CHANNEL_START_CUTOVER_VERSION}';`);
40
+ for (const v of ['0.223.0', '0.223.1'])
41
+ expect(releasedBeforeChannelStart(v)).toBe(true);
42
+ for (const v of ['0.224.0', '0.224.0-rc.1', 'dev', '0.0.0', ''])
43
+ expect(releasedBeforeChannelStart(v)).toBe(false);
44
+ });
45
+ it('a stdio server without a channel heartbeats once as a tool server', () => {
46
+ expect(toolServerHeartbeat(' Build-Host ')).toEqual({ toolServer: true, host: 'build-host' });
47
+ });
48
+ it('the channel heartbeat carries the start marker', () => {
49
+ expect(channelClientInfo(null, '', 'orboto-claude')).toEqual({ channel: 'claude-code', channelStart: 'orboto-claude' });
50
+ expect(channelClientInfo(null, '')).toEqual({ channel: 'claude-code' });
51
+ });
52
+ it('the wake enum and labels mirror @orboto/shared-schema agent-session-wake.ts', () => {
53
+ const shared = readFileSync(resolve(process.cwd(), '../../packages/shared-schema/src/agent-session-wake.ts'), 'utf8');
54
+ expect(shared).toContain("export const AgentSessionWakeSchema = z.enum(['channel', 'stream', 'lane', 'on_call']);");
55
+ for (const [wake, label] of Object.entries(AGENT_WAKE_LABELS))
56
+ expect(shared).toContain(` ${wake}: '${label}',`);
57
+ });
58
+ it('a directory line names a session nothing wakes', () => {
59
+ const line = whoLine({
60
+ sessionId: 's', shortId: 'abcd1234', email: 'claude@example.invalid', role: 'coordinator', projectKeys: [], addresses: [],
61
+ lastSeenAt: 'now', channel: { announced: null, online: false }, wake: 'on_call', currentTicket: null, status: 'idle',
62
+ });
63
+ expect(line).toContain('reads mail only on its own calls');
64
+ });
65
+ it('orboto_session_start prints the server notes and reports the wake', async () => {
66
+ const notes = ['Not carried over: the scope an earlier process of this session row declared (role coordinator; pid 4100).', 'Mail does not wake this session: start it with `orboto claude` to be woken.'];
67
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
68
+ const path = new URL(url.toString()).pathname;
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 },
71
+ '/users/me': { email: 'dev@example.invalid', fullName: 'Dev' },
72
+ '/agent-instructions': { instructions: 'rules', rulesHash: 'h' },
73
+ };
74
+ const key = Object.keys(map).find((k) => path === k);
75
+ return { ok: true, status: 200, statusText: 'OK', json: async () => (key ? map[key] : {}), text: async () => '' };
76
+ });
77
+ const res = await makeSessionStartHandler(new OrbotoClient({ baseUrl: 'https://orboto.example.invalid', apiKey: 'orb_x' }))({}, { sessionId: 'x' });
78
+ const text = res.content[0].text;
79
+ for (const note of notes)
80
+ expect(text).toContain(note);
81
+ expect(res.structuredContent).toMatchObject({ session: { wake: 'on_call', scopeExpired: { scope: { role: 'coordinator' } } } });
82
+ });
83
+ });
@@ -46,8 +46,13 @@ export function readClaudeContext(dir, now = Date.now(), root = claudeContextDir
46
46
  }
47
47
  return fresh.length === 1 ? fresh[0] : null;
48
48
  }
49
- /** ORB-2465 - the channel heartbeat's clientInfo: the wake channel, the host `--host` filters on, and the context report. */
50
- export function channelClientInfo(context, host = hostname()) {
49
+ /** ORB-2584 - the one heartbeat of a stdio server without a channel: its host and `toolServer`, so the API drops a `legacy` channel an older proxy of this row announced. */
50
+ export function toolServerHeartbeat(host = hostname()) {
51
51
  const value = host.trim().toLowerCase().slice(0, 200);
52
- return { channel: 'claude-code', ...(value ? { host: value } : {}), ...(context ?? {}) };
52
+ return { toolServer: true, ...(value ? { host: value } : {}) };
53
+ }
54
+ /** ORB-2465 - the channel heartbeat's clientInfo: the wake channel, the host `--host` filters on, and the context report; ORB-2584 - the start that delivers the channel. */
55
+ export function channelClientInfo(context, host = hostname(), start) {
56
+ const value = host.trim().toLowerCase().slice(0, 200);
57
+ return { channel: 'claude-code', ...(start ? { channelStart: start } : {}), ...(value ? { host: value } : {}), ...(context ?? {}) };
53
58
  }
@@ -0,0 +1,19 @@
1
+ /** ORB-2564 - a person's acknowledgement wakes the sending session as kind `acknowledged`, and the inbox names the ladder step of urgent mail. */
2
+ import { describe, expect, it } from 'vitest';
3
+ import { eventKind, renderEvent } from './inbox-channel.js';
4
+ import { repeatMarks } from './tools/agent-messages.js';
5
+ describe('ORB-2564 - acknowledged wake', () => {
6
+ it('renders the acknowledgement as its own kind and any other message by its kind', () => {
7
+ const ack = { id: 'a1', kind: 'info', subject: 'acknowledged: disk full', payload: { type: 'acknowledged', acknowledgedMessageId: 'm1' }, createdAt: new Date().toISOString() };
8
+ expect(eventKind(ack)).toBe('acknowledged');
9
+ const event = renderEvent(ack, Date.now());
10
+ expect(event.meta.kind).toBe('acknowledged');
11
+ expect(event.content.startsWith('[acknowledged] acknowledged: disk full')).toBe(true);
12
+ expect(eventKind({ kind: 'request', payload: { type: 'ticket_ready' } })).toBe('request');
13
+ });
14
+ it('marks how far an urgent message escalated', () => {
15
+ expect(repeatMarks({ priority: 'urgent', escalationStep: 2 })).toBe(' [urgent, escalated: deputy]');
16
+ expect(repeatMarks({ priority: 'urgent', escalationStep: 1, reminderCount: 1 })).toBe(' [urgent, reminder 1, escalated: mail and channels]');
17
+ expect(repeatMarks({ priority: 'normal' })).toBe('');
18
+ });
19
+ });
@@ -37,11 +37,11 @@ export const CHANNEL_START_COMMANDS = {
37
37
  continueLatest: 'orboto claude --continue',
38
38
  resumeById: 'orboto claude --resume <session id>',
39
39
  };
40
- /** The raw Claude Code form, for a machine without the orboto CLI. */
40
+ /** The raw Claude Code form, for a machine without the orboto CLI; ORB-2584 - ORBOTO_MCP_CHANNEL=1 is the operator's marker that this start delivers the channel. */
41
41
  export const CHANNEL_START_COMMANDS_WITHOUT_CLI = {
42
- fresh: `claude ${START_FLAG}`,
43
- continueLatest: `claude ${START_FLAG} --continue`,
44
- resumeById: `claude ${START_FLAG} --resume <session id>`,
42
+ fresh: `ORBOTO_MCP_CHANNEL=1 claude ${START_FLAG}`,
43
+ continueLatest: `ORBOTO_MCP_CHANNEL=1 claude ${START_FLAG} --continue`,
44
+ resumeById: `ORBOTO_MCP_CHANNEL=1 claude ${START_FLAG} --resume <session id>`,
45
45
  };
46
46
  /** The `orboto_session_start` block of a stdio proxy whose channel is on. */
47
47
  export function channelStartLines() {
@@ -91,14 +91,19 @@ export function isImmediate(m, now, digestMinutes) {
91
91
  return true;
92
92
  return ASK.test(`${m.subject} ${firstLine(m.payload)}`);
93
93
  }
94
+ /** ORB-2564 - a person acknowledged a message this session sent: the event kind is `acknowledged`, so the sender stops reminding. */
95
+ export function eventKind(m) {
96
+ return m.payload?.type === 'acknowledged' ? 'acknowledged' : m.kind;
97
+ }
94
98
  export function renderEvent(m, now) {
99
+ const kind = eventKind(m);
95
100
  const from = m.from?.label ?? m.fromUserId ?? 'unknown';
96
101
  const to = m.to?.label ?? 'you';
97
102
  const text = firstLine(m.payload);
98
103
  const body = text.length > CONTENT_CAP ? `${text.slice(0, CONTENT_CAP)} [... cut, read the rest with orboto_messages]` : text;
99
104
  const marks = [m.priority === 'urgent' ? '[urgent]' : '', m.reminderCount ? `[reminder ${m.reminderCount}]` : ''].filter(Boolean).join(' ');
100
105
  const lines = [
101
- `${marks ? `${marks} ` : ''}[${m.kind}] ${m.subject}`,
106
+ `${marks ? `${marks} ` : ''}[${kind}] ${m.subject}`,
102
107
  `from ${from} to ${to}${m.projectKey ? ` | project ${m.projectKey}` : ''} | id ${m.id}${m.threadId ? ` | thread ${m.threadId}` : ''}`,
103
108
  ];
104
109
  if (body)
@@ -110,7 +115,7 @@ export function renderEvent(m, now) {
110
115
  return {
111
116
  content: lines.join('\n'),
112
117
  meta: {
113
- id: m.id, kind: m.kind, project: m.projectKey ?? '',
118
+ id: m.id, kind, project: m.projectKey ?? '',
114
119
  from: from, from_session: (m.from?.sessionId ?? '').slice(0, 8), to_session: (m.to?.sessionId ?? '').slice(0, 8),
115
120
  waited_min: String(waited),
116
121
  ...(m.priority === 'urgent' ? { priority: 'urgent' } : {}),
package/dist/index.js CHANGED
@@ -53,7 +53,12 @@ async function main() {
53
53
  console.error(`[orboto-mcp] ${err.message}`);
54
54
  process.exit(1);
55
55
  }
56
- const channel = (process.env.ORBOTO_MCP_CHANNEL ?? '1') !== '0';
56
+ const { channelStartFromEnv } = await import('./channel-start.js');
57
+ const channelStart = channelStartFromEnv({
58
+ ORBOTO_MCP_CHANNEL: process.env.ORBOTO_MCP_CHANNEL, ORBOTO_CLAUDE_CHANNEL: process.env.ORBOTO_CLAUDE_CHANNEL,
59
+ ORBOTO_ORIGIN: process.env.ORBOTO_ORIGIN, ORBOTO_MCP_CLI_OUTDATED: process.env.ORBOTO_MCP_CLI_OUTDATED,
60
+ });
61
+ const channel = channelStart !== null;
57
62
  const server = await buildOrbotoMcpServer({ ...clientConfig, channel, instanceToken: (await import('./tools/shared.js')).mcpProcessInstance() });
58
63
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
59
64
  const stdio = new StdioServerTransport();
@@ -73,7 +78,7 @@ async function main() {
73
78
  void inbox.deliverNotice(CLI_OUTDATED_NOTICE, outdated);
74
79
  const { readClaudeContext, channelClientInfo } = await import('./claude-context.js');
75
80
  const beat = () => {
76
- preflightClient.post('/v1/agent/heartbeat', { clientInfo: channelClientInfo(readClaudeContext(process.cwd())) }, { instanceToken })
81
+ preflightClient.post('/v1/agent/heartbeat', { clientInfo: channelClientInfo(readClaudeContext(process.cwd()), undefined, channelStart ?? undefined) }, { instanceToken })
77
82
  .catch((err) => { console.error(`[orboto-mcp] channel heartbeat failed: ${err.message}`); });
78
83
  };
79
84
  beat();
@@ -84,6 +89,11 @@ async function main() {
84
89
  const { endProbeSessionOnStdinEnd } = await import('./probe-session.js');
85
90
  endProbeSessionOnStdinEnd({ env: process.env, stdin: process.stdin, client: preflightClient, instanceToken, cleanup, exit: (code) => process.exit(code), settle: releaseCapacity });
86
91
  }
92
+ else {
93
+ const { toolServerHeartbeat } = await import('./claude-context.js');
94
+ preflightClient.post('/v1/agent/heartbeat', { clientInfo: toolServerHeartbeat() }, { instanceToken: (await import('./tools/shared.js')).mcpProcessInstance() })
95
+ .catch((err) => { console.error(`[orboto-mcp] heartbeat failed: ${err.message}`); });
96
+ }
87
97
  return;
88
98
  }
89
99
  if (transport === 'http') {
@@ -4,15 +4,27 @@
4
4
  */
5
5
  import { VERSION } from './version.js';
6
6
  import { originHeader } from './session-origin.js';
7
+ /** ORB-2581 - the seconds a `Retry-After` header asks for (delta-seconds or an HTTP date); null when absent or unreadable. */
8
+ export function parseRetryAfter(value, now = Date.now()) {
9
+ if (value == null || value.trim() === '')
10
+ return null;
11
+ const trimmed = value.trim();
12
+ if (/^\d+$/.test(trimmed))
13
+ return Number(trimmed);
14
+ const at = Date.parse(trimmed);
15
+ return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - now) / 1000));
16
+ }
7
17
  export class OrbotoApiError extends Error {
8
18
  status;
9
19
  body;
10
20
  url;
11
- constructor(status, body, url) {
21
+ retryAfterSeconds;
22
+ constructor(status, body, url, retryAfterSeconds = null) {
12
23
  super(`orboto API ${status}: ${body || '(empty body)'}`);
13
24
  this.status = status;
14
25
  this.body = body;
15
26
  this.url = url;
27
+ this.retryAfterSeconds = retryAfterSeconds;
16
28
  this.name = 'OrbotoApiError';
17
29
  }
18
30
  }
@@ -79,7 +91,7 @@ export class OrbotoClient {
79
91
  this.pendingAgentMail = mail ? Number(mail) || 0 : 0;
80
92
  if (!res.ok) {
81
93
  const body = await res.text().catch(() => '');
82
- throw new OrbotoApiError(res.status, body, url);
94
+ throw new OrbotoApiError(res.status, body, url, parseRetryAfter(res.headers?.get?.('retry-after')));
83
95
  }
84
96
  return res;
85
97
  }
@@ -11,8 +11,22 @@ import { hostname } from 'node:os';
11
11
  import { basename, dirname, join } from 'node:path';
12
12
  import { VERSION } from './version.js';
13
13
  const KEYS = [
14
- ['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'],
14
+ ['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'], ['model', 'model'],
15
15
  ];
16
+ const WRAPPED_HARNESSES = { claude: 'claude-code', 'claude-code': 'claude-code', codex: 'codex', gemini: 'gemini', kimi: 'kimi', agy: 'agy', antigravity: 'agy' };
17
+ /** ORB-2571 - the harness that started a stdio proxy: the one `orboto <harness>` named, else the markers a harness sets. */
18
+ export function stdioHarness(env = process.env) {
19
+ const wrapped = WRAPPED_HARNESSES[(env.ORBOTO_HARNESS ?? '').trim().toLowerCase()];
20
+ if (wrapped)
21
+ return wrapped;
22
+ if (env.CLAUDECODE)
23
+ return 'claude-code';
24
+ if (env.CODEX_THREAD_ID || env.CODEX_SESSION_ID)
25
+ return 'codex';
26
+ if (env.GEMINI_CLI)
27
+ return 'gemini';
28
+ return 'mcp-stdio';
29
+ }
16
30
  /** The header value; empty fields are left out. */
17
31
  export function formatOrigin(origin) {
18
32
  const params = new URLSearchParams();
@@ -75,7 +89,8 @@ export function stdioOrigin(env = process.env) {
75
89
  return formatOrigin({
76
90
  host: shortHost(), machineId: machineId(), repo: repoBasename(), pid: process.ppid || null,
77
91
  client: 'orboto-mcp', clientVersion: VERSION,
78
- harness: env.CLAUDECODE ? 'claude-code' : env.CODEX_THREAD_ID || env.CODEX_SESSION_ID ? 'codex' : 'mcp-stdio',
92
+ harness: stdioHarness(env),
93
+ model: /^[\x21-\x7e]{1,80}$/.test((env.ORBOTO_MODEL ?? '').trim()) ? env.ORBOTO_MODEL.trim() : null,
79
94
  });
80
95
  }
81
96
  /** The HTTP server's origin: the server process itself, the identity of every connection is its OAuth client. */
@@ -7,7 +7,7 @@ import { mkdtempSync, mkdirSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { createHttpServer } from './http-transport.js';
10
- import { formatOrigin, machineId, repoBasename, shortHost, stdioOrigin } from './session-origin.js';
10
+ import { formatOrigin, machineId, repoBasename, shortHost, stdioHarness, stdioOrigin } from './session-origin.js';
11
11
  import { bindSessionInstance, mcpInstanceToken, releaseSessionInstance, stableClientInstance } from './tools/shared.js';
12
12
  import { InboxChannel, SESSION_ENDED_NOTICE } from './inbox-channel.js';
13
13
  describe('ORB-2544 - the origin header', () => {
@@ -20,6 +20,17 @@ 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-2571 - a stdio proxy names the harness orboto <harness> started and its model, else the harness markers', () => {
24
+ expect(stdioHarness({ ORBOTO_HARNESS: 'kimi', CLAUDECODE: '1' })).toBe('kimi');
25
+ expect(stdioHarness({ ORBOTO_HARNESS: 'antigravity' })).toBe('agy');
26
+ expect(stdioHarness({ GEMINI_CLI: '1' })).toBe('gemini');
27
+ expect(stdioHarness({ CODEX_SESSION_ID: 's' })).toBe('codex');
28
+ expect(stdioHarness({})).toBe('mcp-stdio');
29
+ const params = new URLSearchParams(stdioOrigin({ ORBOTO_HARNESS: 'agy', ORBOTO_MODEL: 'gemini-4-argon' }));
30
+ expect(params.get('harness')).toBe('agy');
31
+ expect(params.get('model')).toBe('gemini-4-argon');
32
+ expect(new URLSearchParams(stdioOrigin({ ORBOTO_MODEL: 'has spaces' })).has('model')).toBe(false);
33
+ });
23
34
  it('reduces the host to its first label and a checkout to the repository basename', () => {
24
35
  expect(shortHost('Build-Host.example.lan.')).toBe('build-host');
25
36
  const root = mkdtempSync(join(tmpdir(), 'origin-'));
@@ -36,6 +36,8 @@ export const TAIL_LABELS = [
36
36
  { method: 'GET', path: new RegExp(`^/v1/agent/messages/${SEG}/attachments(/${SEG}/url)?$`), label: 'orboto_agent_message_attachments' },
37
37
  { method: 'GET', path: /^\/admin\/agents\/transfers$/, label: 'orboto_agent_transfers' },
38
38
  { method: 'DELETE', path: new RegExp(`^/admin/agents/transfers/${SEG}$`), label: 'orboto_agent_transfers' },
39
+ { method: 'GET', path: new RegExp(`^/admin/agents/identities/${SEG}/query-rate-limit$`), label: 'orboto_agent_query_rate_limit' },
40
+ { method: 'PUT', path: new RegExp(`^/admin/agents/identities/${SEG}/query-rate-limit$`), label: 'orboto_agent_query_rate_limit' },
39
41
  ];
40
42
  /** The metrics label of one tool call: the stand-in tool name for a labelled api_call, else the tool name itself. */
41
43
  export function metricsToolName(toolName, args) {
@@ -4,7 +4,7 @@
4
4
  * @see ORB-704, ORB-706
5
5
  */
6
6
  import { z } from 'zod';
7
- import { AgentInventoryEntrySchema, AgentInventoryKindSchema } from './agent-presence-schema.js';
7
+ import { AGENT_WAKE_LABELS, AgentInventoryEntrySchema, AgentInventoryKindSchema } from './agent-presence-schema.js';
8
8
  import { OrbotoApiError } from '../orboto-client.js';
9
9
  import { mcpInstanceToken } from './shared.js';
10
10
  import { AgentSessionScopeSchema } from './agent-session-scope.js';
@@ -37,9 +37,13 @@ export function makeAgentHeartbeatHandler(client) {
37
37
  return async (args, extra) => {
38
38
  const res = await client.post('/v1/agent/heartbeat', args, { instanceToken: mcpInstanceToken(undefined, extra) });
39
39
  const scope = res.scope ? ` scope=${JSON.stringify(res.scope)}` : '';
40
+ const notes = (res.notes ?? []).map((n) => `\n${n}`).join('');
40
41
  return {
41
- content: [{ type: 'text', text: `heartbeat ack - sessionToken=${res.sessionToken.slice(0, 8)}… session ${res.sessionId.slice(0, 8)}${scope}` }],
42
- structuredContent: { sessionToken: res.sessionToken, sessionId: res.sessionId, scope: res.scope ?? null, ...(res.startedBy !== undefined ? { startedBy: res.startedBy } : {}) },
42
+ content: [{ type: 'text', text: `heartbeat ack - sessionToken=${res.sessionToken.slice(0, 8)}… session ${res.sessionId.slice(0, 8)}${scope}${notes}` }],
43
+ structuredContent: {
44
+ sessionToken: res.sessionToken, sessionId: res.sessionId, scope: res.scope ?? null, ...(res.startedBy !== undefined ? { startedBy: res.startedBy } : {}),
45
+ ...(res.wake ? { wake: res.wake } : {}), ...(res.notes ? { notes: res.notes } : {}),
46
+ },
43
47
  };
44
48
  };
45
49
  }
@@ -52,10 +56,10 @@ export function originText(s) {
52
56
  }
53
57
  export const agentPresenceToolConfig = {
54
58
  title: 'Workspace agent presence',
55
- description: 'Active agent instances. The orboto_who set: lane workers and reviewers, inbox, MCP and runner connections; rows carry kind (agent | human, by credential; isBot is its alias), actsAs, owner, projects, connections, lane, sessionId, userEmail, runtime (clientInfo.name), capabilities, status, current ticket, reconnects, origin (host, repo, pid, client, harness, credential) and state (live | quiet | orphaned | unknown_origin). Operators (admin:agents:read or admin:system:read) see the workspace, others their own sessions and those declared on their projects, a foreign row redacted (redacted: true, no pid, repo, machine, connections, inbox or owner). Check it before dispatching a sub-task. projectKey and kind filter client-side like `orboto agents --project KEY --bots|--humans`.',
59
+ description: 'Active agent instances. The orboto_who set: lane workers and reviewers, inbox, MCP and runner connections; rows carry kind (agent | human by credential; alias isBot), actsAs, owner, projects, connections, wake (on_call: nothing wakes it), lane, sessionId, userEmail, runtime, capabilities, status, current ticket, reconnects, origin (host, repo, pid, client, harness, credential), state (live | quiet | orphaned | unknown_origin). Operators (admin:agents:read or admin:system:read) see the workspace, others their own sessions and those on their projects, a foreign row redacted (no pid, repo, machine, connections, inbox, owner). projectKey and kind filter client-side (`orboto agents --project KEY --bots|--humans`).',
56
60
  inputSchema: z.object({
57
- projectKey: z.string().min(1).max(64).optional().describe('Keep only rows whose projects contain this project key (case-insensitive).'),
58
- kind: AgentInventoryKindSchema.optional().describe('Keep only rows classified as agent or human by their credential.'),
61
+ projectKey: z.string().min(1).max(64).optional().describe('Only rows whose projects hold this key.'),
62
+ kind: AgentInventoryKindSchema.optional().describe('Only rows of this kind (by credential).'),
59
63
  }).shape,
60
64
  outputSchema: z.object({
61
65
  sessions: z.array(AgentInventoryEntrySchema),
@@ -83,7 +87,7 @@ export function makeAgentPresenceHandler(client) {
83
87
  const work = s.workingOnTicket
84
88
  ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
85
89
  : '';
86
- lines.push(`- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${originText(s)}${work}; owner: ${s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self')}${s.lane ? `; lane: ${s.lane.name}` : ''}${s.projects.length ? `; projects: ${s.projects.map((p) => p.key).join(', ')}` : ''}${s.connections.length ? `; connections: ${s.connections.map((c) => c.type).join(', ')}` : ''}`);
90
+ lines.push(`- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${originText(s)}${work}; owner: ${s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self')}${s.lane ? `; lane: ${s.lane.name}` : ''}${s.projects.length ? `; projects: ${s.projects.map((p) => p.key).join(', ')}` : ''}${s.connections.length ? `; connections: ${s.connections.map((c) => c.type).join(', ')}` : ''}${s.wake ? `; ${AGENT_WAKE_LABELS[s.wake]}` : ''}`);
87
91
  }
88
92
  }
89
93
  return {
@@ -262,6 +266,7 @@ export function makeAgentNotifyHandler(client) {
262
266
  ...(recipients.length ? { recipients } : {}),
263
267
  ...(res.queued ? { queued: true } : {}),
264
268
  ...(res.ownerHint?.routed ? { owner: res.ownerHint.ownerAddress } : {}),
269
+ ...(res.wakeable !== undefined ? { wakeable: res.wakeable } : {}),
265
270
  },
266
271
  };
267
272
  };
@@ -12,7 +12,7 @@ function excerptOf(body) {
12
12
  return firstLine.length > 200 ? `${firstLine.slice(0, 199)}\u2026` : firstLine;
13
13
  }
14
14
  function renderBlock(b) {
15
- const flag = b.enabled ? '' : ' [disabled]';
15
+ const flag = (b.enabled ? '' : ' [disabled]') + (b.mustRead ? ' [must read]' : '');
16
16
  const kind = b.builtinKey ? `default:${b.builtinKey}` : 'custom';
17
17
  return `- ${b.title}${flag} (${kind}, ${b.body.length} chars, id ${b.id})\n ${excerptOf(b.body)}`;
18
18
  }
@@ -62,6 +62,7 @@ export function makeListAgentInstructionsHandler(client) {
62
62
  id: b.id,
63
63
  title: b.title,
64
64
  enabled: b.enabled,
65
+ ...(b.mustRead ? { mustRead: true } : {}),
65
66
  contentChars: b.body.length,
66
67
  ...(b.builtinKey ? { builtinKey: b.builtinKey } : {}),
67
68
  })),
@@ -81,9 +82,10 @@ export const createAgentInstructionToolConfig = {
81
82
  projectId: z.string().uuid().optional().describe('Required for scope=project.'),
82
83
  customerId: z.string().uuid().optional().describe('Required for scope=customer.'),
83
84
  enabled: z.boolean().optional(),
85
+ mustRead: z.boolean().optional(),
84
86
  sortOrder: z.number().int().optional(),
85
- allowOversize: z.boolean().optional().describe('Override the hard size-cap block (400*2 chars). Only after a call was blocked.'),
86
- oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true - why this body genuinely needs the length. Audit-logged.'),
87
+ allowOversize: z.boolean().optional().describe('Override the hard size cap (800 chars) after a blocked call.'),
88
+ oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true: why the body needs the length (audit-logged).'),
87
89
  }).shape,
88
90
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
89
91
  };
@@ -113,9 +115,10 @@ export const updateAgentInstructionToolConfig = {
113
115
  title: z.string().min(1).max(120).optional(),
114
116
  body: z.string().min(1).max(8000).optional(),
115
117
  enabled: z.boolean().optional(),
118
+ mustRead: z.boolean().optional(),
116
119
  sortOrder: z.number().int().optional(),
117
- allowOversize: z.boolean().optional().describe('Override the hard size-cap block (400*2 chars). Only after a call was blocked.'),
118
- oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true - why this body genuinely needs the length. Audit-logged.'),
120
+ allowOversize: z.boolean().optional().describe('Override the hard size cap (800 chars) after a blocked call.'),
121
+ oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true: why the body needs the length (audit-logged).'),
119
122
  }).shape,
120
123
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
121
124
  };
@@ -11,12 +11,14 @@ import { z } from 'zod';
11
11
  import { makeAgentMessageWorkHandler } from './agent-message-work.js';
12
12
  import { mcpInstanceToken } from './shared.js';
13
13
  const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
14
- /** ORB-2563 - `[urgent]`, `sent N times` and `reminder N` next to a message, the same words the CLI prints. */
14
+ const ESCALATED = ['', 'escalated: mail and channels', 'escalated: deputy'];
15
+ /** ORB-2563 - `[urgent]`, `sent N times` and `reminder N` next to a message, the same words the CLI prints; ORB-2564 - the ladder step it reached. */
15
16
  export function repeatMarks(m) {
16
17
  const marks = [
17
18
  m.priority === 'urgent' ? 'urgent' : '',
18
19
  m.repeatCount && m.repeatCount > 1 ? `sent ${m.repeatCount} times` : '',
19
20
  m.reminderCount ? `reminder ${m.reminderCount}` : '',
21
+ ESCALATED[m.escalationStep ?? 0] ?? '',
20
22
  ].filter(Boolean);
21
23
  return marks.length ? ` [${marks.join(', ')}]` : '';
22
24
  }
@@ -104,9 +106,11 @@ export function makeAgentMessagesHandler(client) {
104
106
  feedbackSent = res.feedbackSent;
105
107
  }
106
108
  let acked = 0;
109
+ let escalationsStopped = 0;
107
110
  if (args.ackIds && args.ackIds.length > 0) {
108
111
  const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds, instanceToken: mcpInstanceToken(undefined, extra) });
109
112
  acked = res.acked;
113
+ escalationsStopped = res.escalationsStopped ?? 0;
110
114
  }
111
115
  const compact = !args.all;
112
116
  const q = new URLSearchParams();
@@ -137,6 +141,8 @@ export function makeAgentMessagesHandler(client) {
137
141
  if (args.dismiss) {
138
142
  lines.unshift(`Dismissed ${dismissedCount} message(s) as ${args.dismiss.reason}${closedCount > 0 ? `, ${closedCount} closed for the account` : ''}${feedbackSent > 0 ? `, ${feedbackSent} sender(s) told about the misroute` : ''}.`);
139
143
  }
144
+ if (escalationsStopped > 0)
145
+ lines.unshift(`Stopped the urgent escalation of ${escalationsStopped} message(s).`);
140
146
  if (sessionId)
141
147
  lines.push(`This session: ${sessionId.slice(0, 8)} (ref ${instanceToken}).`);
142
148
  else