@0xmaxma/claude-gateway 2.0.4 → 2.0.6
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/README.md +10 -1
- package/dist/agent/builtin-commands.d.ts +1 -1
- package/dist/agent/builtin-commands.d.ts.map +1 -1
- package/dist/agent/builtin-commands.js +3 -2
- package/dist/agent/builtin-commands.js.map +1 -1
- package/dist/agent/runner.d.ts +123 -9
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +215 -182
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/session-compaction-scheduler.d.ts +22 -0
- package/dist/agent/session-compaction-scheduler.d.ts.map +1 -0
- package/dist/agent/session-compaction-scheduler.js +40 -0
- package/dist/agent/session-compaction-scheduler.js.map +1 -0
- package/dist/agent/skill-learning/index.d.ts.map +1 -1
- package/dist/agent/skill-learning/index.js +27 -8
- package/dist/agent/skill-learning/index.js.map +1 -1
- package/dist/agent/skill-learning/reviewer.d.ts +3 -1
- package/dist/agent/skill-learning/reviewer.d.ts.map +1 -1
- package/dist/agent/skill-learning/reviewer.js +6 -2
- package/dist/agent/skill-learning/reviewer.js.map +1 -1
- package/dist/agent/turn-stream.d.ts +3 -1
- package/dist/agent/turn-stream.d.ts.map +1 -1
- package/dist/agent/turn-stream.js +8 -4
- package/dist/agent/turn-stream.js.map +1 -1
- package/dist/api/gateway-router.d.ts +2 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +323 -16
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/memory-activity.d.ts +18 -0
- package/dist/api/memory-activity.d.ts.map +1 -0
- package/dist/api/memory-activity.js +97 -0
- package/dist/api/memory-activity.js.map +1 -0
- package/dist/api/router.js +11 -11
- package/dist/api/router.js.map +1 -1
- package/dist/api/slack-webhook-router.js +1 -1
- package/dist/api/slack-webhook-router.js.map +1 -1
- package/dist/api/voice-router.d.ts +1 -0
- package/dist/api/voice-router.d.ts.map +1 -1
- package/dist/api/voice-router.js +129 -7
- package/dist/api/voice-router.js.map +1 -1
- package/dist/config/claude-settings.d.ts +14 -0
- package/dist/config/claude-settings.d.ts.map +1 -1
- package/dist/config/claude-settings.js +20 -0
- package/dist/config/claude-settings.js.map +1 -1
- package/dist/orchestration/agent-cli-session.d.ts +46 -0
- package/dist/orchestration/agent-cli-session.d.ts.map +1 -0
- package/dist/orchestration/agent-cli-session.js +121 -0
- package/dist/orchestration/agent-cli-session.js.map +1 -0
- package/dist/orchestration/agent-tool-schemas.d.ts +6 -0
- package/dist/orchestration/agent-tool-schemas.d.ts.map +1 -0
- package/dist/orchestration/agent-tool-schemas.js +23 -0
- package/dist/orchestration/agent-tool-schemas.js.map +1 -0
- package/dist/orchestration/bridge.d.ts +6 -3
- package/dist/orchestration/bridge.d.ts.map +1 -1
- package/dist/orchestration/bridge.js +31 -14
- package/dist/orchestration/bridge.js.map +1 -1
- package/dist/orchestration/browser-voice.d.ts +14 -0
- package/dist/orchestration/browser-voice.d.ts.map +1 -0
- package/dist/orchestration/browser-voice.js +30 -0
- package/dist/orchestration/browser-voice.js.map +1 -0
- package/dist/orchestration/compact-measurements.d.ts +21 -0
- package/dist/orchestration/compact-measurements.d.ts.map +1 -0
- package/dist/orchestration/compact-measurements.js +62 -0
- package/dist/orchestration/compact-measurements.js.map +1 -0
- package/dist/orchestration/config.d.ts +4 -0
- package/dist/orchestration/config.d.ts.map +1 -1
- package/dist/orchestration/config.js +1 -1
- package/dist/orchestration/config.js.map +1 -1
- package/dist/orchestration/context-delivery.d.ts +32 -0
- package/dist/orchestration/context-delivery.d.ts.map +1 -0
- package/dist/orchestration/context-delivery.js +101 -0
- package/dist/orchestration/context-delivery.js.map +1 -0
- package/dist/orchestration/context-footprint.d.ts +14 -0
- package/dist/orchestration/context-footprint.d.ts.map +1 -0
- package/dist/orchestration/context-footprint.js +57 -0
- package/dist/orchestration/context-footprint.js.map +1 -0
- package/dist/orchestration/conversation-intake.d.ts +10 -6
- package/dist/orchestration/conversation-intake.d.ts.map +1 -1
- package/dist/orchestration/conversation-intake.js +22 -8
- package/dist/orchestration/conversation-intake.js.map +1 -1
- package/dist/orchestration/dashboard-charts.d.ts +32 -0
- package/dist/orchestration/dashboard-charts.d.ts.map +1 -0
- package/dist/orchestration/dashboard-charts.js +77 -0
- package/dist/orchestration/dashboard-charts.js.map +1 -0
- package/dist/orchestration/dashboard-reader-worker.d.ts +2 -0
- package/dist/orchestration/dashboard-reader-worker.d.ts.map +1 -0
- package/dist/orchestration/dashboard-reader-worker.js +173 -0
- package/dist/orchestration/dashboard-reader-worker.js.map +1 -0
- package/dist/orchestration/dashboard-reader.d.ts +15 -0
- package/dist/orchestration/dashboard-reader.d.ts.map +1 -0
- package/dist/orchestration/dashboard-reader.js +87 -0
- package/dist/orchestration/dashboard-reader.js.map +1 -0
- package/dist/orchestration/decision-context.d.ts +15 -0
- package/dist/orchestration/decision-context.d.ts.map +1 -0
- package/dist/orchestration/decision-context.js +57 -0
- package/dist/orchestration/decision-context.js.map +1 -0
- package/dist/orchestration/display-stream.d.ts +21 -2
- package/dist/orchestration/display-stream.d.ts.map +1 -1
- package/dist/orchestration/display-stream.js +61 -8
- package/dist/orchestration/display-stream.js.map +1 -1
- package/dist/orchestration/inference-errors.d.ts.map +1 -1
- package/dist/orchestration/inference-errors.js +19 -21
- package/dist/orchestration/inference-errors.js.map +1 -1
- package/dist/orchestration/mutation-recovery.d.ts +13 -0
- package/dist/orchestration/mutation-recovery.d.ts.map +1 -0
- package/dist/orchestration/mutation-recovery.js +49 -0
- package/dist/orchestration/mutation-recovery.js.map +1 -0
- package/dist/orchestration/native-compact.d.ts +8 -0
- package/dist/orchestration/native-compact.d.ts.map +1 -0
- package/dist/orchestration/native-compact.js +33 -0
- package/dist/orchestration/native-compact.js.map +1 -0
- package/dist/orchestration/notification-mailbox.d.ts +3 -1
- package/dist/orchestration/notification-mailbox.d.ts.map +1 -1
- package/dist/orchestration/notification-mailbox.js +4 -2
- package/dist/orchestration/notification-mailbox.js.map +1 -1
- package/dist/orchestration/process-turn.d.ts +16 -2
- package/dist/orchestration/process-turn.d.ts.map +1 -1
- package/dist/orchestration/process-turn.js +78 -14
- package/dist/orchestration/process-turn.js.map +1 -1
- package/dist/orchestration/progress-review.d.ts +8 -18
- package/dist/orchestration/progress-review.d.ts.map +1 -1
- package/dist/orchestration/progress-review.js +12 -14
- package/dist/orchestration/progress-review.js.map +1 -1
- package/dist/orchestration/provider-message.d.ts +7 -0
- package/dist/orchestration/provider-message.d.ts.map +1 -0
- package/dist/orchestration/provider-message.js +63 -0
- package/dist/orchestration/provider-message.js.map +1 -0
- package/dist/orchestration/response-errors.d.ts +6 -0
- package/dist/orchestration/response-errors.d.ts.map +1 -0
- package/dist/orchestration/response-errors.js +88 -0
- package/dist/orchestration/response-errors.js.map +1 -0
- package/dist/orchestration/response-schema.d.ts +33 -0
- package/dist/orchestration/response-schema.d.ts.map +1 -0
- package/dist/orchestration/response-schema.js +61 -0
- package/dist/orchestration/response-schema.js.map +1 -0
- package/dist/orchestration/runtime.d.ts +79 -0
- package/dist/orchestration/runtime.d.ts.map +1 -1
- package/dist/orchestration/runtime.js +415 -68
- package/dist/orchestration/runtime.js.map +1 -1
- package/dist/orchestration/session-compaction.d.ts +59 -0
- package/dist/orchestration/session-compaction.d.ts.map +1 -0
- package/dist/orchestration/session-compaction.js +212 -0
- package/dist/orchestration/session-compaction.js.map +1 -0
- package/dist/orchestration/skills.d.ts.map +1 -1
- package/dist/orchestration/skills.js +3 -2
- package/dist/orchestration/skills.js.map +1 -1
- package/dist/orchestration/source-policy.d.ts +1 -1
- package/dist/orchestration/source-policy.d.ts.map +1 -1
- package/dist/orchestration/source-policy.js +1 -1
- package/dist/orchestration/speech.d.ts +15 -17
- package/dist/orchestration/speech.d.ts.map +1 -1
- package/dist/orchestration/speech.js +90 -30
- package/dist/orchestration/speech.js.map +1 -1
- package/dist/orchestration/store.d.ts +2 -0
- package/dist/orchestration/store.d.ts.map +1 -1
- package/dist/orchestration/store.js +16 -1
- package/dist/orchestration/store.js.map +1 -1
- package/dist/orchestration/tasks/driver.d.ts.map +1 -1
- package/dist/orchestration/tasks/driver.js +8 -3
- package/dist/orchestration/tasks/driver.js.map +1 -1
- package/dist/orchestration/tasks/pool.d.ts.map +1 -1
- package/dist/orchestration/tasks/pool.js +8 -4
- package/dist/orchestration/tasks/pool.js.map +1 -1
- package/dist/orchestration/tasks/service.d.ts +48 -1
- package/dist/orchestration/tasks/service.d.ts.map +1 -1
- package/dist/orchestration/tasks/service.js +41 -17
- package/dist/orchestration/tasks/service.js.map +1 -1
- package/dist/orchestration/token-ledger.d.ts +132 -0
- package/dist/orchestration/token-ledger.d.ts.map +1 -0
- package/dist/orchestration/token-ledger.js +233 -0
- package/dist/orchestration/token-ledger.js.map +1 -0
- package/dist/orchestration/token-usage.d.ts +46 -0
- package/dist/orchestration/token-usage.d.ts.map +1 -0
- package/dist/orchestration/token-usage.js +102 -0
- package/dist/orchestration/token-usage.js.map +1 -0
- package/dist/orchestration/tool-activity.d.ts.map +1 -1
- package/dist/orchestration/tool-activity.js +5 -3
- package/dist/orchestration/tool-activity.js.map +1 -1
- package/dist/orchestration/tool-name.d.ts +10 -0
- package/dist/orchestration/tool-name.d.ts.map +1 -0
- package/dist/orchestration/tool-name.js +14 -0
- package/dist/orchestration/tool-name.js.map +1 -0
- package/dist/orchestration/transcript-checkpoint.d.ts +14 -0
- package/dist/orchestration/transcript-checkpoint.d.ts.map +1 -0
- package/dist/orchestration/transcript-checkpoint.js +83 -0
- package/dist/orchestration/transcript-checkpoint.js.map +1 -0
- package/dist/orchestration/unanswered-inputs.d.ts +6 -0
- package/dist/orchestration/unanswered-inputs.d.ts.map +1 -0
- package/dist/orchestration/unanswered-inputs.js +18 -0
- package/dist/orchestration/unanswered-inputs.js.map +1 -0
- package/dist/session/process.d.ts +10 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +91 -9
- package/dist/session/process.js.map +1 -1
- package/dist/session/request-tool-capture.d.ts +31 -0
- package/dist/session/request-tool-capture.d.ts.map +1 -0
- package/dist/session/request-tool-capture.js +194 -0
- package/dist/session/request-tool-capture.js.map +1 -0
- package/dist/session/runtime-profile.d.ts +7 -3
- package/dist/session/runtime-profile.d.ts.map +1 -1
- package/dist/session/runtime-profile.js +3 -1
- package/dist/session/runtime-profile.js.map +1 -1
- package/dist/session/store.d.ts +7 -0
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +27 -0
- package/dist/session/store.js.map +1 -1
- package/dist/shell/tailer.d.ts +7 -5
- package/dist/shell/tailer.d.ts.map +1 -1
- package/dist/shell/tailer.js +6 -17
- package/dist/shell/tailer.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/cli-viewer-ui.js +1 -1
- package/dist/ui/dashboard-charts.d.ts +3 -0
- package/dist/ui/dashboard-charts.d.ts.map +1 -0
- package/dist/ui/dashboard-charts.js +130 -0
- package/dist/ui/dashboard-charts.js.map +1 -0
- package/dist/ui/dashboard-client.d.ts +3 -0
- package/dist/ui/dashboard-client.d.ts.map +1 -0
- package/dist/ui/dashboard-client.js +184 -0
- package/dist/ui/dashboard-client.js.map +1 -0
- package/dist/ui/dashboard-controls.d.ts +4 -0
- package/dist/ui/dashboard-controls.d.ts.map +1 -0
- package/dist/ui/dashboard-controls.js +27 -0
- package/dist/ui/dashboard-controls.js.map +1 -0
- package/dist/ui/dashboard-presentation.d.ts +12 -0
- package/dist/ui/dashboard-presentation.d.ts.map +1 -0
- package/dist/ui/dashboard-presentation.js +62 -0
- package/dist/ui/dashboard-presentation.js.map +1 -0
- package/dist/ui/dashboard-range.d.ts +5 -0
- package/dist/ui/dashboard-range.d.ts.map +1 -0
- package/dist/ui/dashboard-range.js +36 -0
- package/dist/ui/dashboard-range.js.map +1 -0
- package/dist/ui/dashboard-theme.d.ts +6 -0
- package/dist/ui/dashboard-theme.d.ts.map +1 -0
- package/dist/ui/dashboard-theme.js +562 -0
- package/dist/ui/dashboard-theme.js.map +1 -0
- package/dist/ui/memory-activity.d.ts +4 -0
- package/dist/ui/memory-activity.d.ts.map +1 -0
- package/dist/ui/memory-activity.js +115 -0
- package/dist/ui/memory-activity.js.map +1 -0
- package/dist/ui/token-report-client.d.ts +3 -0
- package/dist/ui/token-report-client.d.ts.map +1 -0
- package/dist/ui/token-report-client.js +34 -0
- package/dist/ui/token-report-client.js.map +1 -0
- package/dist/ui/token-report.d.ts +90 -0
- package/dist/ui/token-report.d.ts.map +1 -0
- package/dist/ui/token-report.js +203 -0
- package/dist/ui/token-report.js.map +1 -0
- package/dist/ui/web-ui.d.ts +1 -1
- package/dist/ui/web-ui.d.ts.map +1 -1
- package/dist/ui/web-ui.js +129 -409
- package/dist/ui/web-ui.js.map +1 -1
- package/dist/voice/replay.d.ts +8 -0
- package/dist/voice/replay.d.ts.map +1 -0
- package/dist/voice/replay.js +48 -0
- package/dist/voice/replay.js.map +1 -0
- package/dist/voice/session.d.ts +9 -0
- package/dist/voice/session.d.ts.map +1 -1
- package/dist/voice/session.js +72 -14
- package/dist/voice/session.js.map +1 -1
- package/mcp/lazy-connector.test.ts +58 -0
- package/mcp/lazy-connector.ts +67 -0
- package/mcp/lazy-tools.test.ts +152 -0
- package/mcp/lazy-tools.ts +41 -0
- package/mcp/server.ts +53 -34
- package/mcp/tools/discord/commands.ts +2 -0
- package/mcp/tools/tasks/module.ts +1 -12
- package/mcp/tools/telegram/receiver-server.ts +45 -31
- package/mcp/tools/telegram/session-browser.ts +10 -0
- package/package.json +2 -1
- package/resource/dashboard/OFL.txt +93 -0
- package/resource/dashboard/poppins-300.woff2 +0 -0
- package/resource/dashboard/poppins-400.woff2 +0 -0
- package/resource/dashboard/poppins-500.woff2 +0 -0
- package/resource/dashboard/poppins-600.woff2 +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { createLazyToolCatalog } from './lazy-tools';
|
|
3
|
+
import type { McpToolDefinition } from './types';
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const defs: McpToolDefinition[] = Array.from({ length: 23 }, (_, i) => ({ name: `tool_${String(i).padStart(2, '0')}`, description: `Original description ${i}`,
|
|
11
|
+
inputSchema: { type: 'object', properties: { target: { type: 'string', enum: ['a', 'b'] } }, required: ['target'], additionalProperties: false } }));
|
|
12
|
+
const parse = (r: ReturnType<ReturnType<typeof createLazyToolCatalog>['search']>) => JSON.parse(r.content[0].text);
|
|
13
|
+
|
|
14
|
+
describe('lazy gateway tools', () => {
|
|
15
|
+
test('bounded discovery lists all metadata and exact original schemas on demand', () => {
|
|
16
|
+
const catalog = createLazyToolCatalog(defs);
|
|
17
|
+
const index = parse(catalog.search({ limit: 20 }));
|
|
18
|
+
expect(index.total).toBe(23); expect(index.next_offset).toBe(20);
|
|
19
|
+
expect(index.tools[0]).toEqual({ name: defs[0].name, description: defs[0].description });
|
|
20
|
+
expect(parse(catalog.search({ offset: 20 })).tools).toHaveLength(3);
|
|
21
|
+
expect(parse(catalog.search({ name: 'TOOL_01' })).tools).toEqual([defs[1]]);
|
|
22
|
+
expect(parse(catalog.search({ query: 'ORIGINAL description 22' })).tools).toEqual([defs[22]]);
|
|
23
|
+
expect(catalog.search({ limit: 21 }).isError).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
test('unauthorized, task and recursive tools never reach dispatcher', async () => {
|
|
26
|
+
let calls = 0; const dispatch = async () => { calls++; return { content: [] }; };
|
|
27
|
+
const catalog = createLazyToolCatalog(defs);
|
|
28
|
+
for (const name of ['missing', 'task_spawn', 'tool_call', 'tool_search']) expect((await catalog.call({ name, arguments: {} }, dispatch)).isError).toBe(true);
|
|
29
|
+
expect((await catalog.call({ name: 'tool_00', arguments: [] }, dispatch)).isError).toBe(true);
|
|
30
|
+
expect(calls).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
test('excludeReserved filters an underlying tool literally named tool_search/tool_call; the connector opt-out does not', async () => {
|
|
33
|
+
const collision: McpToolDefinition[] = [...defs,
|
|
34
|
+
{ name: 'tool_search', description: 'Underlying tool that collides with the reserved name', inputSchema: { type: 'object', properties: {}, additionalProperties: false } }];
|
|
35
|
+
let calls = 0; const dispatch = async () => { calls++; return { content: [] }; };
|
|
36
|
+
const filtered = createLazyToolCatalog(collision);
|
|
37
|
+
expect(parse(filtered.search({ name: 'tool_search' })).total).toBe(0);
|
|
38
|
+
expect((await filtered.call({ name: 'tool_search', arguments: {} }, dispatch)).isError).toBe(true);
|
|
39
|
+
expect(calls).toBe(0);
|
|
40
|
+
const unfiltered = createLazyToolCatalog(collision, false);
|
|
41
|
+
expect(parse(unfiltered.search({ name: 'tool_search' })).total).toBe(1);
|
|
42
|
+
expect((await unfiltered.call({ name: 'tool_search', arguments: {} }, dispatch)).isError).toBeFalsy();
|
|
43
|
+
expect(calls).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
test('preserves original arguments, validation errors and cancellation through dispatcher', async () => {
|
|
46
|
+
const catalog = createLazyToolCatalog(defs), args = { target: 'invalid' };
|
|
47
|
+
const denial = { isError: true, content: [{ type: 'text' as const, text: 'original validation rejected target' }] };
|
|
48
|
+
expect(await catalog.call({ name: 'tool_00', arguments: args }, async (name, actual) => {
|
|
49
|
+
expect(name).toBe('tool_00'); expect(actual).toBe(args); return denial;
|
|
50
|
+
})).toBe(denial);
|
|
51
|
+
const controller = new AbortController(); controller.abort();
|
|
52
|
+
await expect(catalog.call({ name: 'tool_00', arguments: {} }, async () => { controller.signal.throwIfAborted(); return denial; })).rejects.toThrow();
|
|
53
|
+
});
|
|
54
|
+
test('real MCP worker facade preserves ticket validation for direct and wrapped calls', async () => {
|
|
55
|
+
const dir = mkdtempSync(join(tmpdir(), 'gateway-lazy-'));
|
|
56
|
+
const requests: string[] = [];
|
|
57
|
+
const bridge = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) {
|
|
58
|
+
requests.push((await req.json()).tool);
|
|
59
|
+
return new Response('TASK_TICKET_REVOKED', { status: 403 });
|
|
60
|
+
} });
|
|
61
|
+
const ticket = join(dir, 'ticket.json');
|
|
62
|
+
writeFileSync(ticket, JSON.stringify({ url: `http://127.0.0.1:${bridge.port}`, token: 'test-only' }));
|
|
63
|
+
const client = new Client({ name: 'lazy-test', version: '1' });
|
|
64
|
+
const transport = new StdioClientTransport({ command: 'bun', args: [join(import.meta.dir, 'server.ts')], stderr: 'ignore', env: {
|
|
65
|
+
PATH: process.env.PATH ?? '', HOME: dir, GATEWAY_ORCHESTRATION_ROLE: 'worker', GATEWAY_ORCHESTRATION_MEDIA: 'true',
|
|
66
|
+
GATEWAY_LAZY_TOOLS: 'true', GATEWAY_ORCHESTRATION_TICKET_FILE: ticket, IMAGE_DISABLED: 'true', VIDEO_DISABLED: 'true',
|
|
67
|
+
} });
|
|
68
|
+
try {
|
|
69
|
+
await client.connect(transport);
|
|
70
|
+
const list = await client.listTools();
|
|
71
|
+
expect(list.tools.some(t => t.name === 'tool_search')).toBe(true);
|
|
72
|
+
expect(list.tools.some(t => t.name === 'browser_navigate')).toBe(false);
|
|
73
|
+
expect(list.tools.some(t => t.name === 'task_report_progress')).toBe(true);
|
|
74
|
+
const found = await client.callTool({ name: 'tool_search', arguments: { name: 'browser_navigate' } });
|
|
75
|
+
expect(JSON.parse((found.content as Array<{ text: string }>)[0].text).tools[0].inputSchema).toBeDefined();
|
|
76
|
+
for (const call of [{ name: 'browser_navigate', arguments: { url: 'https://example.test' } },
|
|
77
|
+
{ name: 'tool_call', arguments: { name: 'browser_navigate', arguments: { url: 'https://example.test' } } }]) {
|
|
78
|
+
const response = await client.callTool(call);
|
|
79
|
+
expect(response.isError).toBe(true);
|
|
80
|
+
expect((response.content as Array<{ text: string }>)[0].text).toBe('TASK_TICKET_REVOKED');
|
|
81
|
+
}
|
|
82
|
+
expect(requests).toEqual(['task_validate', 'task_validate']);
|
|
83
|
+
} finally { await client.close(); bridge.stop(true); rmSync(dir, { recursive: true, force: true }); }
|
|
84
|
+
}, 15000);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('real lazy inventory preserves every execution schema and successful browser behavior', async () => {
|
|
88
|
+
const dir = mkdtempSync(join(tmpdir(), 'gateway-lazy-smoke-'));
|
|
89
|
+
const browserCalls: unknown[] = [], bridgeCalls: unknown[] = [];
|
|
90
|
+
const browserResult = { content: [{ type: 'text', text: JSON.stringify({ title: 'Stub page', status: 'ready' }) }] };
|
|
91
|
+
const local = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) {
|
|
92
|
+
const body = await req.json();
|
|
93
|
+
if (new URL(req.url).pathname === '/ticket') {
|
|
94
|
+
bridgeCalls.push(body); return Response.json({ valid: true });
|
|
95
|
+
}
|
|
96
|
+
if (new URL(req.url).pathname === '/mcp') {
|
|
97
|
+
browserCalls.push(body);
|
|
98
|
+
return new Response(`data: ${JSON.stringify({ jsonrpc: '2.0', id: body.id, result: browserResult })}\n\n`, { headers: { 'Content-Type': 'text/event-stream' } });
|
|
99
|
+
}
|
|
100
|
+
throw new Error('Unexpected network call: ' + req.url);
|
|
101
|
+
} });
|
|
102
|
+
const base = `http://127.0.0.1:${local.port}`, ticket = join(dir, 'ticket.json');
|
|
103
|
+
writeFileSync(ticket, JSON.stringify({ url: base + '/ticket', token: 'synthetic-only' }));
|
|
104
|
+
const common = { PATH: process.env.PATH ?? '', HOME: dir, GATEWAY_ORCHESTRATION_ROLE: 'worker', GATEWAY_ORCHESTRATION_MEDIA: 'true',
|
|
105
|
+
GATEWAY_ORCHESTRATION_TICKET_FILE: ticket, GETPOD_BROWSER_URL: base, GETPOD_BROWSER_API_KEY: 'synthetic-only',
|
|
106
|
+
ANTHROPIC_BASE_URL: base, ANTHROPIC_AUTH_TOKEN: 'synthetic-only', GATEWAY_SESSION_ID: 'synthetic-session', GATEWAY_AGENT_ID: 'synthetic-agent' };
|
|
107
|
+
const clients: Client[] = [];
|
|
108
|
+
const start = performance.now();
|
|
109
|
+
try {
|
|
110
|
+
for (const lazy of [false, true]) {
|
|
111
|
+
const client = new Client({ name: 'local-inventory-smoke', version: '1' }); clients.push(client);
|
|
112
|
+
await client.connect(new StdioClientTransport({ command: 'bun', args: [join(import.meta.dir, 'server.ts')], stderr: 'ignore',
|
|
113
|
+
env: { ...common, GATEWAY_LAZY_TOOLS: String(lazy) } }));
|
|
114
|
+
}
|
|
115
|
+
const readyMs = performance.now() - start;
|
|
116
|
+
const direct = (await clients[0].listTools()).tools, lazy = (await clients[1].listTools()).tools;
|
|
117
|
+
const execution = direct.filter(tool => !lazy.some(other => other.name === tool.name));
|
|
118
|
+
expect(execution.map(t => t.name)).toContain('generate_image');
|
|
119
|
+
expect(execution.map(t => t.name)).toContain('generate_video');
|
|
120
|
+
const searchStart = performance.now();
|
|
121
|
+
for (const tool of execution) {
|
|
122
|
+
const found = await clients[1].callTool({ name: 'tool_search', arguments: { name: tool.name } });
|
|
123
|
+
const entry = JSON.parse((found.content as Array<{ text: string }>)[0].text).tools[0];
|
|
124
|
+
expect(entry).toEqual(tool);
|
|
125
|
+
}
|
|
126
|
+
const schemaSearchMs = performance.now() - searchStart;
|
|
127
|
+
const args = { url: 'https://example.test/synthetic?q=1', tab_id: 'stub-tab' };
|
|
128
|
+
const directStart = performance.now();
|
|
129
|
+
const a = await clients[0].callTool({ name: 'browser_navigate', arguments: args });
|
|
130
|
+
const directCallMs = performance.now() - directStart;
|
|
131
|
+
const wrappedStart = performance.now();
|
|
132
|
+
const b = await clients[1].callTool({ name: 'tool_call', arguments: { name: 'browser_navigate', arguments: args } });
|
|
133
|
+
const wrappedCallMs = performance.now() - wrappedStart;
|
|
134
|
+
expect(a).toEqual(b); expect(a.isError).not.toBe(true);
|
|
135
|
+
expect(a.content).toEqual(browserResult.content);
|
|
136
|
+
expect(browserCalls).toHaveLength(2);
|
|
137
|
+
const params = browserCalls.map(call => (call as { params: unknown }).params);
|
|
138
|
+
expect(params[0]).toEqual(params[1]);
|
|
139
|
+
expect(params[0]).toEqual({ name: 'browser_navigate', arguments: { ...args, session_id: 'synthetic-session', agent_id: 'synthetic-agent' } });
|
|
140
|
+
expect(bridgeCalls.map(call => (call as { tool: string }).tool)).toEqual(['task_validate', 'task_validate']);
|
|
141
|
+
writeFileSync('/tmp/gateway-500-lazy-schema-smoke.json', JSON.stringify({
|
|
142
|
+
measuredAt: new Date().toISOString(), fixture: 'local synthetic browser and ticket bridge; no external provider/model calls',
|
|
143
|
+
directToolCount: direct.length, lazyToolCount: lazy.length, deferredToolCount: execution.length,
|
|
144
|
+
directSchemaBytes: Buffer.byteLength(JSON.stringify(direct)), lazySchemaBytes: Buffer.byteLength(JSON.stringify(lazy)),
|
|
145
|
+
readyMs, schemaSearchMs, directCallMs, wrappedCallMs,
|
|
146
|
+
note: 'Single local smoke timing, not a performance benchmark or model-level token savings measurement.',
|
|
147
|
+
}, null, 2));
|
|
148
|
+
} finally {
|
|
149
|
+
for (const client of clients) await client.close();
|
|
150
|
+
local.stop(true); rmSync(dir, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
}, 15000);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { McpToolDefinition, McpToolResult } from './types';
|
|
2
|
+
|
|
3
|
+
export const LAZY_TOOL_DEFINITIONS: McpToolDefinition[] = [
|
|
4
|
+
{ name: 'tool_search', description: 'Discover available gateway capabilities. Empty query lists tool names and descriptions; search by query or exact name to retrieve original argument schemas before calling tool_call. Results are paginated. This catalog contains only tools authorized for this worker.',
|
|
5
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' }, name: { type: 'string' }, offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 20 }, include_schema: { type: 'boolean' } }, additionalProperties: false } },
|
|
6
|
+
{ name: 'tool_call', description: 'Execute a gateway capability discovered with tool_search. Use its exact name and original arguments. All original permissions, validation and cancellation apply. Task reporting and memory retrieval remain directly available.',
|
|
7
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string' }, arguments: { type: 'object' } }, required: ['name', 'arguments'], additionalProperties: false } },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const fail = (text: string): McpToolResult => ({ content: [{ type: 'text', text }], isError: true });
|
|
11
|
+
const result = (value: unknown): McpToolResult => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
|
|
12
|
+
|
|
13
|
+
/** Discovery is data-only. Invocation delegates to the SAME dispatcher as direct calls. */
|
|
14
|
+
export function createLazyToolCatalog(tools: readonly McpToolDefinition[], excludeReserved = true) {
|
|
15
|
+
const reserved = new Set(LAZY_TOOL_DEFINITIONS.map(tool => tool.name));
|
|
16
|
+
const catalog = [...tools].filter(tool => !excludeReserved || !reserved.has(tool.name))
|
|
17
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
18
|
+
const names = new Set(catalog.map(tool => tool.name));
|
|
19
|
+
return {
|
|
20
|
+
search(args: Record<string, unknown>): McpToolResult {
|
|
21
|
+
if ((args.query !== undefined && typeof args.query !== 'string') ||
|
|
22
|
+
(args.name !== undefined && typeof args.name !== 'string') ||
|
|
23
|
+
(args.include_schema !== undefined && typeof args.include_schema !== 'boolean')) return fail('Invalid search arguments');
|
|
24
|
+
const offset = args.offset ?? 0; const limit = args.limit ?? 5;
|
|
25
|
+
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 ||
|
|
26
|
+
typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 20) return fail('Invalid search pagination');
|
|
27
|
+
const query = String(args.query ?? '').trim().toLowerCase();
|
|
28
|
+
const name = String(args.name ?? '').trim().toLowerCase();
|
|
29
|
+
const matches = catalog.filter(tool => name ? tool.name.toLowerCase() === name :
|
|
30
|
+
query.split(/\s+/).every(word => `${tool.name} ${tool.description}`.toLowerCase().includes(word)));
|
|
31
|
+
const schemas = args.include_schema ?? Boolean(query || name);
|
|
32
|
+
return result({ total: matches.length, offset, next_offset: offset + limit < matches.length ? offset + limit : null,
|
|
33
|
+
tools: matches.slice(offset, offset + limit).map(tool => schemas ? tool : { name: tool.name, description: tool.description }) });
|
|
34
|
+
},
|
|
35
|
+
async call(args: Record<string, unknown>, dispatch: (name: string, input: Record<string, unknown>) => Promise<McpToolResult>): Promise<McpToolResult> {
|
|
36
|
+
if (typeof args.name !== 'string' || !names.has(args.name)) return fail('Unknown or unauthorized gateway tool');
|
|
37
|
+
if (!args.arguments || typeof args.arguments !== 'object' || Array.isArray(args.arguments)) return fail('Tool arguments must be an object');
|
|
38
|
+
return dispatch(args.name, args.arguments as Record<string, unknown>);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
package/mcp/server.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
15
15
|
import { gatewayModules } from './modules';
|
|
16
16
|
import { AGENT_TASK_TOOLS, WORKER_REPORT_TOOLS, callTaskBridge } from './tools/tasks/module';
|
|
17
|
+
import { createLazyToolCatalog, LAZY_TOOL_DEFINITIONS } from './lazy-tools';
|
|
17
18
|
import { buildChannelInstructions } from './instructions';
|
|
18
19
|
import type { ChannelModule, ToolModule, McpToolDefinition } from './types';
|
|
19
20
|
|
|
@@ -52,9 +53,23 @@ for (const mod of modules) {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// conversation_intake is declared on every agent turn, whatever the semantic-intake feature
|
|
57
|
+
// state. The advertised tool list is position 0 of Anthropic's cached [tools, system, messages]
|
|
58
|
+
// prefix, and the feature is decided per turn (runtime.ts computes it from the flag AND from
|
|
59
|
+
// whether this turn is a notification), so filtering the declaration here re-wrote the prefix
|
|
60
|
+
// mid-session and invalidated the whole cache — the same defect ee800a7 fixed for the response
|
|
61
|
+
// schema. The turn's INTAKE_OVERLAY prompt text, below the cache breakpoint, is what asks for
|
|
62
|
+
// the tool; the bridge refuses the call outright when the feature is not active.
|
|
63
|
+
const taskTools = ORCHESTRATION_ROLE === 'agent' ? AGENT_TASK_TOOLS.filter(tool => tool.name !== 'capabilities_list' || process.env.GATEWAY_CAPABILITY_CATALOG === 'true') : ORCHESTRATION_ROLE === 'worker' ? WORKER_REPORT_TOOLS.filter(tool => tool.name !== 'task_memory_append' || process.env.GATEWAY_ORCHESTRATION_WRITE_MEMORY === 'true') : [];
|
|
56
64
|
if (ORCHESTRATION_ROLE && process.env.GATEWAY_ORCHESTRATION_TICKET_FILE) visibleTools.push(...taskTools);
|
|
57
65
|
|
|
66
|
+
// Preserve the full policy-filtered inventory internally; only schema exposure is lazy.
|
|
67
|
+
const lazyEnabled = ORCHESTRATION_ROLE === 'worker' && process.env.GATEWAY_LAZY_TOOLS === 'true';
|
|
68
|
+
const deferredTools = visibleTools.filter(tool => toolMap.has(tool.name) && !['memory_search','memory_get'].includes(tool.name));
|
|
69
|
+
const deferredNames = new Set(deferredTools.map(tool => tool.name));
|
|
70
|
+
const lazyCatalog = createLazyToolCatalog(deferredTools);
|
|
71
|
+
const advertisedTools = lazyEnabled ? [...visibleTools.filter(tool => !deferredNames.has(tool.name)), ...LAZY_TOOL_DEFINITIONS] : visibleTools;
|
|
72
|
+
|
|
58
73
|
// Initialize channel modules so their bot API clients are ready for tool calls.
|
|
59
74
|
// initBot() returns immediately after creating the client — no blocking.
|
|
60
75
|
for (const mod of modules) {
|
|
@@ -79,47 +94,51 @@ const mcp = new Server(
|
|
|
79
94
|
'claude/channel/permission': {},
|
|
80
95
|
},
|
|
81
96
|
},
|
|
82
|
-
instructions: ORCHESTRATION_ROLE ? 'Use scoped capability discovery, task tools and memory retrieval. The agent writes user-facing responses; workers return task results and orchestration handles delivery.' : buildChannelInstructions(imageEnabled, videoEnabled),
|
|
97
|
+
instructions: ORCHESTRATION_ROLE ? 'Use scoped capability discovery, task tools and memory retrieval. The agent writes user-facing responses; workers return task results and orchestration handles delivery.' + (lazyEnabled ? ' Gateway execution capabilities are available through tool_search and tool_call: search for the capability to read its original arguments, then invoke it by exact name. An unlisted execution tool is not unavailable; inspect the catalog. Task reporting and memory retrieval remain direct tools.' : '') : buildChannelInstructions(imageEnabled, videoEnabled),
|
|
83
98
|
},
|
|
84
99
|
);
|
|
85
100
|
|
|
86
101
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
87
|
-
tools:
|
|
102
|
+
tools: advertisedTools,
|
|
88
103
|
}));
|
|
89
104
|
|
|
90
105
|
mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
91
|
-
const
|
|
106
|
+
const dispatch = async (toolName: string, args: Record<string, unknown>) => {
|
|
107
|
+
if (ORCHESTRATION_ROLE && process.env.GATEWAY_ORCHESTRATION_TICKET_FILE && taskTools.some(tool => tool.name === toolName)) {
|
|
108
|
+
return callTaskBridge(toolName, args, String(extra.requestId), AbortSignal.any([extra.signal, shutdownController.signal]));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const mod = toolMap.get(toolName);
|
|
112
|
+
if (!mod) {
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: 'text', text: `unknown tool: ${toolName}` }],
|
|
115
|
+
isError: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (ORCHESTRATION_ROLE === 'worker' && process.env.GATEWAY_ORCHESTRATION_MEDIA === 'true') {
|
|
119
|
+
const validation = await callTaskBridge('task_validate', {}, String(extra.requestId), AbortSignal.any([extra.signal, shutdownController.signal]));
|
|
120
|
+
if (validation.isError) return validation;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// extra.signal fires on notifications/cancelled for THIS call (the SDK matches
|
|
124
|
+
// it by requestId) — the CLI sends that when a user Stop/Ctrl-C interrupts an
|
|
125
|
+
// in-flight tool call. Threaded through so a long-running module (image
|
|
126
|
+
// generation's poll loop) can react instead of running to its full timeout.
|
|
127
|
+
// Also combine with shutdownController.signal: the CLI sending that
|
|
128
|
+
// notification is only a "SHOULD" in the MCP spec, not a "MUST", and in
|
|
129
|
+
// practice a Stop that lets the CLI process exit cleanly (rather than
|
|
130
|
+
// staying alive to keep chatting) closes this server's stdin without ever
|
|
131
|
+
// sending notifications/cancelled — extra.signal would then never fire, and
|
|
132
|
+
// an in-flight image generation would poll to its full timeout instead of
|
|
133
|
+
// cancelling. stdin closing is a reliable, protocol-independent signal that
|
|
134
|
+
// the turn is over either way, so it backstops the notification.
|
|
135
|
+
const combinedSignal = AbortSignal.any([extra.signal, shutdownController.signal]);
|
|
136
|
+
return mod.handleTool(toolName, args, combinedSignal);
|
|
137
|
+
};
|
|
92
138
|
const args = (req.params.arguments ?? {}) as Record<string, unknown>;
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const mod = toolMap.get(toolName);
|
|
98
|
-
if (!mod) {
|
|
99
|
-
return {
|
|
100
|
-
content: [{ type: 'text', text: `unknown tool: ${toolName}` }],
|
|
101
|
-
isError: true,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
if (ORCHESTRATION_ROLE === 'worker' && process.env.GATEWAY_ORCHESTRATION_MEDIA === 'true') {
|
|
105
|
-
const validation = await callTaskBridge('task_validate', {}, String(extra.requestId), AbortSignal.any([extra.signal, shutdownController.signal]));
|
|
106
|
-
if (validation.isError) return validation;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// extra.signal fires on notifications/cancelled for THIS call (the SDK matches
|
|
110
|
-
// it by requestId) — the CLI sends that when a user Stop/Ctrl-C interrupts an
|
|
111
|
-
// in-flight tool call. Threaded through so a long-running module (image
|
|
112
|
-
// generation's poll loop) can react instead of running to its full timeout.
|
|
113
|
-
// Also combine with shutdownController.signal: the CLI sending that
|
|
114
|
-
// notification is only a "SHOULD" in the MCP spec, not a "MUST", and in
|
|
115
|
-
// practice a Stop that lets the CLI process exit cleanly (rather than
|
|
116
|
-
// staying alive to keep chatting) closes this server's stdin without ever
|
|
117
|
-
// sending notifications/cancelled — extra.signal would then never fire, and
|
|
118
|
-
// an in-flight image generation would poll to its full timeout instead of
|
|
119
|
-
// cancelling. stdin closing is a reliable, protocol-independent signal that
|
|
120
|
-
// the turn is over either way, so it backstops the notification.
|
|
121
|
-
const combinedSignal = AbortSignal.any([extra.signal, shutdownController.signal]);
|
|
122
|
-
return mod.handleTool(toolName, args, combinedSignal);
|
|
139
|
+
if (lazyEnabled && req.params.name === 'tool_search') return lazyCatalog.search(args);
|
|
140
|
+
if (lazyEnabled && req.params.name === 'tool_call') return lazyCatalog.call(args, dispatch);
|
|
141
|
+
return dispatch(req.params.name, args);
|
|
123
142
|
});
|
|
124
143
|
|
|
125
144
|
// Connect MCP transport
|
|
@@ -11,6 +11,8 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
|
|
11
11
|
description: 'Ask the agent a question',
|
|
12
12
|
options: [{ name: 'question', description: 'Your question', required: true, type: 'STRING' }],
|
|
13
13
|
},
|
|
14
|
+
{ name: 'clear', description: 'Reset model context without deleting chat history' },
|
|
15
|
+
{ name: 'compact', description: 'Compact Claude Code context without changing chat history' },
|
|
14
16
|
{ name: 'session', description: 'Show current session info' },
|
|
15
17
|
{
|
|
16
18
|
name: 'new',
|
|
@@ -5,18 +5,7 @@ import type { McpToolDefinition, McpToolResult } from '../../types';
|
|
|
5
5
|
|
|
6
6
|
const schema = (properties: Record<string, unknown>, required: string[]) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
7
7
|
const text = { type: 'string' };
|
|
8
|
-
export
|
|
9
|
-
{ name: 'capabilities_list', description: 'Discover all capabilities this agent can offer: installed/shared/native skills, gateway MCP tools and enabled connector/plugin MCP tools, including tools executed only by workers. Read metadata only; never executes a tool or grants permissions. Use query to find relevant tools before assigning work. Follow next_offset until null when the user asks for the complete list, passing catalog_version from the first page. On CAPABILITY_CATALOG_CHANGED restart from offset 0. Report unavailable discovery honestly; do not confuse your own restricted callable tools with the capabilities of the agent as a whole.', inputSchema: schema({query:text,catalog_version:text,offset:{type:'integer',minimum:0}},[]) },
|
|
10
|
-
{ name: 'conversation_intake', description: 'Prepare executable work, incomplete materials, or an amendment to an existing task. Do not call this for greetings, introductions, casual conversation or questions you can answer directly; answer those normally once. When semantic intake is enabled, acknowledge before work mutations. Incomplete material is inspected now; the gateway asks the supplied clarification after the configured silence interval, not on every item.',
|
|
11
|
-
inputSchema: schema({mode:{type:'string',enum:['ready','wait','update']},acknowledgement:{type:'string',description:'A brief contextual message addressed directly to the user about the work you will do. Immediately displayed and spoken aloud. Never include private classification, reasoning, or instructions to yourself.'},preparation:text,clarification:text,task_id:text},['mode']) },
|
|
12
|
-
{ name: 'task_spawn', description: 'Durably queue a task for an independent worker. Returns after commit without waiting for worker startup or completion.',
|
|
13
|
-
inputSchema: schema({ title: text, instructions: text, target_profile: text, spoken_acknowledgement: { type: 'string', description: 'Required for live voice: a short natural response in the user language explaining the specific work you are about to do. Describe the specific user request as your own task, in the voice of your existing persona; do not announce handing it to a worker or use a generic acknowledgement. Speak as the person doing the work; avoid Thai delegation phrases such as สั่งเช็คแล้ว, สั่งไปแล้ว or ส่งให้ตรวจแล้ว. Describe queued work as accepted/to do next, and only say it is running when that state is confirmed. Do not claim completion. The gateway manages playback and channel text-before-voice ordering.' }, skill_name: { type: 'string', description: 'Exact installed gateway or discovered Claude Code runtime skill name for skill-worker. Native skills are invoked directly by the CLI; shared/workspace skills are supplied with their resources. Never supply a file path.' }, skill_args: { type: 'string', description: 'Arguments for the installed skill; required with skill_name.' }, continue_task_id: { type: 'string', description: 'Prior task ID in this conversation for related follow-up work. Reuses its worker session when compatible; waits for success by default. Queue every authorized dependent step now, using each returned task ID for the next step. Omit for independent work.' }, continuation_policy: { type: 'string', enum: ['after_success', 'after_terminal'], description: 'With continue_task_id: after_success (default) starts only after the predecessor succeeds; otherwise fails without executing. Use after_terminal only for explicitly authorized recovery/inspection that must run after failure or cancellation.' }, context_refs: { type: 'array', items: text, description: 'Only orchestration input IDs, authorized attachment refs or task artifact IDs. Put project filenames in instructions instead.' } }, ['title', 'instructions', 'target_profile']) },
|
|
14
|
-
{ name: 'task_status', description: 'Read the task index in this conversation without waiting. Pass task_id to retrieve the complete stored result and evidence before discussing an earlier task. An index entry is not a partial worker report.', inputSchema: schema({ task_id: text }, []) },
|
|
15
|
-
{ name: 'task_cancel', description: 'Request cancellation, including needs_reconciliation cleanup. cancel_requested is not proof of termination. Existing files are retained. Set replaced_by_task_id only for a known replacement task, never infer from similar titles.', inputSchema: schema({ task_id: text, replaced_by_task_id: text }, ['task_id']) },
|
|
16
|
-
{ name: 'task_update', description: 'Accept a revised task instruction. applied_revision changes only when the worker accepts it.', inputSchema: schema({ task_id: text, expected_revision: { type: 'integer', minimum: 1 }, instruction: text, mode: { type: 'string', enum: ['when_ready', 'interrupt_and_resume'] } }, ['task_id', 'expected_revision', 'instruction', 'mode']) },
|
|
17
|
-
{ name: 'task_question', description: 'Manage pending questions without answering or resuming work. Ask stages one natural separate message after your reply; discuss marks ongoing consultation; defer/mute/resume persist the user reminder preference.', inputSchema: schema({ action: { type: 'string', enum: ['ask','discuss','defer','mute','resume'] }, question_ids: { type: 'array', items: text, minItems: 1, maxItems: 20 }, text, delay_ms: { type: 'integer', minimum: 60000, maximum: 2592000000 } }, ['action', 'question_ids']) },
|
|
18
|
-
{ name: 'task_answer', description: 'Answer the matching pending question on a task.', inputSchema: schema({ task_id: text, question_id: text, answer: text }, ['task_id', 'question_id', 'answer']) },
|
|
19
|
-
];
|
|
8
|
+
export { AGENT_TASK_TOOLS } from '../../../dist/orchestration/agent-tool-schemas.js';
|
|
20
9
|
export const WORKER_REPORT_TOOLS: McpToolDefinition[] = [
|
|
21
10
|
{ name: 'task_memory_append', description: 'Append an explicitly requested memory note for a channel task. API tasks cannot write memory. Files are restricted to MEMORY.md, USER.md, or memory/<name>.md. Retains existing content and deduplicates retries.', inputSchema: schema({ note: text, path: text }, ['note']) },
|
|
22
11
|
{ name: 'task_stage_file', description: 'Stage a finished image or document for the user. The orchestration attaches it to the next completed agent response after this task succeeds, using the original destination. This tool does not send to a channel. Call once per output file. For an image returned by an MCP tool (such as a remote browser screenshot), omit path to use the latest captured image in this attempt, or pass source_tool_call_id to select its tool call. The gateway saves the actual image bytes; never invent a screenshot filename. For an existing local file supply path.', inputSchema: schema({ path: text, source_tool_call_id: text, caption: text }, []) },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { parseSessionInfo } from './session-browser';
|
|
2
3
|
/**
|
|
3
4
|
* Telegram channel for Claude Gateway.
|
|
4
5
|
*
|
|
@@ -877,8 +878,8 @@ const BOT_COMMANDS = [
|
|
|
877
878
|
{ command: 'sessions', description: 'Manage conversation sessions' },
|
|
878
879
|
{ command: 'new', description: 'Create a new session' },
|
|
879
880
|
{ command: 'rename', description: 'Rename current session' },
|
|
880
|
-
{ command: 'clear', description: '
|
|
881
|
-
{ command: 'compact', description: '
|
|
881
|
+
{ command: 'clear', description: 'Reset Claude Code context; keep chat history' },
|
|
882
|
+
{ command: 'compact', description: 'Compact Claude Code context' },
|
|
882
883
|
{ command: 'stop', description: ORCHESTRATION_ENABLED ? 'Stop the agent reply and choose a task to cancel' : 'Interrupt the agent and stop current work' },
|
|
883
884
|
{ command: 'restart', description: 'Graceful restart session' },
|
|
884
885
|
{ command: 'model', description: 'Show current AI model' },
|
|
@@ -1169,8 +1170,8 @@ bot.command('help', async ctx => {
|
|
|
1169
1170
|
`/sessions — list and switch between sessions\n` +
|
|
1170
1171
|
`/new <name> — create a new session\n` +
|
|
1171
1172
|
`/rename <name> — rename current session\n` +
|
|
1172
|
-
`/clear —
|
|
1173
|
-
`/compact —
|
|
1173
|
+
`/clear — reset context; keep chat history\n` +
|
|
1174
|
+
`/compact — compact Claude Code context; keep chat history\n` +
|
|
1174
1175
|
(ORCHESTRATION_ENABLED ? `/stop — stop the reply and choose a task to cancel\n` : `/stop — interrupt the running turn\n`) +
|
|
1175
1176
|
`/restart — graceful restart session\n\n` +
|
|
1176
1177
|
`*Agent*\n` +
|
|
@@ -1355,7 +1356,7 @@ bot.command('compact', async ctx => {
|
|
|
1355
1356
|
.text('\u274c Cancel', 'compact:cancel')
|
|
1356
1357
|
|
|
1357
1358
|
await ctx.reply(
|
|
1358
|
-
'🧠 Compact session?\nThis
|
|
1359
|
+
'🧠 Compact session?\nThis compacts Claude Code context. Your chat history stays unchanged.',
|
|
1359
1360
|
{ reply_markup: keyboard },
|
|
1360
1361
|
)
|
|
1361
1362
|
})
|
|
@@ -1514,24 +1515,37 @@ bot.command('voice', async ctx => {
|
|
|
1514
1515
|
} catch { await ctx.reply('Voice settings are unavailable. Check the agent TTS configuration.') }
|
|
1515
1516
|
})
|
|
1516
1517
|
|
|
1517
|
-
//
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
const
|
|
1526
|
-
method:
|
|
1527
|
-
|
|
1528
|
-
body: JSON.stringify({ command: 'session_info', chat_id: String(ctx.chat.id) }),
|
|
1518
|
+
// Reuse the /tasks live-message lifecycle: serialize reads/replacement, edit only
|
|
1519
|
+
// changes, persist the last message across restart, and honor Telegram backoff.
|
|
1520
|
+
const SESSION_BROWSER_FILE = join(STATE_DIR, 'session-browser.json')
|
|
1521
|
+
let restoredSessionBrowsers: TaskBrowserMessage[] = []
|
|
1522
|
+
try { const saved = JSON.parse(readFileSync(SESSION_BROWSER_FILE, 'utf8')); if (Array.isArray(saved)) restoredSessionBrowsers = saved } catch {}
|
|
1523
|
+
const liveSessionBrowser = new LiveTaskBrowser({
|
|
1524
|
+
read: async (chat) => {
|
|
1525
|
+
if (!CALLBACK_URL_BASE) throw new Error('not_configured')
|
|
1526
|
+
const response = await fetch(CALLBACK_URL_BASE + '/command', {
|
|
1527
|
+
method:'POST', headers:{'Content-Type':'application/json'}, signal:AbortSignal.timeout(10000),
|
|
1528
|
+
body:JSON.stringify({command:'session_info',chat_id:chat}),
|
|
1529
1529
|
})
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
}
|
|
1530
|
+
return parseSessionInfo(response.ok, await response.json())
|
|
1531
|
+
},
|
|
1532
|
+
render: result => ({text:(result as {sessionId:string;text:string}).text,reply_markup:{inline_keyboard:[]}}),
|
|
1533
|
+
send: async (chat,menu) => (await bot.api.sendMessage(chat,menu.text,{parse_mode:'HTML'})).message_id,
|
|
1534
|
+
edit: (chat,id,menu) => bot.api.editMessageText(chat,id,menu.text,{parse_mode:'HTML'}),
|
|
1535
|
+
remove: (chat,id) => bot.api.deleteMessage(chat,id),
|
|
1536
|
+
close: (chat,id) => bot.api.editMessageText(chat,id,'Session view closed.'),
|
|
1537
|
+
allowed: (chat,user) => chat === user && loadAccess().allowFrom.includes(user),
|
|
1538
|
+
persist: entries => {
|
|
1539
|
+
mkdirSync(STATE_DIR,{recursive:true,mode:0o700})
|
|
1540
|
+
const temp=SESSION_BROWSER_FILE+'.tmp'
|
|
1541
|
+
writeFileSync(temp,JSON.stringify(entries),{mode:0o600});renameSync(temp,SESSION_BROWSER_FILE)
|
|
1542
|
+
},
|
|
1543
|
+
},restoredSessionBrowsers)
|
|
1544
|
+
setInterval(()=>{void liveSessionBrowser.tick().catch(()=>{})},3000).unref()
|
|
1545
|
+
bot.command('session',async ctx=>{
|
|
1546
|
+
if(ctx.chat?.type!=='private'||!isCallbackAuthorized(ctx))return
|
|
1547
|
+
try{await liveSessionBrowser.open(String(ctx.chat.id),String(ctx.from!.id))}
|
|
1548
|
+
catch{await ctx.reply('Could not load session info. Please try /session again.')}
|
|
1535
1549
|
})
|
|
1536
1550
|
|
|
1537
1551
|
// /sessions — list sessions with inline keyboard for switching/deleting
|
|
@@ -1583,18 +1597,18 @@ bot.command('sessions', async ctx => {
|
|
|
1583
1597
|
}
|
|
1584
1598
|
})
|
|
1585
1599
|
|
|
1586
|
-
// /clear —
|
|
1600
|
+
// /clear — confirm a fresh CLI context without deleting history
|
|
1587
1601
|
bot.command('clear', async ctx => {
|
|
1588
1602
|
if (ctx.chat?.type !== 'private') return
|
|
1589
1603
|
const access = loadAccess()
|
|
1590
1604
|
if (!access.allowFrom.includes(String(ctx.from!.id))) return
|
|
1591
1605
|
|
|
1592
1606
|
await ctx.reply(
|
|
1593
|
-
'
|
|
1607
|
+
'Reset Claude Code context?\n\nChat history and attachments stay unchanged. Your next message starts a new context with the latest 50 messages.',
|
|
1594
1608
|
{
|
|
1595
1609
|
reply_markup: {
|
|
1596
1610
|
inline_keyboard: [[
|
|
1597
|
-
{ text: '✅
|
|
1611
|
+
{ text: '✅ Reset context', callback_data: 'session_clear_confirm' },
|
|
1598
1612
|
{ text: '❌ Cancel', callback_data: 'session_clear_cancel' },
|
|
1599
1613
|
]],
|
|
1600
1614
|
},
|
|
@@ -1880,10 +1894,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
1880
1894
|
payload: { model: newModel },
|
|
1881
1895
|
}),
|
|
1882
1896
|
})
|
|
1883
|
-
const result = (await res.json()) as { success?: boolean; error?: string; restarted?: boolean }
|
|
1897
|
+
const result = (await res.json()) as { success?: boolean; error?: string; restarted?: boolean; appliesNextTurn?: boolean }
|
|
1884
1898
|
if (result.success) {
|
|
1885
1899
|
await ctx.answerCallbackQuery({ text: `✅ Model changed to ${newModel}` }).catch(() => {})
|
|
1886
|
-
await ctx.editMessageText(`\u2705 Model changed to ${newModel}`).catch(() => {})
|
|
1900
|
+
await ctx.editMessageText(`\u2705 Model changed to ${newModel}${result.appliesNextTurn ? "\nApplies to the next response; current work continues." : ""}`).catch(() => {})
|
|
1887
1901
|
} else {
|
|
1888
1902
|
await ctx.answerCallbackQuery({ text: result.error ?? 'Failed' }).catch(() => {})
|
|
1889
1903
|
}
|
|
@@ -1920,7 +1934,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
1920
1934
|
chat_id: String(ctx.callbackQuery.message?.chat.id),
|
|
1921
1935
|
}),
|
|
1922
1936
|
})
|
|
1923
|
-
const result = (await res.json()) as { success?: boolean; error?: string; restarted?: boolean }
|
|
1937
|
+
const result = (await res.json()) as { success?: boolean; error?: string; restarted?: boolean; appliesNextTurn?: boolean }
|
|
1924
1938
|
if (result.success) {
|
|
1925
1939
|
if (result.restarted === false) {
|
|
1926
1940
|
// No active session — nothing to restart
|
|
@@ -2085,8 +2099,8 @@ bot.on('callback_query:data', async ctx => {
|
|
|
2085
2099
|
}
|
|
2086
2100
|
const chatId = String(ctx.callbackQuery.message?.chat.id)
|
|
2087
2101
|
try {
|
|
2088
|
-
await ctx.answerCallbackQuery({ text: '
|
|
2089
|
-
await ctx.editMessageText('\u23f3
|
|
2102
|
+
await ctx.answerCallbackQuery({ text: 'Resetting context...' }).catch(() => {})
|
|
2103
|
+
await ctx.editMessageText('\u23f3 Resetting context...').catch(() => {})
|
|
2090
2104
|
const res = await fetch(CALLBACK_URL_BASE + '/command', {
|
|
2091
2105
|
method: 'POST',
|
|
2092
2106
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -2094,7 +2108,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
2094
2108
|
})
|
|
2095
2109
|
const result = (await res.json()) as { success?: boolean; error?: string }
|
|
2096
2110
|
if (result.success) {
|
|
2097
|
-
await ctx.reply('\uD83D\uDCA1
|
|
2111
|
+
await ctx.reply('\uD83D\uDCA1 Claude Code context reset. Chat history is unchanged. The next message loads the latest 50 messages.').catch(() => {})
|
|
2098
2112
|
} else {
|
|
2099
2113
|
await ctx.reply(`\u274C Clear failed: ${result.error ?? 'Unknown error'}`).catch(() => {})
|
|
2100
2114
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A successful empty session is a status, not a transport failure. The empty
|
|
2
|
+
* ID binds its live message until a real session appears, when it is retired. */
|
|
3
|
+
export function parseSessionInfo(ok: boolean, value: unknown): { sessionId: string; text: string } {
|
|
4
|
+
const result = value as { success?: unknown; sessionId?: unknown; text?: unknown } | null;
|
|
5
|
+
if (!ok || result?.success !== true || typeof result.text !== 'string' ||
|
|
6
|
+
!(result.sessionId === null || (typeof result.sessionId === 'string' && result.sessionId.length > 0))) {
|
|
7
|
+
throw new Error('Session info unavailable');
|
|
8
|
+
}
|
|
9
|
+
return { sessionId: result.sessionId ?? '', text: result.text };
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xmaxma/claude-gateway",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"description": "Multi-agent gateway for Claude",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"chokidar": "^3.5.0",
|
|
53
53
|
"cron-parser": "^5.5.0",
|
|
54
54
|
"express": "^4.18.0",
|
|
55
|
+
"js-tiktoken": "^1.0.21",
|
|
55
56
|
"js-yaml": "^4.1.0",
|
|
56
57
|
"lru-cache": "^10.0.0",
|
|
57
58
|
"node-cron": "^3.0.0",
|