@orboto/mcp 0.223.0 → 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,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
  }
@@ -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() {
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,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
  };
@@ -9,6 +9,15 @@ const AgentAccountRefSchema = z.object({ id: z.string().uuid(), name: z.string()
9
9
  export const AgentInventoryKindSchema = z.enum(['agent', 'human']);
10
10
  export const AgentConnectionTypeSchema = z.enum(['api_key', 'oauth', 'lane', 'external_token', 'live_events', 'web', 'channel']);
11
11
  export const AgentInventoryConnectionSchema = z.object({ type: AgentConnectionTypeSchema, label: z.string() });
12
+ /** ORB-2584 - mirror of AgentSessionWakeSchema in @orboto/shared-schema agent-session-wake.ts. */
13
+ export const AgentSessionWakeSchema = z.enum(['channel', 'stream', 'lane', 'on_call']);
14
+ /** ORB-2584 - mirror of AGENT_WAKE_LABELS: what presence lines print for each wake. */
15
+ export const AGENT_WAKE_LABELS = {
16
+ channel: 'reachable by channel',
17
+ stream: 'reachable by inbox stream',
18
+ lane: 'reachable as a lane',
19
+ on_call: 'reads mail only on its own calls',
20
+ };
12
21
  export const AgentInventoryProjectSchema = z.object({ id: z.string().uuid(), key: z.string(), name: z.string() });
13
22
  export const AgentInventoryEntrySchema = z.object({
14
23
  userId: z.string().uuid(),
@@ -25,6 +34,8 @@ export const AgentInventoryEntrySchema = z.object({
25
34
  lane: z.object({ id: z.string().uuid(), name: z.string(), role: z.string(), paused: z.boolean() }).nullable(),
26
35
  projects: z.array(AgentInventoryProjectSchema),
27
36
  connections: z.array(AgentInventoryConnectionSchema),
37
+ /** ORB-2584 - whether mail wakes the session (channel, stream, lane) or it reads mail only on its own calls (`on_call`). */
38
+ wake: AgentSessionWakeSchema.optional(),
28
39
  workingOnTicket: z.object({ id: z.string().uuid(), key: z.string().nullable(), title: z.string(), projectKey: z.string().nullable() }).nullable(),
29
40
  workSessions: z.array(z.object({
30
41
  ticketId: z.string().uuid(), ticketKey: z.string().nullable(), role: z.string(), leaseUntil: z.string(),
@@ -3,6 +3,7 @@
3
3
  * did with it and what it cost.
4
4
  */
5
5
  import { z } from 'zod';
6
+ import { AGENT_WAKE_LABELS } from './agent-presence-schema.js';
6
7
  export const agentWakesToolConfig = {
7
8
  title: 'Wake ledger',
8
9
  description: 'The wake ledger: who woke this account\'s sessions and at what cost. It names the visibility rule that delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read); `wakes`, `precision` and the median latency count deliveries (`channel` | `follow` | `poll`) only, `backlogSeen` is the separate count of `nudge` rows - a backlog sighting, not a wake. Without `stats` it returns the ledger rows of your own account, newest first. Use it to prove a misrouting sender instead of describing it in prose.',
@@ -21,7 +22,8 @@ function statsLine(e) {
21
22
  const precision = e.precision === null ? 'unjudged' : `${Math.round(e.precision * 100)}%`;
22
23
  const tokens = e.wrongWakeContextTokens > 0 ? `, wrong wakes cost ${e.wrongWakeContextTokens} tokens` : '';
23
24
  const backlog = e.backlogSeen > 0 ? `, ${e.backlogSeen} backlog seen` : '';
24
- return `- ${e.label}: ${e.wakes} wakes, precision ${precision}, median ${e.medianLatencyMs ?? '?'} ms, top rule ${e.topRule ?? '-'}${tokens}${backlog}`;
25
+ const wake = e.wake ? `, now ${AGENT_WAKE_LABELS[e.wake]}` : '';
26
+ return `- ${e.label}: ${e.wakes} wakes, precision ${precision}, median ${e.medianLatencyMs ?? '?'} ms, top rule ${e.topRule ?? '-'}${tokens}${backlog}${wake}`;
25
27
  }
26
28
  export function makeAgentWakesHandler(client) {
27
29
  return async (args) => {
@@ -25,6 +25,14 @@ describe('orboto_agent_wakes', () => {
25
25
  expect(text).toContain('- worker (ef56ab78): 1 wakes, precision unjudged, median ? ms, top rule -');
26
26
  expect(text).toContain('Senders:\n- (none)');
27
27
  });
28
+ it('ORB-2584 - names how mail reaches each session now', async () => {
29
+ const { client } = clientReturning({
30
+ hours: 24,
31
+ sessions: [{ label: 'coordinator (aa11bb22)', wakes: 2, precision: null, medianLatencyMs: null, topRule: null, wrongWakeContextTokens: 0, backlogSeen: 0, wake: 'on_call' }],
32
+ senders: [],
33
+ });
34
+ expect(textOf(await makeAgentWakesHandler(client)({ stats: true }))).toContain('- coordinator (aa11bb22): 2 wakes, precision unjudged, median ? ms, top rule -, now reads mail only on its own calls');
35
+ });
28
36
  it('says so when nothing woke anyone', async () => {
29
37
  const { client } = clientReturning({ hours: 24, sessions: [], senders: [] });
30
38
  expect(textOf(await makeAgentWakesHandler(client)({ stats: true }))).toContain('- (no wakes)');
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { AgentSessionRoleSchema } from './agent-session-scope.js';
4
4
  import { ownerLine } from './responsibilities.js';
5
5
  import { originText } from './agent-coordination.js';
6
+ import { AGENT_WAKE_LABELS } from './agent-presence-schema.js';
6
7
  export const agentWhoToolConfig = {
7
8
  title: 'Who receives agent mail',
8
9
  description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. Each row names account, role, declared projects, last seen, wake channel and current ticket; address a role from here, never a session id copied from a log. `for` (free text) names the topic owner and keeps its sessions; `state` keeps one state (orphaned: only another process touches it).',
@@ -18,7 +19,8 @@ export const agentWhoToolConfig = {
18
19
  /** One directory row as a line: address, account, session, projects, channel, ticket. */
19
20
  export function whoLine(s) {
20
21
  const address = s.addresses.join(', ') || (s.role ? `${s.role} (no project)` : 'no scope');
21
- const channel = s.channel.announced ? `${s.channel.announced}${s.channel.online ? ' online' : ' offline'}` : 'no channel';
22
+ const announced = s.channel.announced ? `${s.channel.announced}${s.channel.online ? ' online' : ' offline'}` : 'no channel';
23
+ const channel = s.wake === 'channel' || !s.wake ? announced : AGENT_WAKE_LABELS[s.wake];
22
24
  const ticket = s.currentTicket ? `; on ${s.currentTicket.key ?? s.currentTicket.title}` : '';
23
25
  const host = s.origin ? originText(s) : s.host ? `, host ${s.host}` : '';
24
26
  const shared = s.sharedAddresses?.length ? `; also held by another live session: ${s.sharedAddresses.join(', ')}` : '';
@@ -220,7 +220,10 @@ export function makeSessionStartHandler(client, opts = {}) {
220
220
  },
221
221
  };
222
222
  }
223
- const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? { sessionId: r.sessionId, scope: r.scope ?? null, reconnects: r.reconnects, lastReconnectAt: r.lastReconnectAt } : null);
223
+ const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? {
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') : [],
226
+ } : null);
224
227
  const host = localSessionHost(extra);
225
228
  let registration = await client.post('/v1/agent/heartbeat', { ...(input.scope !== undefined ? { scope: input.scope } : {}), ...(host ? { clientInfo: { host } } : {}) }, { instanceToken })
226
229
  .then(toRegistration)
@@ -343,6 +346,8 @@ export function makeSessionStartHandler(client, opts = {}) {
343
346
  lines.push(`Address form: peers reach this session and every other ${registration.scope.role} of the project with orboto_agent_notify { to: "${roleAddresses[0]}" } (${roleAddresses.join(', ')}); address a role, never a session id copied from a log.`);
344
347
  if (registration?.scope)
345
348
  lines.push(identityLine(registration));
349
+ for (const note of registration?.notes ?? [])
350
+ lines.push(note);
346
351
  if (opts.channel)
347
352
  lines.push('', ...channelStartLines());
348
353
  if (pendingMessages.length > 0) {
@@ -377,6 +382,7 @@ export function makeSessionStartHandler(client, opts = {}) {
377
382
  session: {
378
383
  ref: instanceToken, id: registration?.sessionId ?? null, scope: registration?.scope ?? null,
379
384
  reconnects: registration?.reconnects ?? 0, reconnectedAt: registration?.lastReconnectAt ?? null,
385
+ ...(registration?.wake ? { wake: registration.wake } : {}), scopeExpired: registration?.scopeExpired ?? null,
380
386
  },
381
387
  gitHealth: {
382
388
  unhealthy: gitHealthWithConnections
@@ -16,6 +16,8 @@ import { VERSION } from './version.js';
16
16
  const SRC = dirname(fileURLToPath(import.meta.url));
17
17
  const pkg = createRequire(import.meta.url)('../package.json');
18
18
  const ALLOWED = new Set(['version.ts']);
19
+ /** ORB-2584 - the channel-start cut-over is a protocol version pinned to shared-schema by channel-start.test.ts, not the package version. */
20
+ const ALLOWED_LINES = [/^export const CHANNEL_START_CUTOVER_VERSION = '\d+\.\d+\.\d+';$/];
19
21
  function walk(dir, acc = []) {
20
22
  for (const entry of readdirSync(dir)) {
21
23
  const full = join(dir, entry);
@@ -38,7 +40,7 @@ describe('ORB-1173 - version literal guard', () => {
38
40
  for (const file of walk(SRC)) {
39
41
  const text = readFileSync(file, 'utf8');
40
42
  text.split('\n').forEach((line, i) => {
41
- if (semverLiteral.test(line))
43
+ if (semverLiteral.test(line) && !ALLOWED_LINES.some((allowed) => allowed.test(line.trim())))
42
44
  offenders.push(`${file.replace(SRC, 'src')}:${i + 1}: ${line.trim()}`);
43
45
  });
44
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.223.0",
3
+ "version": "0.224.0",
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",