@orboto/mcp 0.223.0 → 0.225.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.
- package/dist/channel-start.js +52 -0
- package/dist/channel-start.test.js +83 -0
- package/dist/claude-context.js +8 -3
- package/dist/inbox-channel.js +4 -4
- package/dist/index.js +12 -2
- package/dist/required-rules-wrapper.test.js +2 -1
- package/dist/test/child-window-test-timeout.test.js +20 -0
- package/dist/test/ci-window.js +8 -0
- package/dist/test/hermetic-cli-env.js +42 -0
- package/dist/tools/agent-coordination.js +12 -7
- package/dist/tools/agent-presence-schema.js +11 -0
- package/dist/tools/agent-wakes.js +3 -1
- package/dist/tools/agent-wakes.test.js +8 -0
- package/dist/tools/agent-who.js +3 -1
- package/dist/tools/doc-replace-wrapper.test.js +5 -3
- package/dist/tools/review-policy-wrapper.test.js +5 -3
- package/dist/tools/semantic-degraded-wrapper.test.js +5 -3
- package/dist/tools/session-start.js +7 -1
- package/dist/version-guard.test.js +3 -1
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/dist/claude-context.js
CHANGED
|
@@ -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-
|
|
50
|
-
export function
|
|
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 {
|
|
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
|
}
|
package/dist/inbox-channel.js
CHANGED
|
@@ -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
|
|
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') {
|
|
@@ -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: {
|
|
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 = '';
|
|
@@ -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
|
+
}
|
|
@@ -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: {
|
|
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
|
|
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('
|
|
58
|
-
kind: AgentInventoryKindSchema.optional().describe('
|
|
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
|
-
|
|
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)');
|
package/dist/tools/agent-who.js
CHANGED
|
@@ -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
|
|
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(', ')}` : '';
|
|
@@ -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: {
|
|
49
|
-
timeout:
|
|
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: {
|
|
39
|
-
timeout:
|
|
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: {
|
|
49
|
-
timeout:
|
|
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));
|
|
@@ -220,7 +220,10 @@ export function makeSessionStartHandler(client, opts = {}) {
|
|
|
220
220
|
},
|
|
221
221
|
};
|
|
222
222
|
}
|
|
223
|
-
const toRegistration = (r) => (r && typeof r.sessionId === 'string' ? {
|
|
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.
|
|
3
|
+
"version": "0.225.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",
|