@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.202",
3
+ "version": "0.1.0-beta.203",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.202",
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,
@@ -12,7 +12,9 @@ export const telegramBotStation: Station = {
12
12
  'unreact',
13
13
  'edit',
14
14
  'delete',
15
+ 'typing',
15
16
  ]),
17
+ typingRefreshMs: 4_000,
16
18
  attachmentMode: 'canonical',
17
19
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
18
20
  readsProfiles: true,
@@ -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 } });
@@ -13,7 +13,9 @@ export const whatsappStation: Station = {
13
13
  'unreact',
14
14
  'edit',
15
15
  'delete',
16
+ 'typing',
16
17
  ]),
18
+ typingRefreshMs: 20_000,
17
19
  attachmentMode: 'canonical',
18
20
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
19
21
  readsProfiles: true,
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.202"
2
+ "version": "0.1.0-beta.203"
3
3
  }