@stage-labs/metro 0.1.0-beta.202 β 0.1.0-beta.203
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/orchestrator.md +1 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +19 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +14 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/typing.ts +46 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +12 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +2 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +9 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +5 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +2 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.203",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -5,7 +5,7 @@ description: How this box's agent works on metro. Acknowledge, delegate, relay,
|
|
|
5
5
|
|
|
6
6
|
You are the main thread of a Claude Code session that is connected to chat through metro, on a machine nobody watches. The main thread is an orchestrator: it may only delegate and talk. A guard in the metro plugin denies every other tool on this thread, so this is the shape of the work, not a preference.
|
|
7
7
|
|
|
8
|
-
**Inbound.** A message arrives as a `<channel source="metro" β¦>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, the first tool call is `react` on it (π, then π or β
once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim.
|
|
8
|
+
**Inbound.** A message arrives as a `<channel source="metro" β¦>` block. Read `addressed` first: `direct`, `mention` or `reply` means it is for you; without it the message merely happened in a room you watch, so treat it as context and stay silent unless the person plainly wants you. For a message that is for you, the first tool call is `react` on it (π, then π or β
once done) so the person knows you saw it, and any reply goes on the same `line`, passed verbatim. When the answer will take more than a few seconds, call `typing` on that `line` right after the π: metro keeps "typingβ¦" showing until you send or reply there (or 2 minutes pass). Only WhatsApp and the Telegram bot support it; elsewhere skip it.
|
|
9
9
|
|
|
10
10
|
**Delegate everything that is work.** Reading files, running commands, searching, fetching pages, editing, and every connector tool: hand it to a subagent with the `Agent` tool, `subagent_type: "worker"`, always `run_in_background: true`, then keep answering chat while it runs and relay the result when the task notification arrives. A foreground subagent blinds you to chat and the guard refuses it. You may read image files yourself, so a screenshot someone sends is yours to look at.
|
|
11
11
|
|
|
@@ -7,7 +7,8 @@ export type Verb =
|
|
|
7
7
|
| 'unreact'
|
|
8
8
|
| 'edit'
|
|
9
9
|
| 'delete'
|
|
10
|
-
| 'read'
|
|
10
|
+
| 'read'
|
|
11
|
+
| 'typing';
|
|
11
12
|
|
|
12
13
|
export interface ToolResult {
|
|
13
14
|
content: { type: 'text'; text: string }[];
|
|
@@ -104,6 +105,7 @@ export interface Station {
|
|
|
104
105
|
hasAccounts: boolean;
|
|
105
106
|
hasTrain: boolean;
|
|
106
107
|
messageVerbs: ReadonlySet<Verb>;
|
|
108
|
+
typingRefreshMs?: number;
|
|
107
109
|
groupOps?: ReadonlySet<GroupOp>;
|
|
108
110
|
resolvesSenders?: boolean;
|
|
109
111
|
profileFields?: ReadonlySet<ProfileField>;
|
|
@@ -133,6 +133,7 @@ const PRIOR_WORKER: ReadonlySet<string> = new Set(['08a0cd8710df285d6512245246bb
|
|
|
133
133
|
const PRIOR_SKILL: ReadonlySet<string> = new Set([
|
|
134
134
|
'0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad',
|
|
135
135
|
'36f4fb57f231a119d717b5e3d6ccb654f94960fcca708683f376679aead8df25',
|
|
136
|
+
'3840bc50253e51f431691376cf29a925b8245d87b77b8b20804a159e378a6e67',
|
|
136
137
|
]);
|
|
137
138
|
|
|
138
139
|
const digest = (text: string): string => createHash('sha256').update(text).digest('hex');
|
|
@@ -10,6 +10,7 @@ import { errResult, makeCtx, ok, toErr } from './ctx.js';
|
|
|
10
10
|
import { runRead } from './read-tool.js';
|
|
11
11
|
import { allowedAgents, currentIdentity } from './request-identity.js';
|
|
12
12
|
import { str } from '@metro-labs/core/str';
|
|
13
|
+
import { startTyping, stopTyping, TYPING_MAX_MS } from './typing.js';
|
|
13
14
|
|
|
14
15
|
type Station = NonNullable<ReturnType<typeof stationForLine>>;
|
|
15
16
|
|
|
@@ -186,14 +187,31 @@ function makeVerbHandler(verb: string, spec: VerbSpec): MessageHandler {
|
|
|
186
187
|
};
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
const handleTyping = async ({ line, a, ctx, station }: MessageArgs): Promise<ToolResult> => {
|
|
191
|
+
if (a.on === false) {
|
|
192
|
+
stopTyping(line);
|
|
193
|
+
return ok('typing stopped');
|
|
194
|
+
}
|
|
195
|
+
await startTyping(line, (on) => ctx.call('typing', { line, on }), station.typingRefreshMs);
|
|
196
|
+
return ok(`typing shown; it stops when you send or reply here, or after ${String(TYPING_MAX_MS / 60_000)} minutes`);
|
|
197
|
+
};
|
|
198
|
+
|
|
189
199
|
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
|
190
200
|
send: handleSend,
|
|
191
201
|
read: handleRead,
|
|
202
|
+
typing: handleTyping,
|
|
192
203
|
...Object.fromEntries(
|
|
193
204
|
Object.entries(MESSAGE_VERBS).map(([verb, spec]) => [verb, makeVerbHandler(verb, spec)]),
|
|
194
205
|
),
|
|
195
206
|
};
|
|
196
207
|
|
|
208
|
+
const SENDS = new Set(['send', 'reply']);
|
|
209
|
+
|
|
210
|
+
function endTypingOnSend(name: string, line: string, result: ToolResult): ToolResult {
|
|
211
|
+
if (SENDS.has(name) && result.isError !== true) stopTyping(line);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
197
215
|
export interface ToolHooks {
|
|
198
216
|
onSent?: (messageId: string) => void;
|
|
199
217
|
}
|
|
@@ -219,7 +237,7 @@ export async function dispatchMessageTool(
|
|
|
219
237
|
`${station.name} does not support ${name}; it supports ${[...verbs].join(', ')}.`,
|
|
220
238
|
);
|
|
221
239
|
try {
|
|
222
|
-
return await handler({ line, a, ctx: makeCtx(station.name), station, onSent: hooks.onSent });
|
|
240
|
+
return endTypingOnSend(name, line, await handler({ line, a, ctx: makeCtx(station.name), station, onSent: hooks.onSent }));
|
|
223
241
|
} catch (e) {
|
|
224
242
|
return toErr(name, e);
|
|
225
243
|
}
|
|
@@ -85,6 +85,20 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
85
85
|
required: ['line', 'message_id', 'emoji'],
|
|
86
86
|
},
|
|
87
87
|
},
|
|
88
|
+
{
|
|
89
|
+
name: 'typing',
|
|
90
|
+
group: 'write',
|
|
91
|
+
description:
|
|
92
|
+
'Show "typingβ¦" in a chat while you prepare an answer that takes more than a few seconds. Call it once: metro keeps it alive and stops it when you send or reply on that line, when you call it with on: false, or after 2 minutes. Args: line, on (default true).',
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
line: lineProp,
|
|
97
|
+
on: { type: 'boolean', description: 'false stops it. Default true.' },
|
|
98
|
+
},
|
|
99
|
+
required: ['line'],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
88
102
|
{
|
|
89
103
|
name: 'unreact',
|
|
90
104
|
group: 'write',
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
|
|
3
|
+
export const TYPING_MAX_MS = 120_000;
|
|
4
|
+
const DEFAULT_REFRESH_MS = 5_000;
|
|
5
|
+
|
|
6
|
+
export type TypingCall = (on: boolean) => Promise<unknown>;
|
|
7
|
+
|
|
8
|
+
interface Held {
|
|
9
|
+
timer: ReturnType<typeof setInterval>;
|
|
10
|
+
call: TypingCall;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const held = new Map<string, Held>();
|
|
14
|
+
|
|
15
|
+
const quietly = (line: string, run: Promise<unknown>): void => {
|
|
16
|
+
run.catch((err: unknown) => {
|
|
17
|
+
log.debug({ line, err: errMsg(err) }, 'typing: a refresh failed');
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function stopTyping(line: string): void {
|
|
22
|
+
const current = held.get(line);
|
|
23
|
+
if (current === undefined) return;
|
|
24
|
+
clearInterval(current.timer);
|
|
25
|
+
held.delete(line);
|
|
26
|
+
quietly(line, current.call(false));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function startTyping(line: string, call: TypingCall, refreshMs = DEFAULT_REFRESH_MS, now = Date.now): Promise<void> {
|
|
30
|
+
const previous = held.get(line);
|
|
31
|
+
if (previous !== undefined) clearInterval(previous.timer);
|
|
32
|
+
held.delete(line);
|
|
33
|
+
await call(true);
|
|
34
|
+
const stopAt = now() + TYPING_MAX_MS;
|
|
35
|
+
const timer = setInterval(() => {
|
|
36
|
+
if (now() >= stopAt) {
|
|
37
|
+
stopTyping(line);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
quietly(line, call(true));
|
|
41
|
+
}, refreshMs);
|
|
42
|
+
timer.unref();
|
|
43
|
+
held.set(line, { timer, call });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const typingLines = (): string[] => [...held.keys()];
|
|
@@ -157,6 +157,17 @@ async function react(id: string, args: Record<string, unknown>): Promise<void> {
|
|
|
157
157
|
respond(id, { result: { ok: true, account: accountId } });
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
async function typing(id: string, args: Record<string, unknown>): Promise<void> {
|
|
161
|
+
const { line, on, account } = args as { line: string; on?: boolean; account?: string };
|
|
162
|
+
const { accountId, chatId, topicId } = targetOf(line, account);
|
|
163
|
+
if (on !== false) {
|
|
164
|
+
const body: Record<string, unknown> = { chat_id: chatId, action: 'typing' };
|
|
165
|
+
if (topicId !== undefined) body.message_thread_id = topicId;
|
|
166
|
+
await tg(accountId, 'sendChatAction', body);
|
|
167
|
+
}
|
|
168
|
+
respond(id, { result: { ok: true, account: accountId } });
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
async function edit(id: string, args: Record<string, unknown>): Promise<void> {
|
|
161
172
|
const { line, messageId, text, account } = args as {
|
|
162
173
|
line: string;
|
|
@@ -217,6 +228,7 @@ const HANDLERS: Record<string, StationHandler> = {
|
|
|
217
228
|
accounts: (id) => listAccounts(id),
|
|
218
229
|
send,
|
|
219
230
|
react,
|
|
231
|
+
typing,
|
|
220
232
|
edit,
|
|
221
233
|
delete: remove,
|
|
222
234
|
listMembers,
|
|
@@ -127,6 +127,14 @@ function makeReact(clientFor: ClientFor): StationHandler {
|
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
function makeTyping(clientFor: ClientFor): StationHandler {
|
|
131
|
+
return async (id, args) => {
|
|
132
|
+
const { accountId, client, jid } = resolve(args, clientFor);
|
|
133
|
+
await guard(() => client.setTyping(jid, args.on !== false));
|
|
134
|
+
respond(id, { result: { ok: true, account: accountId } });
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
130
138
|
function makeEdit(clientFor: ClientFor): StationHandler {
|
|
131
139
|
return async (id, args) => {
|
|
132
140
|
const { accountId, client, jid } = resolve(args, clientFor);
|
|
@@ -201,6 +209,7 @@ export function makeHandleCall(
|
|
|
201
209
|
accounts: makeAccounts(clientFor),
|
|
202
210
|
send: makeSend(clientFor),
|
|
203
211
|
react: makeReact(clientFor),
|
|
212
|
+
typing: makeTyping(clientFor),
|
|
204
213
|
edit: makeEdit(clientFor),
|
|
205
214
|
delete: makeDelete(clientFor),
|
|
206
215
|
resolve_sender: makeResolveSender(clientFor),
|
|
@@ -50,6 +50,7 @@ export interface WAClient {
|
|
|
50
50
|
sendText(jid: string, text: string, quotedId?: string): Promise<string>;
|
|
51
51
|
sendMedia(jid: string, media: WAMedia, quotedId?: string): Promise<string>;
|
|
52
52
|
sendReaction(jid: string, messageId: string, emoji: string): Promise<void>;
|
|
53
|
+
setTyping(jid: string, on: boolean): Promise<void>;
|
|
53
54
|
editMessage(jid: string, messageId: string, text: string): Promise<void>;
|
|
54
55
|
deleteMessage(jid: string, messageId: string): Promise<void>;
|
|
55
56
|
reuploadMedia(m: WAMessage): Promise<WAMessage>;
|
|
@@ -339,6 +340,10 @@ export function createClient(account: WhatsAppAccount): WAClient {
|
|
|
339
340
|
quotedId ? quotedOpts(st, jid, quotedId) : undefined,
|
|
340
341
|
);
|
|
341
342
|
},
|
|
343
|
+
async setTyping(jid, on) {
|
|
344
|
+
const sock = await ready(st);
|
|
345
|
+
await sock.sendPresenceUpdate(on ? 'composing' : 'paused', jid);
|
|
346
|
+
},
|
|
342
347
|
async sendReaction(jid, messageId, emoji) {
|
|
343
348
|
const target = targetKey(st.keys, jid, messageId, 'react to');
|
|
344
349
|
await send(st, jid, { react: { text: emoji, key: target } });
|
package/runtime/runtime.json
CHANGED