@stage-labs/metro 0.1.0-beta.276 → 0.1.0-beta.277

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.
Files changed (78) hide show
  1. package/dist/claude.js +2 -1
  2. package/dist/native-recovery-scan.js +246 -0
  3. package/dist/native-recovery-state.js +154 -0
  4. package/dist/native-recovery.js +164 -0
  5. package/package.json +1 -1
  6. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  7. package/runtime/marketplace/plugin/bin/policy.mjs +4 -0
  8. package/runtime/node_modules/@metro-labs/core/package.json +2 -0
  9. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
  10. package/runtime/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
  11. package/runtime/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
  12. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +6 -4
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -3
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/channel-tool.ts +80 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +12 -1
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +6 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +4 -1
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +7 -0
  21. package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +2 -0
  22. package/runtime/node_modules/@metro-labs/discord-bot/src/channel-source.ts +79 -0
  23. package/runtime/node_modules/@metro-labs/discord-bot/src/channels.ts +186 -0
  24. package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +1 -0
  25. package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +2 -1
  26. package/runtime/node_modules/@metro-labs/gmail/src/channels.ts +66 -0
  27. package/runtime/node_modules/@metro-labs/gmail/src/station.ts +1 -0
  28. package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +2 -1
  29. package/runtime/node_modules/@metro-labs/outlook/src/channels.ts +60 -0
  30. package/runtime/node_modules/@metro-labs/outlook/src/station.ts +1 -0
  31. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +19 -0
  32. package/runtime/node_modules/@metro-labs/telegram/src/channels.ts +58 -0
  33. package/runtime/node_modules/@metro-labs/telegram/src/station.ts +1 -0
  34. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +2 -0
  35. package/runtime/node_modules/@metro-labs/telegram-bot/src/channel-files.ts +3 -0
  36. package/runtime/node_modules/@metro-labs/telegram-bot/src/channels.ts +43 -0
  37. package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +5 -34
  38. package/runtime/node_modules/@metro-labs/telegram-bot/src/observed-chats.ts +172 -0
  39. package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +4 -0
  40. package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +30 -3
  41. package/runtime/node_modules/@metro-labs/telegram-bot/src/updates.ts +26 -0
  42. package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +13 -1
  43. package/runtime/node_modules/@metro-labs/threema/src/actions.ts +7 -1
  44. package/runtime/node_modules/@metro-labs/threema/src/channels.ts +37 -0
  45. package/runtime/node_modules/@metro-labs/threema/src/chats.ts +88 -0
  46. package/runtime/node_modules/@metro-labs/threema/src/inbound.ts +5 -0
  47. package/runtime/node_modules/@metro-labs/threema/src/outbound.ts +1 -0
  48. package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -2
  49. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +11 -0
  50. package/runtime/node_modules/@metro-labs/whatsapp/src/channel-chats.ts +137 -0
  51. package/runtime/node_modules/@metro-labs/whatsapp/src/channels.ts +48 -0
  52. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +2 -0
  53. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +8 -6
  54. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
  55. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +3 -1
  56. package/runtime/node_modules/@metro-labs/xmtp/src/channels.ts +56 -0
  57. package/runtime/node_modules/@metro-labs/xmtp/src/network.ts +4 -0
  58. package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
  59. package/runtime/runtime.json +1 -1
  60. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +2 -0
  61. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +2 -0
  62. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-directory.ts +159 -0
  63. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/channel-pages.ts +129 -0
  64. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
  65. package/runtime/sdk-runner/runner.json +1 -1
  66. package/runtime/sdk-runner/src/activity-tasks.ts +7 -1
  67. package/runtime/sdk-runner/src/activity.ts +33 -3
  68. package/runtime/sdk-runner/src/app.ts +5 -2
  69. package/runtime/sdk-runner/src/inbox.ts +10 -1
  70. package/runtime/sdk-runner/src/input-queue.ts +6 -4
  71. package/runtime/sdk-runner/src/main.ts +21 -8
  72. package/runtime/sdk-runner/src/rules.ts +1 -0
  73. package/runtime/sdk-runner/src/runner.ts +18 -4
  74. package/runtime/sdk-runner/src/session-store.ts +19 -4
  75. package/runtime/sdk-runner/src/session-watch.ts +5 -1
  76. package/runtime/sdk-runner/src/task-ledger.ts +229 -0
  77. package/runtime/sdk-runner/src/task-recovery.ts +203 -0
  78. package/runtime/sdk-runner/src/task-state.ts +71 -0
@@ -0,0 +1,159 @@
1
+ import { Line } from '../lines.js';
2
+ import { ticketStore } from '../tickets.js';
3
+ import { TrainError } from '../train-error.js';
4
+
5
+ export const DIRECTORY_LIMIT = 5000;
6
+ const SNAPSHOT_BYTES = 2 * 1024 * 1024;
7
+ const CURSOR_TTL = 5 * 60 * 1000;
8
+ const KINDS = new Set(['direct', 'group', 'channel', 'thread']);
9
+
10
+ export interface ChannelEntry {
11
+ id: string;
12
+ line: string;
13
+ name?: string;
14
+ kind: 'direct' | 'group' | 'channel' | 'thread';
15
+ }
16
+
17
+ export interface ChannelCapability {
18
+ supported: boolean;
19
+ complete: boolean;
20
+ source: 'remote' | 'local' | 'mixed' | 'unsupported';
21
+ reason?: string;
22
+ }
23
+
24
+ export interface ChannelSnapshot {
25
+ channels: ChannelEntry[];
26
+ capability: ChannelCapability;
27
+ }
28
+
29
+ export interface ChannelList extends ChannelSnapshot {
30
+ next_cursor?: string;
31
+ }
32
+
33
+ export interface ChannelOptions {
34
+ query: string;
35
+ limit: number;
36
+ cursor?: string;
37
+ }
38
+
39
+ function cursorOf(cursor: unknown): string | undefined {
40
+ if (cursor === undefined) return undefined;
41
+ if (typeof cursor !== 'string' || !cursor || cursor.length > 2048)
42
+ throw new TrainError('bad_request', 'cursor must be a nonempty string of at most 2048 characters');
43
+ return cursor;
44
+ }
45
+
46
+ export function channelOptions(args: Record<string, unknown>): ChannelOptions {
47
+ const { query = '', limit = 50 } = args;
48
+ if (typeof query !== 'string' || query.length > 200)
49
+ throw new TrainError('bad_request', 'query must be a string of at most 200 characters');
50
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100)
51
+ throw new TrainError('bad_request', 'limit must be an integer from 1 to 100');
52
+ const cursor = cursorOf(args.cursor);
53
+ return { query: query.trim().toLowerCase(), limit, ...(cursor === undefined ? {} : { cursor }) };
54
+ }
55
+
56
+ export function channelMatches(entry: ChannelEntry, query: string): boolean {
57
+ return [entry.id, entry.line, entry.name ?? ''].some((value) => value.toLowerCase().includes(query));
58
+ }
59
+
60
+ export function channelEntry(entry: ChannelEntry, account: string): ChannelEntry | undefined {
61
+ if (!entry.id || entry.id.length > 1024 || entry.line.length > 2048 || !KINDS.has(entry.kind))
62
+ return undefined;
63
+ const parsed = Line.parse(entry.line);
64
+ if (parsed?.path[0] !== account || parsed.path.length < 2) return undefined;
65
+ return {
66
+ id: entry.id,
67
+ line: entry.line,
68
+ kind: entry.kind,
69
+ ...(entry.name ? { name: Array.from(entry.name).slice(0, 256).join('') } : {}),
70
+ };
71
+ }
72
+
73
+ function limitedCapability(capability: ChannelCapability): ChannelCapability {
74
+ return {
75
+ ...capability,
76
+ complete: false,
77
+ reason: [capability.reason, 'Directory metadata exceeded the local size or entry limit; results are partial.']
78
+ .filter(Boolean).join(' '),
79
+ };
80
+ }
81
+
82
+ function bounded(snapshot: ChannelSnapshot, account: string, query: string): ChannelSnapshot {
83
+ const seen = new Set<string>();
84
+ const channels: ChannelEntry[] = [];
85
+ let bytes = 0;
86
+ let clipped = snapshot.channels.length > DIRECTORY_LIMIT;
87
+ for (const raw of snapshot.channels.slice(0, DIRECTORY_LIMIT)) {
88
+ const entry = channelEntry(raw, account);
89
+ if (!entry) {
90
+ clipped = true;
91
+ continue;
92
+ }
93
+ if (seen.has(entry.line)) continue;
94
+ bytes += Buffer.byteLength(JSON.stringify(entry));
95
+ if (bytes > SNAPSHOT_BYTES) {
96
+ clipped = true;
97
+ break;
98
+ }
99
+ seen.add(entry.line);
100
+ if (channelMatches(raw, query)) channels.push(entry);
101
+ }
102
+ channels.sort((a, b) => a.line < b.line ? -1 : Number(a.line > b.line));
103
+ return { channels, capability: clipped ? limitedCapability(snapshot.capability) : { ...snapshot.capability } };
104
+ }
105
+
106
+ interface Cursor {
107
+ account: string;
108
+ query: string;
109
+ snapshot: ChannelSnapshot;
110
+ offset: number;
111
+ expiresAt: number;
112
+ }
113
+
114
+ export class ChannelDirectory {
115
+ private readonly cursors = ticketStore<Cursor>(CURSOR_TTL, 32);
116
+
117
+ async list(
118
+ account: string,
119
+ args: Record<string, unknown>,
120
+ load: () => Promise<ChannelSnapshot>,
121
+ ): Promise<ChannelList> {
122
+ const options = channelOptions(args);
123
+ const cursor = options.cursor === undefined
124
+ ? {
125
+ account,
126
+ query: options.query,
127
+ snapshot: bounded(await load(), account, options.query),
128
+ offset: 0,
129
+ expiresAt: Date.now() + CURSOR_TTL,
130
+ }
131
+ : this.resume(options.cursor, account, options.query);
132
+ return this.page(cursor, options.limit);
133
+ }
134
+
135
+ private resume(ticket: string, account: string, query: string): Cursor {
136
+ const cursor = this.cursors.peek(ticket);
137
+ if (!cursor || cursor.account !== account || cursor.query !== query || cursor.expiresAt <= Date.now())
138
+ throw new TrainError('channel_cursor', 'Invalid or expired channel cursor; start a new listing for this account and query.');
139
+ return cursor;
140
+ }
141
+
142
+ private page(cursor: Cursor, limit: number): ChannelList {
143
+ const { snapshot, offset } = cursor;
144
+ const channels = snapshot.channels.slice(offset, offset + limit).map((entry) => ({ ...entry }));
145
+ const nextOffset = offset + channels.length;
146
+ if (nextOffset >= snapshot.channels.length)
147
+ return { channels, capability: { ...snapshot.capability } };
148
+ const { ticket } = this.cursors.mint({ ...cursor, offset: nextOffset });
149
+ return {
150
+ channels,
151
+ capability: {
152
+ ...snapshot.capability,
153
+ complete: false,
154
+ reason: [snapshot.capability.reason, 'More channels are available; follow next_cursor.'].filter(Boolean).join(' '),
155
+ },
156
+ next_cursor: ticket,
157
+ };
158
+ }
159
+ }
@@ -0,0 +1,129 @@
1
+ import { ticketStore } from '../tickets.js';
2
+ import { TrainError } from '../train-error.js';
3
+ import { channelEntry, channelMatches, channelOptions, DIRECTORY_LIMIT, type ChannelEntry, type ChannelList } from './channel-directory.js';
4
+
5
+ const TTL = 5 * 60 * 1000;
6
+ const MAX_BYTES = 2 * 1024 * 1024;
7
+ const CAP_REASON = 'Stopped after 5000 metadata records or pages; search does not cover the whole mailbox.';
8
+ const SIZE_REASON = 'Directory metadata exceeded the local size or entry limit; search is partial.';
9
+
10
+ export interface ChannelPage {
11
+ channels: ChannelEntry[];
12
+ scanned: number;
13
+ next?: string;
14
+ }
15
+
16
+ export type ChannelPageLoader = (token: string | undefined, limit: number, seen: ReadonlySet<string>) => Promise<ChannelPage>;
17
+
18
+ interface Scan {
19
+ next?: string;
20
+ channels: ChannelEntry[];
21
+ seen: Set<string>;
22
+ visited: Set<string>;
23
+ scanned: number;
24
+ bytes: number;
25
+ complete: boolean;
26
+ stopped?: string;
27
+ }
28
+
29
+ interface Cursor {
30
+ account: string;
31
+ query: string;
32
+ scope: string;
33
+ expiresAt: number;
34
+ scan: Scan;
35
+ loaded?: Promise<Scan>;
36
+ }
37
+
38
+ function addEntries(scan: Scan, rows: ChannelEntry[], account: string, query: string): void {
39
+ for (const raw of rows) {
40
+ const entry = channelEntry(raw, account);
41
+ if (!entry) {
42
+ scan.stopped = SIZE_REASON;
43
+ continue;
44
+ }
45
+ if (scan.seen.has(entry.id)) continue;
46
+ const size = Buffer.byteLength(JSON.stringify(entry)) + Buffer.byteLength(JSON.stringify(entry.id));
47
+ if (scan.bytes + size > MAX_BYTES) {
48
+ scan.stopped = SIZE_REASON;
49
+ break;
50
+ }
51
+ scan.bytes += size;
52
+ scan.seen.add(entry.id);
53
+ if (channelMatches(raw, query)) scan.channels.push(entry);
54
+ }
55
+ }
56
+
57
+ function project(cursor: Cursor, page: ChannelPage): Scan {
58
+ const scan: Scan = {
59
+ next: page.next, channels: [], seen: new Set(cursor.scan.seen),
60
+ visited: new Set(cursor.scan.visited).add(cursor.scan.next ?? ''),
61
+ scanned: cursor.scan.scanned + page.scanned,
62
+ bytes: cursor.scan.bytes + Buffer.byteLength(page.next ?? ''), complete: !page.next,
63
+ };
64
+ if (page.next && scan.visited.has(page.next)) throw new TrainError('channel_page', 'The mailbox repeated a channel page token.');
65
+ addEntries(scan, page.channels, cursor.account, cursor.query);
66
+ if (scan.bytes > MAX_BYTES) scan.stopped = SIZE_REASON;
67
+ if (page.next && Math.max(scan.scanned, scan.visited.size) >= DIRECTORY_LIMIT) scan.stopped ??= CAP_REASON;
68
+ if (scan.stopped) {
69
+ scan.next = undefined;
70
+ scan.complete = false;
71
+ }
72
+ return scan;
73
+ }
74
+
75
+ export class ChannelPages {
76
+ private readonly cursors = ticketStore<Cursor>(TTL, 32);
77
+
78
+ async list(account: string, args: Record<string, unknown>, load: ChannelPageLoader, scope: string): Promise<ChannelList> {
79
+ const { query, limit, cursor: ticket } = channelOptions(args);
80
+ const cursor: Cursor = ticket === undefined
81
+ ? {
82
+ account, query, scope, expiresAt: Date.now() + TTL,
83
+ scan: { channels: [], seen: new Set(), visited: new Set(), scanned: 0, bytes: Buffer.byteLength(account + query + scope), complete: false },
84
+ }
85
+ : this.resume(ticket, account, query);
86
+ const scan = await this.load(cursor, load);
87
+ if (cursor.expiresAt <= Date.now()) throw new TrainError('channel_cursor', 'Channel listing expired; start a new listing.');
88
+ const channels = scan.channels.slice(0, limit);
89
+ const remaining = scan.channels.slice(channels.length);
90
+ const more = remaining.length > 0 || (!scan.complete && !scan.stopped);
91
+ const next = more ? this.cursors.mint({ ...cursor, scan: { ...scan, channels: remaining }, loaded: undefined }).ticket : undefined;
92
+ return {
93
+ channels: channels.map((entry) => ({ ...entry })),
94
+ capability: {
95
+ supported: true, complete: scan.complete && !more, source: 'remote',
96
+ reason: [cursor.scope, scan.stopped, more ? 'More metadata remains; follow next_cursor, even after an empty page.' : ''].filter(Boolean).join(' '),
97
+ },
98
+ ...(next === undefined ? {} : { next_cursor: next }),
99
+ };
100
+ }
101
+
102
+ private resume(ticket: string, account: string, query: string): Cursor {
103
+ const cursor = this.cursors.peek(ticket);
104
+ if (!cursor || cursor.account !== account || cursor.query !== query || cursor.expiresAt <= Date.now())
105
+ throw new TrainError('channel_cursor', 'Invalid or expired channel cursor; start a new listing for this account and query.');
106
+ return cursor;
107
+ }
108
+
109
+ private async load(cursor: Cursor, load: ChannelPageLoader): Promise<Scan> {
110
+ if (cursor.scan.channels.length || cursor.scan.complete || cursor.scan.stopped) return cursor.scan;
111
+ cursor.loaded ??= this.fetch(cursor, load);
112
+ try {
113
+ return await cursor.loaded;
114
+ } catch (err) {
115
+ cursor.loaded = undefined;
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ private async fetch(cursor: Cursor, load: ChannelPageLoader): Promise<Scan> {
121
+ const count = Math.min(100, DIRECTORY_LIMIT - cursor.scan.scanned);
122
+ const page = await load(cursor.scan.next, count, new Set(cursor.scan.seen));
123
+ if (!Number.isInteger(page.scanned) || page.scanned < 0 || page.scanned > count || page.channels.length > page.scanned)
124
+ throw new TrainError('channel_page', 'The mailbox returned more channel metadata than requested.');
125
+ if (page.next !== undefined && (typeof page.next !== 'string' || !page.next || page.next.length > 16384))
126
+ throw new TrainError('channel_page', 'The mailbox returned an invalid channel page token.');
127
+ return project(cursor, page);
128
+ }
129
+ }
@@ -110,6 +110,7 @@ export interface Station {
110
110
  profileFields?: ReadonlySet<ProfileField>;
111
111
  claimsName?: boolean;
112
112
  readsProfiles?: boolean;
113
+ discoversChannels?: boolean;
113
114
  readFilters?: ReadonlySet<ReadFilter>;
114
115
  approvals?: boolean;
115
116
  sendsFrames?: boolean;
@@ -148,7 +148,7 @@ function sessionCommand(body: Record<string, unknown>, session: SessionDeps): un
148
148
  }
149
149
  if (body.action === 'stop') {
150
150
  setAutostart(false, session.agents);
151
- return stopSession(session);
151
+ return stopSession(session, true);
152
152
  }
153
153
  if (body.action !== undefined) throw new ApiError('action must be start or stop', 400);
154
154
  if (typeof body.autostart === 'boolean') setAutostart(body.autostart, session.agents);
@@ -25,7 +25,7 @@ function pidAlive(pid: number): boolean {
25
25
  }
26
26
  }
27
27
 
28
- export function sdkAlive(activity: RunnerActivity | null): boolean {
28
+ export function sdkAlive(activity: RunnerActivity | null): activity is RunnerActivity {
29
29
  if (activity === null) return false;
30
30
  const found = identity(activity.pid);
31
31
  if (found?.zombie === true) return false;
@@ -40,11 +40,13 @@ function assertIdentity(activity: RunnerActivity): void {
40
40
  throw new ApiError('The Agent SDK is still running outside tmux, but its process identity cannot be verified safely. Stop that metro agent process from its terminal.', 503);
41
41
  }
42
42
 
43
- export function stopSdkRunner(home: string): void {
43
+ export function stopSdkRunner(home: string, cancelActive = false): boolean {
44
44
  const activity = readAgentActivity(home);
45
- if (activity === null || !sdkAlive(activity)) return;
45
+ if (!sdkAlive(activity)) return false;
46
46
  assertIdentity(activity);
47
- const run = spawnSync(...asAgent('kill', ['-TERM', String(activity.pid)]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
47
+ const signal = cancelActive && activity.cancelSignal === 'SIGUSR2' ? '-USR2' : '-TERM';
48
+ const run = spawnSync(...asAgent('kill', [signal, String(activity.pid)]), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
48
49
  if ((run.error !== undefined || run.status !== 0) && sdkAlive(activity))
49
50
  throw new ApiError(`The Agent SDK could not be stopped: ${run.error?.message ?? run.stderr.trim()}`, 503);
51
+ return true;
50
52
  }
@@ -265,9 +265,9 @@ export function startSession(deps: SessionDeps = {}): SessionStatus {
265
265
  return sessionStatus(deps);
266
266
  }
267
267
 
268
- export function stopSession(deps: SessionDeps = {}): SessionStatus {
269
- spawnSync(...asAgent(deps.tmux ?? 'tmux', ['kill-session', '-t', SESSION_NAME]), { stdio: 'ignore' });
270
- stopSdkRunner(sessionHome(deps));
268
+ export function stopSession(deps: SessionDeps = {}, cancelActive = false): SessionStatus {
269
+ if (!stopSdkRunner(sessionHome(deps), cancelActive))
270
+ spawnSync(...asAgent(deps.tmux ?? 'tmux', ['kill-session', '-t', SESSION_NAME]), { stdio: 'ignore' });
271
271
  return sessionStatus(deps);
272
272
  }
273
273
 
@@ -0,0 +1,80 @@
1
+ import { channelOptions, type ChannelList } from '@metro-labs/core/stations/channel-directory';
2
+ import type { ToolResult } from '@metro-labs/core/stations/types';
3
+ import { str } from '@metro-labs/core/str';
4
+ import { stationByName } from '../stations/registry.js';
5
+ import { callTargetDenied } from '../agents/scope.js';
6
+ import { errResult, makeCtx, okJson, toErr } from './ctx.js';
7
+ import { stationOfAccount } from './read-tool.js';
8
+ import type { ToolDef } from './tool-def.js';
9
+
10
+ export const LIST_CHANNELS_TOOL: ToolDef = {
11
+ name: 'list_channels',
12
+ group: 'read',
13
+ description:
14
+ 'Discover existing conversations for one account from list_accounts, returning metadata only. ' +
15
+ 'Args: account (required), query?, limit? (1 to 100, default 50), cursor?. Query matches names, ids or lines ' +
16
+ 'case-insensitively. Returns {account, station, channels:[{id, line, name?, kind:direct|group|channel|thread}], ' +
17
+ 'capability:{supported, complete, source:remote|local|mixed|unsupported, reason?}, next_cursor?}. ' +
18
+ 'Use each returned line with other Metro tools. Follow next_cursor with the same account and query. ' +
19
+ 'Check capability: local or partial directories are not exhaustive, and unsupported is not an empty account. ' +
20
+ 'No recent message is required. Receive messages Off does not disable discovery; read policies still apply.',
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ account: { type: 'string', minLength: 1, description: 'An account id from list_accounts.' },
25
+ query: { type: 'string', maxLength: 200, description: 'Case-insensitive name, id or line substring.' },
26
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 50, description: 'Maximum channels per page.' },
27
+ cursor: { type: 'string', minLength: 1, maxLength: 2048, description: 'The next_cursor from the previous page.' },
28
+ },
29
+ required: ['account'],
30
+ },
31
+ };
32
+
33
+ export function channelScopeDenied(allowed: Set<string>, a: Record<string, unknown>): boolean {
34
+ const account = str(a.account);
35
+ const station = account ? stationOfAccount(account) : undefined;
36
+ return station === undefined || stationByName(station) === undefined || callTargetDenied(allowed, station, { account });
37
+ }
38
+
39
+ export function channelArgsError(a: Record<string, unknown>): ToolResult | undefined {
40
+ if (typeof a.account !== 'string' || !a.account.trim())
41
+ return errResult('list_channels requires `account` from list_accounts');
42
+ try {
43
+ channelOptions(a);
44
+ return undefined;
45
+ } catch (e) {
46
+ return toErr('list_channels', e);
47
+ }
48
+ }
49
+
50
+ export async function dispatchListChannels(a: Record<string, unknown>): Promise<ToolResult> {
51
+ const account = str(a.account);
52
+ const name = stationOfAccount(account);
53
+ const station = name === undefined ? undefined : stationByName(name);
54
+ if (station === undefined) return errResult('list_channels requires a unique account from list_accounts');
55
+ if (!station.hasTrain || station.discoversChannels !== true) {
56
+ const list: ChannelList = {
57
+ channels: [],
58
+ capability: {
59
+ supported: false,
60
+ complete: false,
61
+ source: 'unsupported',
62
+ reason: `${station.name} does not support channel discovery`,
63
+ },
64
+ };
65
+ return okJson({ account, station: station.name, ...list });
66
+ }
67
+ try {
68
+ const { limit, cursor } = channelOptions(a);
69
+ const args = {
70
+ account,
71
+ limit,
72
+ ...(a.query === undefined ? {} : { query: str(a.query) }),
73
+ ...(cursor === undefined ? {} : { cursor }),
74
+ };
75
+ const { result } = await makeCtx(station.name).call('listChannels', args);
76
+ return okJson({ ...result as ChannelList, account, station: station.name });
77
+ } catch (e) {
78
+ return toErr('list_channels', e);
79
+ }
80
+ }
@@ -1,5 +1,8 @@
1
+ import { str } from '@metro-labs/core/str';
1
2
  import { readPreview, shownValue } from '../approvals/preview.js';
2
3
  import { connectorToolOf } from '../connectors/gates.js';
4
+ import { stationByName } from '../stations/registry.js';
5
+ import { stationOfAccount } from './read-tool.js';
3
6
 
4
7
  export interface PermissionParams {
5
8
  request_id: string;
@@ -37,6 +40,14 @@ function channelOf(input: Record<string, unknown>): string | undefined {
37
40
  return typeof input.station === 'string' && input.station !== '' ? input.station : undefined;
38
41
  }
39
42
 
43
+ function discoveryChannel(input: Record<string, unknown>): string {
44
+ const account = str(input.account);
45
+ if (!account) return 'unknown account';
46
+ const name = stationOfAccount(account);
47
+ const station = name === undefined ? undefined : stationByName(name);
48
+ return `${station?.name ?? 'unknown station'} · ${shorten(account)}`;
49
+ }
50
+
40
51
  function argText(value: unknown): string | undefined {
41
52
  const plain = valueText(value);
42
53
  if (plain !== undefined || value === null || typeof value !== 'object') return plain;
@@ -50,7 +61,7 @@ function fieldText(key: string, value: unknown): string | undefined {
50
61
 
51
62
  function metroLines(tool: string, input: Record<string, unknown>): string[] {
52
63
  const lines = [`Approval needed: ${tool}`];
53
- const channel = channelOf(input);
64
+ const channel = tool === 'list_channels' ? discoveryChannel(input) : channelOf(input);
54
65
  if (channel !== undefined) lines.push(`Channel: ${channel}`);
55
66
  if (tool === 'send' && input.speech !== undefined) lines.push('Delivery: voice call only, no chat post');
56
67
  const keys = [...LEAD, ...Object.keys(input).filter((k) => !LEAD.includes(k))];
@@ -25,8 +25,14 @@ function channelTargets(name: string, a: Record<string, unknown>): ChannelTarget
25
25
  return line === undefined ? [] : fromLine(line, undefined);
26
26
  }
27
27
  const account = str(a.account);
28
+ if (name === 'list_channels') return accountTargets(name, { account });
28
29
  const line = str(a.line);
29
30
  if (line) return fromLine(line, name === 'list_members' ? undefined : account);
31
+ return accountTargets(name, a);
32
+ }
33
+
34
+ function accountTargets(name: string, a: Record<string, unknown>): ChannelTarget[] {
35
+ const account = str(a.account);
30
36
  const station = stationForTool(name, a) ?? (account ? stationOfAccount(account) : undefined);
31
37
  if (station === undefined) return [];
32
38
  if (account) return [{ kind: 'channel', station, account }];
@@ -3,6 +3,7 @@ import { STATIONS } from '../stations/registry.js';
3
3
  import { COMMON_TOOLS, LIST_ACCOUNTS_TOOL } from './tool-schemas.js';
4
4
  import { SET_PROFILE_TOOL } from './profile-tool.js';
5
5
  import { GET_PROFILE_TOOL } from './profile-lookup.js';
6
+ import { LIST_CHANNELS_TOOL } from './channel-tool.js';
6
7
  import type { ToolDef } from './tool-def.js';
7
8
 
8
9
  const defOf = (tool: StationTool): ToolDef => ({
@@ -17,6 +18,7 @@ export const TOOL_DEFS: ToolDef[] = [
17
18
  ...COMMON_TOOLS,
18
19
  ...STATIONS.flatMap((s) => s.tools.map(defOf)),
19
20
  LIST_ACCOUNTS_TOOL,
21
+ LIST_CHANNELS_TOOL,
20
22
  SET_PROFILE_TOOL,
21
23
  GET_PROFILE_TOOL,
22
24
  ];
@@ -85,7 +87,8 @@ export const stationToolGroups = (): Record<string, GroupedTool[]> =>
85
87
  export function channelToolsOf(station: Station): GroupedTool[] {
86
88
  const names: string[] = [...station.messageVerbs];
87
89
  if (station.hasTrain && station.hasAccounts) names.push('list_members');
88
- for (const op of station.groupOps ?? []) names.push(GROUP_OP_TOOLS[op]);
90
+ if (station.hasTrain && station.discoversChannels === true) names.push('list_channels');
91
+ names.push(...Array.from(station.groupOps ?? [], (op) => GROUP_OP_TOOLS[op]));
89
92
  if ((station.profileFields?.size ?? 0) > 0) names.push('set_profile');
90
93
  if (station.readsProfiles === true) names.push('get_profile');
91
94
  names.push(...station.tools.map((t) => t.name));
@@ -37,6 +37,7 @@ import {
37
37
  } from './request-identity.js';
38
38
  import { str } from '@metro-labs/core/str';
39
39
  import { linelessRead, stationOfAccount } from './read-tool.js';
40
+ import { channelArgsError, channelScopeDenied, dispatchListChannels } from './channel-tool.js';
40
41
 
41
42
  const STATION_TOOLS = new Map<
42
43
  string,
@@ -50,6 +51,7 @@ const CORE_DISPATCH: Record<
50
51
  (a: Record<string, unknown>) => Promise<ToolResult>
51
52
  > = {
52
53
  list_members: dispatchListMembers,
54
+ list_channels: dispatchListChannels,
53
55
  create_group: dispatchCreateGroup,
54
56
  add_members: dispatchAddMembers,
55
57
  remove_members: dispatchRemoveMembers,
@@ -91,6 +93,7 @@ export function scopeDenied(
91
93
  args: Record<string, unknown>,
92
94
  ): boolean {
93
95
  const allowed = allowedAgents(identity);
96
+ if (name === 'list_channels') return channelScopeDenied(allowed, args);
94
97
  if (name === 'read' && !str(args.line)) {
95
98
  const account = str(args.account);
96
99
  if (!account) return false;
@@ -147,6 +150,10 @@ async function runTool(
147
150
  ): Promise<ToolResult> {
148
151
  const name = req.params.name;
149
152
  const a = req.params.arguments ?? {};
153
+ if (name === 'list_channels') {
154
+ const invalid = channelArgsError(a);
155
+ if (invalid) return invalid;
156
+ }
150
157
 
151
158
  const identity = currentIdentity();
152
159
  if (name !== 'list_accounts' && scopeDenied(identity, name, a))
@@ -15,6 +15,7 @@ import {
15
15
  import { makeStation, respond, type CallMsg, type StationHandler } from '@metro-labs/core/stations/station-runtime';
16
16
  import { readProfile, setProfile } from './profile.js';
17
17
  import { discordMembers } from './members.js';
18
+ import { listChannels } from './channels.js';
18
19
  import {
19
20
  groupAddHandler,
20
21
  groupCreateHandler,
@@ -210,6 +211,7 @@ const HANDLERS: Record<string, StationHandler> = {
210
211
  delete: remove,
211
212
  read,
212
213
  listMembers,
214
+ listChannels,
213
215
  set_profile: setProfile,
214
216
  profile: readProfile,
215
217
  };
@@ -0,0 +1,79 @@
1
+ import {
2
+ ChannelType,
3
+ type Client,
4
+ type Guild,
5
+ type GuildBasedChannel,
6
+ type GuildMember,
7
+ type PermissionsBitField,
8
+ } from 'discord.js';
9
+
10
+ export interface DiscordChannelMetadata {
11
+ id: string;
12
+ name?: string;
13
+ type: ChannelType;
14
+ permissions?: Readonly<PermissionsBitField> | null;
15
+ joined?: boolean;
16
+ archived?: boolean | null;
17
+ }
18
+
19
+ export interface DiscordGuildSource {
20
+ channels(): Promise<Iterable<DiscordChannelMetadata>>;
21
+ threads(): Promise<Iterable<DiscordChannelMetadata>>;
22
+ }
23
+
24
+ export interface DiscordChannelSource {
25
+ guilds(after: string | undefined, limit: number): Promise<string[]>;
26
+ guild(id: string): Promise<DiscordGuildSource>;
27
+ directs(): Iterable<DiscordChannelMetadata>;
28
+ }
29
+
30
+ function* guildMetadata(
31
+ channels: Iterable<GuildBasedChannel | null>,
32
+ me: GuildMember,
33
+ ): Iterable<DiscordChannelMetadata> {
34
+ for (const channel of channels) {
35
+ if (!channel) continue;
36
+ yield {
37
+ id: channel.id,
38
+ name: channel.name,
39
+ type: channel.type,
40
+ permissions: channel.permissionsFor(me),
41
+ ...(channel.isThread() ? { joined: channel.joined, archived: channel.archived } : {}),
42
+ };
43
+ }
44
+ }
45
+
46
+ function guildSource(guild: Guild, me: GuildMember): DiscordGuildSource {
47
+ return {
48
+ async channels() {
49
+ return guildMetadata((await guild.channels.fetch()).values(), me);
50
+ },
51
+ async threads() {
52
+ const result = await guild.channels.fetchActiveThreads(false);
53
+ return guildMetadata(result.threads.values(), me);
54
+ },
55
+ };
56
+ }
57
+
58
+ export function discordChannelSource(client: Client): DiscordChannelSource {
59
+ return {
60
+ async guilds(after, limit) {
61
+ return [...(await client.guilds.fetch({ after, limit })).keys()];
62
+ },
63
+ async guild(id) {
64
+ const guild = await client.guilds.fetch({ guild: id, force: true, withCounts: false });
65
+ const me = await guild.members.fetchMe({ force: true });
66
+ return guildSource(guild, me);
67
+ },
68
+ *directs() {
69
+ for (const channel of client.channels.cache.values()) {
70
+ if (channel.type !== ChannelType.DM && channel.type !== ChannelType.GroupDM) continue;
71
+ yield {
72
+ id: channel.id,
73
+ type: channel.type,
74
+ ...('name' in channel && channel.name ? { name: channel.name } : {}),
75
+ };
76
+ }
77
+ },
78
+ };
79
+ }