@stage-labs/metro 0.1.0-beta.273 → 0.1.0-beta.275

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.273",
3
+ "version": "0.1.0-beta.275",
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.273",
3
+ "version": "0.1.0-beta.275",
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",
@@ -1,5 +1,5 @@
1
1
  import { IdentifierKind, type DecodedMessage } from '@xmtp/node-sdk';
2
- import { accountForCall, convOf, lineOf, parseLine } from './accounts.js';
2
+ import { accountForCall, convOf, lineOf, type Account } from './accounts.js';
3
3
  import { respond } from '@metro-labs/core/stations/station-runtime';
4
4
  import { resolveMsgId } from './wire.js';
5
5
  import { TrainError } from '@metro-labs/core/train-error';
@@ -17,6 +17,7 @@ import { updateChannelMeta } from './actions-meta.js';
17
17
  import { deletedByRequests, isDeleteRequest, superAdminCheck } from './delete-requests.js';
18
18
  import { isCallSignal } from './codecs.js';
19
19
  import { syncConversation } from './network.js';
20
+ import { readAttachments } from './read-attachments.js';
20
21
 
21
22
  type Args = Record<string, unknown>;
22
23
  type Handler = (id: string, args: Args) => Promise<void>;
@@ -56,27 +57,37 @@ function textOf(m: DecodedMessage): string {
56
57
 
57
58
  const DELETED = { text: '[deletedMessage]', contentType: 'deletedMessage' };
58
59
 
60
+ function messageOf(acct: Account, m: DecodedMessage, deleted: Set<string>): Record<string, unknown> {
61
+ return {
62
+ id: m.id,
63
+ ts: new Date(Number(m.sentAtNs / 1_000_000n)).toISOString(),
64
+ from: `metro://xmtp/${acct.cfg.id}/user/${m.senderInboxId}`,
65
+ ...(m.senderInboxId === acct.inboxId ? { self: true } : {}),
66
+ ...(deleted.has(m.id) ? DELETED : { text: textOf(m), contentType: m.contentType?.typeId ?? 'unknown' }),
67
+ };
68
+ }
69
+
59
70
  async function read(id: string, args: Args): Promise<void> {
60
- const { line, limit, before } = args as { line: string; limit?: number; before?: string };
71
+ const { line, limit, before, messageId } = args as { line: string; limit?: number; before?: string; messageId?: string };
61
72
  const { acct, conv } = await convOf(line);
62
73
  if (!conv)
63
74
  throw new TrainError('NOT_FOUND', `conversation not found for ${line}`);
64
- const lim = Math.min(Math.max(1, limit ?? 20), 200);
65
75
  await syncConversation(acct.client, conv);
66
76
  const all = await conv.messages();
77
+ if (messageId) {
78
+ const target = resolveMsgId(messageId);
79
+ const message = acct.client.conversations.getMessageById(target);
80
+ if (message?.conversationId !== conv.id || isDeleteRequest(message) || isCallSignal(message))
81
+ throw new TrainError('NOT_FOUND', `message ${messageId} is not in this conversation`);
82
+ const deleted = deletedByRequests([...all, message], superAdminCheck(conv));
83
+ const attachments = deleted.has(message.id) ? [] : await readAttachments(message);
84
+ respond(id, { result: { line, account: acct.cfg.id, message: { ...messageOf(acct, message, deleted), attachments } } });
85
+ return;
86
+ }
87
+ const lim = Math.min(Math.max(1, limit ?? 20), 200);
67
88
  const deleted = deletedByRequests(all, superAdminCheck(conv));
68
89
  const slice = upTo(all.filter((m) => !isDeleteRequest(m) && !isCallSignal(m)), before).slice(-lim);
69
- const parsed = parseLine(line);
70
- if (!parsed)
71
- throw new TrainError('NOT_FOUND', `could not parse line ${line}`);
72
- const acctId = parsed.accountId;
73
- const messages = slice.map((m) => ({
74
- id: m.id,
75
- ts: new Date(Number(m.sentAtNs / 1_000_000n)).toISOString(),
76
- from: `metro://xmtp/${acctId}/user/${m.senderInboxId}`,
77
- ...(m.senderInboxId === acct.inboxId ? { self: true } : {}),
78
- ...(deleted.has(m.id) ? DELETED : { text: textOf(m), contentType: m.contentType?.typeId ?? 'unknown' }),
79
- }));
90
+ const messages = slice.map((m) => messageOf(acct, m, deleted));
80
91
  respond(id, { result: { line, count: messages.length, messages } });
81
92
  }
82
93
 
@@ -0,0 +1,37 @@
1
+ import type { DecodedMessage } from '@xmtp/node-sdk';
2
+ import { kindOf } from '@metro-labs/core/stations/attachments';
3
+ import { saveInlineAttachment, saveRemoteAttachment, type RemoteEntry, type SavedAttachment } from './attachments.js';
4
+
5
+ interface ReadAttachment {
6
+ name?: string;
7
+ mime?: string;
8
+ kind: string;
9
+ size: number;
10
+ local_path: string;
11
+ }
12
+
13
+ function fileOf(saved: SavedAttachment): ReadAttachment {
14
+ return {
15
+ name: saved.name,
16
+ mime: saved.mime,
17
+ kind: kindOf(saved.mime ?? '', saved.path),
18
+ size: saved.bytes,
19
+ local_path: saved.path,
20
+ };
21
+ }
22
+
23
+ export async function readAttachments(message: DecodedMessage): Promise<ReadAttachment[]> {
24
+ const type = message.contentType?.typeId;
25
+ if (type === 'attachment') {
26
+ const content = message.content as { filename?: string; mimeType?: string; content: Uint8Array };
27
+ return [fileOf(await saveInlineAttachment(content, message.id))];
28
+ }
29
+ if (type === 'remoteStaticAttachment')
30
+ return [fileOf(await saveRemoteAttachment(message.content as RemoteEntry, message.id))];
31
+ if (type === 'multiRemoteStaticAttachment' || type === 'multiRemoteAttachment') {
32
+ const content = message.content as { attachments: RemoteEntry[] };
33
+ return Promise.all(content.attachments.map(async (entry, index) =>
34
+ fileOf(await saveRemoteAttachment(entry, message.id, index))));
35
+ }
36
+ return [];
37
+ }
@@ -7,6 +7,7 @@ export const xmtpStation: Station = {
7
7
  hasAccounts: true,
8
8
  hasTrain: true,
9
9
  messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact', 'delete', 'read']),
10
+ readFilters: new Set(['message_id']),
10
11
  groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
11
12
  attachmentMode: 'native',
12
13
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.273"
2
+ "version": "0.1.0-beta.275"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.273",
2
+ "version": "0.1.0-beta.275",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -2,6 +2,7 @@ import { query, type CanUseTool, type Options, type Query, type SDKMessage, type
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
3
  import { isRecord } from '@metro-labs/core/is-record';
4
4
  import type { Activity } from './activity.js';
5
+ import { ActivityTasks } from './activity-tasks.js';
5
6
  import { RunnerCalls } from './calls.js';
6
7
  import { channelText, type ChannelEvent } from './channel-text.js';
7
8
  import type { RunnerConfig } from './config.js';
@@ -14,6 +15,7 @@ import { METRO_SERVER, type MetroTools } from './tool-proxy.js';
14
15
 
15
16
  const STARTUP_WAIT = 'CLAUDE_CODE_MCP_STARTUP_WAIT_MS';
16
17
  export const COMPACT_AT = 120_000;
18
+ export const COMPACT_IDLE_MS = 60_000;
17
19
 
18
20
  const MODEL_PINS: ReadonlySet<string> = new Set(['ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']);
19
21
 
@@ -85,6 +87,8 @@ export class Runner {
85
87
  readonly inbox: Inbox;
86
88
  readonly calls: RunnerCalls;
87
89
  private readonly watch: SessionWatch;
90
+ private readonly tasks = new ActivityTasks(() => undefined);
91
+ private compactTimer: ReturnType<typeof setTimeout> | null = null;
88
92
  private session: Query | null = null;
89
93
  private sessionId: string | null = null;
90
94
  private compactAsked: Uuid | null = null;
@@ -101,7 +105,7 @@ export class Runner {
101
105
  this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
102
106
  ready: () => this.watch.admitting,
103
107
  dispatch: (message) => { this.watch.dispatched(message.uuid); },
104
- input: (input) => { parts.activity?.input(input); },
108
+ input: (input) => { this.cancelCompact(); parts.activity?.input(input); },
105
109
  queue: (count, oldest) => { parts.activity?.pending(count, oldest); },
106
110
  });
107
111
  this.calls = new RunnerCalls(this.inbox, parts.activity);
@@ -133,15 +137,19 @@ export class Runner {
133
137
  if (this.session === null) throw new Error('the runner was not started');
134
138
  try {
135
139
  for await (const message of this.session) {
140
+ this.cancelCompact();
141
+ if (message.type === 'system') this.tasks.observe(message);
136
142
  const m: Record<string, unknown> = { ...message };
137
143
  this.trackInput(m);
138
144
  this.watch.observe(m);
139
145
  this.note(m);
140
146
  this.parts.activity?.observe(message);
141
147
  this.inbox.notify();
148
+ this.maybeCompact();
142
149
  observe?.(message);
143
150
  }
144
151
  } finally {
152
+ this.cancelCompact();
145
153
  this.ended = true;
146
154
  }
147
155
  }
@@ -163,10 +171,12 @@ export class Runner {
163
171
  }
164
172
 
165
173
  close(cancelActive = true): void {
174
+ this.cancelCompact();
166
175
  this.calls.close();
167
176
  try {
168
177
  if (cancelActive && !this.ended) this.inbox.finished();
169
178
  } finally {
179
+ this.ended = true;
170
180
  this.inbox.close();
171
181
  this.session?.close();
172
182
  }
@@ -218,7 +228,6 @@ export class Runner {
218
228
  if (m.type === 'result') this.turnDone(m);
219
229
  if (m.type !== 'system') return;
220
230
  if (m.subtype === 'init') this.started(m);
221
- else if (m.subtype === 'session_state_changed' && m.state === 'idle') this.maybeCompact();
222
231
  else if (m.subtype === 'compact_boundary') {
223
232
  this.compacted = true;
224
233
  this.compactFloor = null;
@@ -239,16 +248,33 @@ export class Runner {
239
248
  this.compactFloor = context;
240
249
  this.compacted = false;
241
250
  }
242
- this.maybeCompact();
251
+ }
252
+
253
+ private compactReady(resuming = false): boolean {
254
+ const busy = this.ended || this.compactAsked !== null || !this.watch.safe || !this.watch.admitting || (!resuming && (this.calls.live || this.inbox.pending > 0 || this.tasks.running > 0));
255
+ return compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy });
243
256
  }
244
257
 
245
258
  private maybeCompact(resuming = false): void {
246
- const busy = this.compactAsked !== null || !this.watch.safe || !this.watch.admitting || (!resuming && (this.calls.live || this.inbox.pending > 0));
247
- if (!compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy })) return;
259
+ if (!this.compactReady(resuming)) return;
260
+ if (resuming) { this.askCompact(true); return; }
261
+ this.compactTimer = setTimeout(() => {
262
+ this.compactTimer = null;
263
+ if (this.compactReady()) this.askCompact(false);
264
+ }, COMPACT_IDLE_MS);
265
+ this.compactTimer.unref();
266
+ }
267
+
268
+ private askCompact(resuming: boolean): void {
248
269
  this.compactAsked = this.inbox.compact();
249
270
  log.info({ context: this.watch.context, resuming }, 'sdk-runner: compacting at a safe idle boundary');
250
271
  }
251
272
 
273
+ private cancelCompact(): void {
274
+ if (this.compactTimer !== null) clearTimeout(this.compactTimer);
275
+ this.compactTimer = null;
276
+ }
277
+
252
278
  private started(m: Record<string, unknown>): void {
253
279
  const id = typeof m.session_id === 'string' ? m.session_id : null;
254
280
  if (id === null || id === this.sessionId) return;