@orboto/mcp 0.222.0 → 0.223.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/agent-context.js +49 -0
- package/dist/agent-context.test.js +45 -0
- package/dist/inbox-channel-acknowledged.test.js +19 -0
- package/dist/inbox-channel.js +18 -4
- package/dist/inbox-channel.test.js +18 -0
- package/dist/manifest-size.test.js +3 -0
- package/dist/orboto-client.js +14 -2
- package/dist/session-origin.js +17 -2
- package/dist/session-origin.test.js +12 -1
- package/dist/tail-labels.js +2 -0
- package/dist/tools/admin-writes.js +2 -1
- package/dist/tools/admin-writes.test.js +13 -0
- package/dist/tools/agent-coordination.js +39 -3
- package/dist/tools/agent-coordination.test.js +40 -2
- package/dist/tools/agent-instructions.js +8 -5
- package/dist/tools/agent-messages.js +16 -1
- package/dist/tools/backup.js +26 -2
- package/dist/tools/backup.test.js +10 -0
- package/dist/tools/docs.js +1 -1
- package/dist/tools/primer-facts.js +1 -1
- package/dist/tools/project-readiness-schema.js +4 -1
- package/dist/tools/query.js +49 -6
- package/dist/tools/query.test.js +69 -0
- package/dist/tools/session-start.js +55 -18
- package/dist/tools/session-start.test.js +109 -12
- package/dist/tools/update-project.js +3 -1
- package/dist/tools/update-project.test.js +10 -0
- package/package.json +1 -1
|
@@ -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,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
|
+
});
|
package/dist/inbox-channel.js
CHANGED
|
@@ -16,6 +16,10 @@ const MIN_BACKOFF_MS = 2_000;
|
|
|
16
16
|
const MAX_BACKOFF_MS = 60_000;
|
|
17
17
|
const HEARTBEAT_GRACE_MS = 90_000;
|
|
18
18
|
const ASK = /\?|\bplease\b|\bbitte\b|\bneed\b|\bshare\b/i;
|
|
19
|
+
/** ORB-2563 - what the channel dedupes on: the message and its reminder count. */
|
|
20
|
+
export function wakeKey(m) {
|
|
21
|
+
return m.reminderCount ? `${m.id}#${m.reminderCount}` : m.id;
|
|
22
|
+
}
|
|
19
23
|
/** ORB-2467 - `attachments: 1 archive, 12.4 MB (orboto messages --download <id>)`, or nothing. */
|
|
20
24
|
export function attachmentLine(m) {
|
|
21
25
|
return m.attachments?.count ? `attachments: ${m.attachments.label} (orboto messages --download ${m.id})` : '';
|
|
@@ -79,19 +83,27 @@ export function isImmediate(m, now, digestMinutes) {
|
|
|
79
83
|
return true;
|
|
80
84
|
if (m.kind === 'request' || m.kind === 'error')
|
|
81
85
|
return true;
|
|
86
|
+
if (m.priority === 'urgent' || (m.reminderCount ?? 0) > 0)
|
|
87
|
+
return true;
|
|
82
88
|
if (isForThisSession(m))
|
|
83
89
|
return true;
|
|
84
90
|
if (waitedMinutes(m.createdAt, now) * 60_000 >= IMMEDIATE_AFTER_MS)
|
|
85
91
|
return true;
|
|
86
92
|
return ASK.test(`${m.subject} ${firstLine(m.payload)}`);
|
|
87
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
|
+
}
|
|
88
98
|
export function renderEvent(m, now) {
|
|
99
|
+
const kind = eventKind(m);
|
|
89
100
|
const from = m.from?.label ?? m.fromUserId ?? 'unknown';
|
|
90
101
|
const to = m.to?.label ?? 'you';
|
|
91
102
|
const text = firstLine(m.payload);
|
|
92
103
|
const body = text.length > CONTENT_CAP ? `${text.slice(0, CONTENT_CAP)} [... cut, read the rest with orboto_messages]` : text;
|
|
104
|
+
const marks = [m.priority === 'urgent' ? '[urgent]' : '', m.reminderCount ? `[reminder ${m.reminderCount}]` : ''].filter(Boolean).join(' ');
|
|
93
105
|
const lines = [
|
|
94
|
-
`[${
|
|
106
|
+
`${marks ? `${marks} ` : ''}[${kind}] ${m.subject}`,
|
|
95
107
|
`from ${from} to ${to}${m.projectKey ? ` | project ${m.projectKey}` : ''} | id ${m.id}${m.threadId ? ` | thread ${m.threadId}` : ''}`,
|
|
96
108
|
];
|
|
97
109
|
if (body)
|
|
@@ -103,9 +115,11 @@ export function renderEvent(m, now) {
|
|
|
103
115
|
return {
|
|
104
116
|
content: lines.join('\n'),
|
|
105
117
|
meta: {
|
|
106
|
-
id: m.id, kind
|
|
118
|
+
id: m.id, kind, project: m.projectKey ?? '',
|
|
107
119
|
from: from, from_session: (m.from?.sessionId ?? '').slice(0, 8), to_session: (m.to?.sessionId ?? '').slice(0, 8),
|
|
108
120
|
waited_min: String(waited),
|
|
121
|
+
...(m.priority === 'urgent' ? { priority: 'urgent' } : {}),
|
|
122
|
+
...(m.reminderCount ? { reminder: String(m.reminderCount) } : {}),
|
|
109
123
|
},
|
|
110
124
|
};
|
|
111
125
|
}
|
|
@@ -175,11 +189,11 @@ export class InboxChannel {
|
|
|
175
189
|
async deliver(m) {
|
|
176
190
|
if (!m?.id)
|
|
177
191
|
return;
|
|
178
|
-
if (this.seen.has(m
|
|
192
|
+
if (this.seen.has(wakeKey(m))) {
|
|
179
193
|
this.stats.duplicates += 1;
|
|
180
194
|
return;
|
|
181
195
|
}
|
|
182
|
-
this.remember(m
|
|
196
|
+
this.remember(wakeKey(m));
|
|
183
197
|
this.lastId = m.id;
|
|
184
198
|
if (isImmediate(m, this.now(), this.digestMinutes)) {
|
|
185
199
|
await this.emit(renderEvent(m, this.now()));
|
|
@@ -66,6 +66,24 @@ describe('renderEvent / isImmediate', () => {
|
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
describe('InboxChannel', () => {
|
|
69
|
+
it('ORB-2563 - a reminder of a seen message wakes again with its mark, a replay of the same reminder does not, urgent info skips the digest', async () => {
|
|
70
|
+
const { mcp, notification } = mockMcp();
|
|
71
|
+
const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, log: () => { }, digestMinutes: 15 });
|
|
72
|
+
const noSession = { email: 'claude@orboto.io', sessionId: null, label: 'claude@orboto.io' };
|
|
73
|
+
const base = { kind: 'info', payload: { message: 'run done' }, createdAt: '2026-09-18 09:59:50+00', to: noSession };
|
|
74
|
+
await channel.deliver(msg({ ...base, id: 'urgent', priority: 'urgent' }));
|
|
75
|
+
await channel.deliver(msg({ ...base, id: 'plain' }));
|
|
76
|
+
await channel.deliver(msg({ ...base, id: 'plain', reminderCount: 1, repeatCount: 3 }));
|
|
77
|
+
await channel.deliver(msg({ ...base, id: 'plain', reminderCount: 1, repeatCount: 3 }));
|
|
78
|
+
expect(notification).toHaveBeenCalledTimes(2);
|
|
79
|
+
const events = notification.mock.calls.map((c) => c[0].params);
|
|
80
|
+
expect(events[0].meta).toMatchObject({ id: 'urgent', priority: 'urgent' });
|
|
81
|
+
expect(events[0].content.startsWith('[urgent] [info]')).toBe(true);
|
|
82
|
+
expect(events[1].meta).toMatchObject({ id: 'plain', reminder: '1' });
|
|
83
|
+
expect(events[1].content.startsWith('[reminder 1] [info]')).toBe(true);
|
|
84
|
+
expect(channel.stats).toMatchObject({ delivered: 2, digested: 1, duplicates: 1 });
|
|
85
|
+
channel.close();
|
|
86
|
+
});
|
|
69
87
|
it('ORB-2265 - a connected channel wakes at once for info addressed to its session and for a reply flagged as in its sent thread, and digests the account info', async () => {
|
|
70
88
|
const { mcp, notification } = mockMcp();
|
|
71
89
|
const mine = 'bbbbbbbb-0000-4000-8000-000000000000';
|
|
@@ -59,6 +59,9 @@ afterEach(() => { vi.restoreAllMocks(); });
|
|
|
59
59
|
* ORB-2544 added no named tool: ending a session rides the escape hatch;
|
|
60
60
|
* orboto_who `state` and the agent_presence redaction line fit after the
|
|
61
61
|
* agent_presence summary shrank (full = 171,160).
|
|
62
|
+
* ORB-2563 gave `orboto_agent_notify` `remind` and `urgent` inside the
|
|
63
|
+
* ceiling: the docs `content` parameter description shrank to pay for it
|
|
64
|
+
* (full = 171,194).
|
|
62
65
|
*
|
|
63
66
|
* @see ORB-1805, ORB-1669, ORB-1910
|
|
64
67
|
*/
|
package/dist/orboto-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/session-origin.js
CHANGED
|
@@ -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
|
|
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-'));
|
package/dist/tail-labels.js
CHANGED
|
@@ -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) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
6
|
+
import { rethrowFullExportInProgress, runningFullExportId } from './backup.js';
|
|
6
7
|
function rewrite403(action) {
|
|
7
8
|
return (err) => {
|
|
8
9
|
if (err instanceof OrbotoApiError && err.status === 403) {
|
|
@@ -134,7 +135,7 @@ export function makeTriggerBackupHandler(client) {
|
|
|
134
135
|
throw new Error(`Backup job "${jobName}" is currently disabled - enable it in /admin/backup first.`);
|
|
135
136
|
}
|
|
136
137
|
const run = await client.post(`/admin/backup/jobs/${job.id}/run`, {})
|
|
137
|
-
.catch(rewrite403('trigger_backup'));
|
|
138
|
+
.catch((err) => (runningFullExportId(err) ? rethrowFullExportInProgress(err) : rewrite403('trigger_backup')(err)));
|
|
138
139
|
return {
|
|
139
140
|
content: [{
|
|
140
141
|
type: 'text',
|
|
@@ -116,4 +116,17 @@ describe('orboto_trigger_backup', () => {
|
|
|
116
116
|
]);
|
|
117
117
|
await expect(makeTriggerBackupHandler(client)({ jobName: 'nightly' })).rejects.toThrow(/super-admin/);
|
|
118
118
|
});
|
|
119
|
+
it('names the running run when a full export is already in progress (ORB-2562)', async () => {
|
|
120
|
+
const refusal = { error: 'busy', errorKey: 'errors.backups.full_export_in_progress', errorParams: { runId: '6f0c1a52-3d4e-4b8a-9c71-2e5f8a9b0c1d' } };
|
|
121
|
+
const responses = [
|
|
122
|
+
{ ok: true, status: 200, body: [{ id: 'j1', name: 'nightly', scope: 'full', schedule: null, isActive: true }] },
|
|
123
|
+
{ ok: false, status: 409, body: refusal },
|
|
124
|
+
];
|
|
125
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
|
126
|
+
const r = responses.shift();
|
|
127
|
+
return { ok: r.ok, status: r.status, statusText: 'x', json: async () => r.body, text: async () => JSON.stringify(r.body) };
|
|
128
|
+
});
|
|
129
|
+
await expect(makeTriggerBackupHandler(client)({ jobName: 'nightly' }))
|
|
130
|
+
.rejects.toThrow('A full export is already running (run 6f0c1a52-3d4e-4b8a-9c71-2e5f8a9b0c1d); nothing was started.');
|
|
131
|
+
});
|
|
119
132
|
});
|
|
@@ -92,6 +92,14 @@ export function makeAgentPresenceHandler(client) {
|
|
|
92
92
|
};
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
+
/** ORB-2563 - mirror of AGENT_MESSAGE_PRIORITIES and the sender lines in @orboto/shared-schema agent-message-dedupe.ts (drift test in agent-coordination.test.ts). */
|
|
96
|
+
export const NOTIFY_PRIORITIES = ['normal', 'urgent'];
|
|
97
|
+
export function repeatedSendNotice(repeatCount) {
|
|
98
|
+
return `already delivered (sent ${repeatCount} times), not sent again`;
|
|
99
|
+
}
|
|
100
|
+
export function reminderSentNotice(reminderCount, repeatCount) {
|
|
101
|
+
return `reminder ${reminderCount} sent (sent ${repeatCount} times)`;
|
|
102
|
+
}
|
|
95
103
|
/** ORB-2263 - mirror of AgentNotifyRoutedToSchema in @orboto/shared-schema (drift test in agent-coordination.test.ts). */
|
|
96
104
|
export const NOTIFY_ROUTED_TO = ['session', 'scope', 'account'];
|
|
97
105
|
/** ORB-2264 - the 409 `no_recipient` as a tool error naming who could receive it instead. */
|
|
@@ -130,9 +138,24 @@ export function ownerMismatchText(err) {
|
|
|
130
138
|
const text = p.ownerHint?.text ?? `"${p.topic ?? '?'}" belongs to ${p.owner ?? '?'}, not ${p.target ?? '?'}`;
|
|
131
139
|
return `not sent: ${text}. Send again with route: true to deliver it to ${p.owner ?? 'the owner'}, or forceTarget: true to keep the target.`;
|
|
132
140
|
}
|
|
141
|
+
/** ORB-2563 - the 429 `reminder_too_soon` as a tool error naming when the next reminder is allowed. */
|
|
142
|
+
export function reminderTooSoonText(err) {
|
|
143
|
+
if (err.status !== 429)
|
|
144
|
+
return null;
|
|
145
|
+
let body;
|
|
146
|
+
try {
|
|
147
|
+
body = JSON.parse(err.body);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (body.errorKey !== 'errors.agents.reminder_too_soon')
|
|
153
|
+
return null;
|
|
154
|
+
return `not sent: message ${body.errorParams?.messageId ?? '?'} was sent or reminded too recently; the next reminder is allowed in ${body.errorParams?.retryAfter ?? '?'} seconds.`;
|
|
155
|
+
}
|
|
133
156
|
export const agentNotifyToolConfig = {
|
|
134
157
|
title: 'Notify an agent or user',
|
|
135
|
-
description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session.',
|
|
158
|
+
description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
|
|
136
159
|
inputSchema: z.object({
|
|
137
160
|
targetEmail: z.string().email().optional(),
|
|
138
161
|
to: z.string().min(3).max(200).optional(),
|
|
@@ -147,6 +170,8 @@ export const agentNotifyToolConfig = {
|
|
|
147
170
|
toSessionRef: z.string().min(1).max(200).optional(),
|
|
148
171
|
route: z.boolean().optional(),
|
|
149
172
|
forceTarget: z.boolean().optional(),
|
|
173
|
+
remind: z.boolean().optional(),
|
|
174
|
+
urgent: z.boolean().optional(),
|
|
150
175
|
}).shape,
|
|
151
176
|
outputSchema: z.object({
|
|
152
177
|
ok: z.literal(true),
|
|
@@ -205,10 +230,11 @@ export function makeAgentNotifyHandler(client) {
|
|
|
205
230
|
const senderRef = mcpInstanceToken(args.senderRef, extra);
|
|
206
231
|
let res;
|
|
207
232
|
try {
|
|
208
|
-
|
|
233
|
+
const { urgent, ...rest } = args;
|
|
234
|
+
res = await client.post('/v1/agent/notify', { ...rest, ...(urgent ? { priority: NOTIFY_PRIORITIES[1] } : {}), senderRef, ownerCheck: 'refuse' }, { instanceToken: mcpInstanceToken(undefined, extra) });
|
|
209
235
|
}
|
|
210
236
|
catch (err) {
|
|
211
|
-
const refused = err instanceof OrbotoApiError ? noRecipientText(err) ?? ownerMismatchText(err) : null;
|
|
237
|
+
const refused = err instanceof OrbotoApiError ? noRecipientText(err) ?? ownerMismatchText(err) ?? reminderTooSoonText(err) : null;
|
|
212
238
|
if (refused)
|
|
213
239
|
return { isError: true, content: [{ type: 'text', text: refused }] };
|
|
214
240
|
throw err;
|
|
@@ -218,6 +244,16 @@ export function makeAgentNotifyHandler(client) {
|
|
|
218
244
|
const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${who} session ${res.toSessionId.slice(0, 8)}` : who;
|
|
219
245
|
const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
|
|
220
246
|
const note = res.routingNote ? ` - ${res.routingNote}` : '';
|
|
247
|
+
if (res.deduplicated) {
|
|
248
|
+
const repeats = res.repeatCount ?? 2;
|
|
249
|
+
const line = res.reminded ? reminderSentNotice(res.reminderCount ?? 1, repeats) : repeatedSendNotice(repeats);
|
|
250
|
+
return {
|
|
251
|
+
content: [{ type: 'text', text: `${line}: ${target} (message ${res.messageId})` }],
|
|
252
|
+
structuredContent: {
|
|
253
|
+
ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null, routedTo,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
221
257
|
return {
|
|
222
258
|
content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})${res.ownerHint?.routed ? `\nrouted to the owner of "${res.ownerHint.topic}" instead of ${res.ownerHint.target}` : ''}${res.hint ? `\nhint: ${res.hint}` : ''}` }],
|
|
223
259
|
structuredContent: {
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
11
|
-
import { OrbotoClient } from '../orboto-client.js';
|
|
12
|
-
import { NOTIFY_ROUTED_TO, agentNotifyToolConfig, makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
|
|
11
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
12
|
+
import { NOTIFY_PRIORITIES, NOTIFY_ROUTED_TO, agentNotifyToolConfig, reminderSentNotice, reminderTooSoonText, repeatedSendNotice, makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
|
|
13
13
|
import { makeAgentMessagesHandler } from './agent-messages.js';
|
|
14
14
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
15
15
|
function stub(responses) {
|
|
@@ -348,3 +348,41 @@ describe('orboto_agent_notify role addresses (ORB-2264)', () => {
|
|
|
348
348
|
expect(agentNotifyToolConfig.description).toContain('integrator@KEY');
|
|
349
349
|
});
|
|
350
350
|
});
|
|
351
|
+
describe('orboto_agent_notify dedupe and reminders (ORB-2563)', () => {
|
|
352
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
353
|
+
it('mirrors the priorities and sender lines of the shared schema', () => {
|
|
354
|
+
const shared = readFileSync(new URL('../../../../packages/shared-schema/src/agent-message-dedupe.ts', import.meta.url), 'utf8');
|
|
355
|
+
const match = shared.match(/AGENT_MESSAGE_PRIORITIES = \[([^\]]+)\]/);
|
|
356
|
+
expect(match[1].split(',').map((v) => v.trim().replace(/'/g, ''))).toEqual([...NOTIFY_PRIORITIES]);
|
|
357
|
+
expect(shared).toContain('`already delivered (sent ${repeatCount} times), not sent again`');
|
|
358
|
+
expect(shared).toContain('`reminder ${reminderCount} sent (sent ${repeatCount} times)`');
|
|
359
|
+
expect(repeatedSendNotice(3)).toBe('already delivered (sent 3 times), not sent again');
|
|
360
|
+
expect(reminderSentNotice(1, 4)).toBe('reminder 1 sent (sent 4 times)');
|
|
361
|
+
expect(agentNotifyToolConfig.description).toContain('remind: true');
|
|
362
|
+
expect(agentNotifyToolConfig.description).toContain('Loops and retries never set remind or urgent');
|
|
363
|
+
});
|
|
364
|
+
it('says a deduplicated send was not sent again, and a reminder that it notified again', async () => {
|
|
365
|
+
const answers = [
|
|
366
|
+
{ ok: true, messageId: '00000000-0000-4000-8000-000000000001', toSessionId: null, routedTo: 'account', deduplicated: true, repeatCount: 8 },
|
|
367
|
+
{ ok: true, messageId: '00000000-0000-4000-8000-000000000001', toSessionId: null, routedTo: 'account', deduplicated: true, repeatCount: 9, reminded: true, reminderCount: 1 },
|
|
368
|
+
];
|
|
369
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
|
370
|
+
const body = answers.shift();
|
|
371
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
372
|
+
});
|
|
373
|
+
const handler = makeAgentNotifyHandler(client);
|
|
374
|
+
const silent = await handler({ targetEmail: 'bob@example.com', subject: 'hi' });
|
|
375
|
+
expect(silent.content[0].text).toContain('already delivered (sent 8 times), not sent again');
|
|
376
|
+
expect(silent.structuredContent).toMatchObject({ messageId: '00000000-0000-4000-8000-000000000001' });
|
|
377
|
+
const reminder = await handler({ targetEmail: 'bob@example.com', subject: 'hi', remind: true, urgent: true });
|
|
378
|
+
expect(reminder.content[0].text).toContain('reminder 1 sent (sent 9 times)');
|
|
379
|
+
const sent = JSON.parse(String(fetch.mock.calls[1][1].body));
|
|
380
|
+
expect(sent).toMatchObject({ remind: true, priority: 'urgent' });
|
|
381
|
+
expect(sent).not.toHaveProperty('urgent');
|
|
382
|
+
});
|
|
383
|
+
it('turns the 429 reminder_too_soon into a tool error with the wait', () => {
|
|
384
|
+
const err = new OrbotoApiError(429, JSON.stringify({ errorKey: 'errors.agents.reminder_too_soon', errorParams: { messageId: 'm-1', retryAfter: 300 } }), 'http://x');
|
|
385
|
+
expect(reminderTooSoonText(err)).toContain('allowed in 300 seconds');
|
|
386
|
+
expect(reminderTooSoonText(new OrbotoApiError(409, '{}', 'http://x'))).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -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
|
|
86
|
-
oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true
|
|
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
|
|
118
|
-
oversizeReason: z.string().min(10).max(500).optional().describe('Required with allowOversize=true
|
|
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,6 +11,17 @@ 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
|
+
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. */
|
|
16
|
+
export function repeatMarks(m) {
|
|
17
|
+
const marks = [
|
|
18
|
+
m.priority === 'urgent' ? 'urgent' : '',
|
|
19
|
+
m.repeatCount && m.repeatCount > 1 ? `sent ${m.repeatCount} times` : '',
|
|
20
|
+
m.reminderCount ? `reminder ${m.reminderCount}` : '',
|
|
21
|
+
ESCALATED[m.escalationStep ?? 0] ?? '',
|
|
22
|
+
].filter(Boolean);
|
|
23
|
+
return marks.length ? ` [${marks.join(', ')}]` : '';
|
|
24
|
+
}
|
|
14
25
|
const UNREGISTERED_INBOX_HINT = 'Not registered: only account mail is listed. Register with orboto_session_start to see the mail addressed to your session.';
|
|
15
26
|
export const agentMessagesToolConfig = {
|
|
16
27
|
title: 'Agent inbox and message work',
|
|
@@ -95,9 +106,11 @@ export function makeAgentMessagesHandler(client) {
|
|
|
95
106
|
feedbackSent = res.feedbackSent;
|
|
96
107
|
}
|
|
97
108
|
let acked = 0;
|
|
109
|
+
let escalationsStopped = 0;
|
|
98
110
|
if (args.ackIds && args.ackIds.length > 0) {
|
|
99
111
|
const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds, instanceToken: mcpInstanceToken(undefined, extra) });
|
|
100
112
|
acked = res.acked;
|
|
113
|
+
escalationsStopped = res.escalationsStopped ?? 0;
|
|
101
114
|
}
|
|
102
115
|
const compact = !args.all;
|
|
103
116
|
const q = new URLSearchParams();
|
|
@@ -116,7 +129,7 @@ export function makeAgentMessagesHandler(client) {
|
|
|
116
129
|
else
|
|
117
130
|
q.set('excludeRef', instanceToken);
|
|
118
131
|
const { messages, sessionId, counts } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
|
|
119
|
-
const header = (m) => `${m.own ? `[sent] ${sentState(m)} ` : ''}[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
|
|
132
|
+
const header = (m) => `${m.own ? `[sent] ${sentState(m)} ` : ''}[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''}${repeatMarks(m)} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
|
|
120
133
|
const lines = messages.length === 0
|
|
121
134
|
? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
|
|
122
135
|
: messages.flatMap((m) => (compact ? compactLines(m, header(m)) : [`${header(m)}${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`]));
|
|
@@ -128,6 +141,8 @@ export function makeAgentMessagesHandler(client) {
|
|
|
128
141
|
if (args.dismiss) {
|
|
129
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` : ''}.`);
|
|
130
143
|
}
|
|
144
|
+
if (escalationsStopped > 0)
|
|
145
|
+
lines.unshift(`Stopped the urgent escalation of ${escalationsStopped} message(s).`);
|
|
131
146
|
if (sessionId)
|
|
132
147
|
lines.push(`This session: ${sessionId.slice(0, 8)} (ref ${instanceToken}).`);
|
|
133
148
|
else
|