@stage-labs/metro 0.1.0-beta.203 → 0.1.0-beta.205

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.203",
3
+ "version": "0.1.0-beta.205",
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.203",
3
+ "version": "0.1.0-beta.205",
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,9 @@ 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. 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.
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.
9
+
10
+ **Typing.** Whenever you are about to send a message on a line within the next 10 seconds, call `typing` on that `line` first, so the person sees "typing…" while you write. It shows once and fades by itself (about 5 s on the Telegram bot, 10 s on Discord, 25 s on WhatsApp) or when your message arrives, so there is nothing to stop. Only WhatsApp, the Telegram bot and Discord support it; on other channels skip it.
9
11
 
10
12
  **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
13
 
@@ -105,7 +105,6 @@ export interface Station {
105
105
  hasAccounts: boolean;
106
106
  hasTrain: boolean;
107
107
  messageVerbs: ReadonlySet<Verb>;
108
- typingRefreshMs?: number;
109
108
  groupOps?: ReadonlySet<GroupOp>;
110
109
  resolvesSenders?: boolean;
111
110
  profileFields?: ReadonlySet<ProfileField>;
@@ -134,6 +134,7 @@ const PRIOR_SKILL: ReadonlySet<string> = new Set([
134
134
  '0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad',
135
135
  '36f4fb57f231a119d717b5e3d6ccb654f94960fcca708683f376679aead8df25',
136
136
  '3840bc50253e51f431691376cf29a925b8245d87b77b8b20804a159e378a6e67',
137
+ 'e6b59eba825b4568dfa9ace5049fd9568a2e10d731719976ad2657084fd4e5f9',
137
138
  ]);
138
139
 
139
140
  const digest = (text: string): string => createHash('sha256').update(text).digest('hex');
@@ -10,7 +10,6 @@ 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';
14
13
 
15
14
  type Station = NonNullable<ReturnType<typeof stationForLine>>;
16
15
 
@@ -187,13 +186,9 @@ function makeVerbHandler(verb: string, spec: VerbSpec): MessageHandler {
187
186
  };
188
187
  }
189
188
 
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`);
189
+ const handleTyping = async ({ line, ctx }: MessageArgs): Promise<ToolResult> => {
190
+ await ctx.call('typing', { line });
191
+ return ok('typing shown');
197
192
  };
198
193
 
199
194
  const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
@@ -205,13 +200,6 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
205
200
  ),
206
201
  };
207
202
 
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
-
215
203
  export interface ToolHooks {
216
204
  onSent?: (messageId: string) => void;
217
205
  }
@@ -237,7 +225,7 @@ export async function dispatchMessageTool(
237
225
  `${station.name} does not support ${name}; it supports ${[...verbs].join(', ')}.`,
238
226
  );
239
227
  try {
240
- return endTypingOnSend(name, line, await handler({ line, a, ctx: makeCtx(station.name), station, onSent: hooks.onSent }));
228
+ return await handler({ line, a, ctx: makeCtx(station.name), station, onSent: hooks.onSent });
241
229
  } catch (e) {
242
230
  return toErr(name, e);
243
231
  }
@@ -89,12 +89,11 @@ export const COMMON_TOOLS: ToolDef[] = [
89
89
  name: 'typing',
90
90
  group: 'write',
91
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).',
92
+ 'Show "typing…" once in a chat while you prepare an answer. The network clears it by itself after its own time (about 5 s on the Telegram bot, 10 s on Discord, 25 s on WhatsApp) or when you send. Call it again to show it again. Args: line.',
93
93
  inputSchema: {
94
94
  type: 'object',
95
95
  properties: {
96
96
  line: lineProp,
97
- on: { type: 'boolean', description: 'false stops it. Default true.' },
98
97
  },
99
98
  required: ['line'],
100
99
  },
@@ -92,6 +92,13 @@ function listAccounts(id: string): void {
92
92
  });
93
93
  }
94
94
 
95
+ async function typing(id: string, args: Record<string, unknown>): Promise<void> {
96
+ const { line, account } = args as { line: string; account?: string };
97
+ const { accountId, channelId } = routeOf(line, account);
98
+ await rest(accountId, 'POST', `/channels/${channelId}/typing`);
99
+ respond(id, { result: { ok: true, account: accountId } });
100
+ }
101
+
95
102
  async function react(id: string, args: Record<string, unknown>): Promise<void> {
96
103
  const { line, messageId, emoji, account } = args as {
97
104
  line: string;
@@ -198,6 +205,7 @@ const HANDLERS: Record<string, StationHandler> = {
198
205
  groupRemoveMembers: groupRemoveHandler,
199
206
  send,
200
207
  react,
208
+ typing,
201
209
  edit,
202
210
  delete: remove,
203
211
  read,
@@ -13,6 +13,7 @@ export const discordBotStation: Station = {
13
13
  'edit',
14
14
  'delete',
15
15
  'read',
16
+ 'typing',
16
17
  ]),
17
18
  groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
18
19
  attachmentMode: 'canonical',
@@ -158,13 +158,11 @@ async function react(id: string, args: Record<string, unknown>): Promise<void> {
158
158
  }
159
159
 
160
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 };
161
+ const { line, account } = args as { line: string; account?: string };
162
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
- }
163
+ const body: Record<string, unknown> = { chat_id: chatId, action: 'typing' };
164
+ if (topicId !== undefined) body.message_thread_id = topicId;
165
+ await tg(accountId, 'sendChatAction', body);
168
166
  respond(id, { result: { ok: true, account: accountId } });
169
167
  }
170
168
 
@@ -14,7 +14,6 @@ export const telegramBotStation: Station = {
14
14
  'delete',
15
15
  'typing',
16
16
  ]),
17
- typingRefreshMs: 4_000,
18
17
  attachmentMode: 'canonical',
19
18
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
20
19
  readsProfiles: true,
@@ -130,7 +130,7 @@ function makeReact(clientFor: ClientFor): StationHandler {
130
130
  function makeTyping(clientFor: ClientFor): StationHandler {
131
131
  return async (id, args) => {
132
132
  const { accountId, client, jid } = resolve(args, clientFor);
133
- await guard(() => client.setTyping(jid, args.on !== false));
133
+ await guard(() => client.showTyping(jid));
134
134
  respond(id, { result: { ok: true, account: accountId } });
135
135
  };
136
136
  }
@@ -50,7 +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
+ showTyping(jid: string): Promise<void>;
54
54
  editMessage(jid: string, messageId: string, text: string): Promise<void>;
55
55
  deleteMessage(jid: string, messageId: string): Promise<void>;
56
56
  reuploadMedia(m: WAMessage): Promise<WAMessage>;
@@ -340,9 +340,9 @@ export function createClient(account: WhatsAppAccount): WAClient {
340
340
  quotedId ? quotedOpts(st, jid, quotedId) : undefined,
341
341
  );
342
342
  },
343
- async setTyping(jid, on) {
343
+ async showTyping(jid) {
344
344
  const sock = await ready(st);
345
- await sock.sendPresenceUpdate(on ? 'composing' : 'paused', jid);
345
+ await sock.sendPresenceUpdate('composing', jid);
346
346
  },
347
347
  async sendReaction(jid, messageId, emoji) {
348
348
  const target = targetKey(st.keys, jid, messageId, 'react to');
@@ -15,7 +15,6 @@ export const whatsappStation: Station = {
15
15
  'delete',
16
16
  'typing',
17
17
  ]),
18
- typingRefreshMs: 20_000,
19
18
  attachmentMode: 'canonical',
20
19
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
21
20
  readsProfiles: true,
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.203"
2
+ "version": "0.1.0-beta.205"
3
3
  }
@@ -1,46 +0,0 @@
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()];