@stage-labs/metro 0.1.0-beta.326 → 0.1.0-beta.328

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 (33) hide show
  1. package/package.json +2 -2
  2. package/runtime/node_modules/@metro-labs/core/package.json +3 -1
  3. package/runtime/node_modules/@metro-labs/core/src/brands.ts +100 -0
  4. package/runtime/node_modules/@metro-labs/core/src/favicon.ts +38 -0
  5. package/runtime/node_modules/@metro-labs/core/src/skill-source.ts +5 -5
  6. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-fetch.ts +2 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +2 -2
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/memory.ts +1 -1
  10. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +6 -2
  11. package/runtime/node_modules/@metro-labs/discord-bot/package.json +1 -1
  12. package/runtime/node_modules/@metro-labs/gmail/package.json +1 -1
  13. package/runtime/node_modules/@metro-labs/http/package.json +1 -1
  14. package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +1 -0
  15. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +42 -2
  16. package/runtime/node_modules/@metro-labs/inference/package.json +1 -1
  17. package/runtime/node_modules/@metro-labs/outlook/package.json +1 -1
  18. package/runtime/node_modules/@metro-labs/telegram/package.json +1 -1
  19. package/runtime/node_modules/@metro-labs/telegram-bot/package.json +1 -1
  20. package/runtime/node_modules/@metro-labs/threema/package.json +1 -1
  21. package/runtime/node_modules/@metro-labs/webhook/package.json +1 -1
  22. package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -1
  23. package/runtime/node_modules/@metro-labs/xmtp/package.json +1 -1
  24. package/runtime/runtime.json +1 -1
  25. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +3 -1
  26. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/brands.ts +100 -0
  27. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/favicon.ts +38 -0
  28. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/skill-source.ts +5 -5
  29. package/runtime/sdk-runner/runner.json +1 -1
  30. package/runtime/sdk-runner/src/app.ts +1 -1
  31. package/runtime/sdk-runner/src/runner.ts +19 -9
  32. package/runtime/sdk-runner/src/session-watch.ts +2 -2
  33. package/skills/METRO.md +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.326",
3
+ "version": "0.1.0-beta.328",
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": {
@@ -32,7 +32,7 @@
32
32
  "test": "tsc --noEmit && node scripts/stage-runtime.mjs && bun test test/"
33
33
  },
34
34
  "devDependencies": {
35
- "@stage-labs/config": "0.1.0-beta.3",
35
+ "@stage-labs/config": "0.1.0-beta.5",
36
36
  "eslint": "^10.3.0",
37
37
  "knip": "^6.15.0",
38
38
  "madge": "^8.0.0",
@@ -8,6 +8,8 @@
8
8
  "./log": "./src/log.ts",
9
9
  "./events": "./src/events.ts",
10
10
  "./is-record": "./src/is-record.ts",
11
+ "./favicon": "./src/favicon.ts",
12
+ "./brands": "./src/brands.ts",
11
13
  "./runner-activity": "./src/runner-activity.ts",
12
14
  "./automation-store": "./src/automation-store.ts",
13
15
  "./automation-types": "./src/automation-types.ts",
@@ -52,7 +54,7 @@
52
54
  "pino-pretty": "^13.1.3"
53
55
  },
54
56
  "devDependencies": {
55
- "@stage-labs/config": "0.1.0-beta.3",
57
+ "@stage-labs/config": "0.1.0-beta.5",
56
58
  "eslint": "^10.3.0",
57
59
  "knip": "^6.15.0",
58
60
  "madge": "^8.0.0",
@@ -0,0 +1,100 @@
1
+ import { baseDomain } from './favicon.js';
2
+
3
+ interface BrandLogo {
4
+ light: string;
5
+ dark?: string;
6
+ }
7
+
8
+ const BY_HOST: Record<string, string> = {
9
+ 'gemini.google.com': 'gemini',
10
+ 'aws.amazon.com': 'aws',
11
+ 'mail.google.com': 'gmail-0a242131',
12
+ 'gmailmcp.googleapis.com': 'gmail-0a242131',
13
+ 'drive.google.com': 'googledrive',
14
+ };
15
+
16
+ const BY_DOMAIN: Record<string, string> = {
17
+ 'xmtp.org': 'xmtp',
18
+ 'whatsapp.com': 'whatsapp',
19
+ 'whatsapp.net': 'whatsapp',
20
+ 'telegram.org': 'telegram',
21
+ 't.me': 'telegram',
22
+ 'discord.com': 'discord',
23
+ 'discord.gg': 'discord',
24
+ 'threema.ch': 'threema',
25
+ 'outlook.com': 'outlook',
26
+ 'office.com': 'outlook',
27
+ 'live.com': 'outlook',
28
+ 'microsoft.com': 'microsoft',
29
+ 'sharepoint.com': 'sharepoint',
30
+ 'linear.app': 'linear',
31
+ 'notion.so': 'notion',
32
+ 'notion.com': 'notion',
33
+ 'claude.ai': 'claude',
34
+ 'anthropic.com': 'claude',
35
+ 'slack.com': 'slack',
36
+ 'github.com': 'github',
37
+ 'openrouter.ai': 'openrouter',
38
+ 'routeway.ai': 'routeway',
39
+ 'openai.com': 'openai',
40
+ 'mistral.ai': 'mistral',
41
+ 'qwen.ai': 'qwen',
42
+ 'qwenlm.ai': 'qwen',
43
+ 'chatgpt.com': 'openai',
44
+ 'x.ai': 'grok',
45
+ 'grok.com': 'grok',
46
+ 'atlassian.com': 'atlassian',
47
+ 'atlassian.net': 'atlassian',
48
+ 'hubspot.com': 'hubspot',
49
+ 'stripe.com': 'stripe',
50
+ 'sentry.io': 'sentry',
51
+ 'asana.com': 'asana',
52
+ 'intercom.com': 'intercom',
53
+ 'intercom.io': 'intercom',
54
+ 'cloudflare.com': 'cloudflare',
55
+ 'figma.com': 'figma',
56
+ 'box.com': 'box',
57
+ 'zapier.com': 'zapier',
58
+ };
59
+
60
+ const WITH_DARK = new Set(['github', 'openrouter', 'aws', 'openai', 'grok']);
61
+ const RASTER = new Set(['routeway']);
62
+
63
+ function logoOf(slug: string): BrandLogo {
64
+ const light = `/brands/${slug}.${RASTER.has(slug) ? 'png' : 'svg'}`;
65
+ return WITH_DARK.has(slug) ? { light, dark: `/brands/${slug}-dark.svg` } : { light };
66
+ }
67
+
68
+ export function brandLogo(url: string): BrandLogo | null {
69
+ let host: string;
70
+ try {
71
+ host = new URL(url).hostname.toLowerCase();
72
+ } catch {
73
+ return null;
74
+ }
75
+ const slug = BY_HOST[host] ?? BY_DOMAIN[baseDomain(host)];
76
+ return slug === undefined ? null : logoOf(slug);
77
+ }
78
+
79
+ export function brandSrc(url: string, dark: boolean): string | null {
80
+ const logo = brandLogo(url);
81
+ if (logo === null) return null;
82
+ return dark ? (logo.dark ?? logo.light) : logo.light;
83
+ }
84
+
85
+ export interface ConnectorIcon {
86
+ src: string;
87
+ mimeType?: string;
88
+ sizes?: string[];
89
+ theme?: 'light' | 'dark';
90
+ }
91
+
92
+ const BRAND_ORIGIN = 'https://metro.box';
93
+
94
+ const brandIcon = (path: string, theme?: 'light' | 'dark'): ConnectorIcon => ({ src: `${BRAND_ORIGIN}${path}`, mimeType: path.endsWith('.png') ? 'image/png' : 'image/svg+xml', ...(theme === undefined ? {} : { theme }) });
95
+
96
+ export function brandIcons(url: string): ConnectorIcon[] {
97
+ const logo = brandLogo(url);
98
+ if (logo === null) return [];
99
+ return logo.dark === undefined ? [brandIcon(logo.light)] : [brandIcon(logo.light, 'light'), brandIcon(logo.dark, 'dark')];
100
+ }
@@ -0,0 +1,38 @@
1
+ const CHECKED_BASE = 'https://t1.gstatic.com/faviconV2';
2
+
3
+ const MULTI_LABEL_SUFFIXES = new Set([
4
+ 'ac.uk', 'co.uk', 'gov.uk', 'me.uk', 'net.uk', 'org.uk',
5
+ 'com.au', 'edu.au', 'net.au', 'org.au',
6
+ 'co.nz', 'net.nz', 'org.nz',
7
+ 'ac.jp', 'co.jp', 'ne.jp', 'or.jp',
8
+ 'co.kr', 'co.za', 'co.il', 'co.in', 'net.in', 'org.in',
9
+ 'com.ar', 'com.br', 'net.br', 'org.br',
10
+ 'com.cn', 'net.cn', 'org.cn',
11
+ 'com.hk', 'com.mx', 'com.pl', 'com.sg', 'com.tr', 'com.tw',
12
+ ]);
13
+
14
+ export function baseDomain(host: string): string {
15
+ const labels = host
16
+ .toLowerCase()
17
+ .replace(/\.$/, '')
18
+ .split('.')
19
+ .filter((label) => label !== '');
20
+ if (labels.length <= 2) return labels.join('.');
21
+ const lastTwo = labels.slice(-2).join('.');
22
+ if (MULTI_LABEL_SUFFIXES.has(lastTwo)) return labels.slice(-3).join('.');
23
+ return lastTwo;
24
+ }
25
+
26
+ export function siteDomain(url: string): string {
27
+ try {
28
+ const domain = baseDomain(new URL(url).hostname);
29
+ return domain.includes('.') ? domain : '';
30
+ } catch {
31
+ return '';
32
+ }
33
+ }
34
+
35
+ export function checkedFaviconUrl(url: string, size: number): string {
36
+ const domain = siteDomain(url);
37
+ return domain === '' ? '' : `${CHECKED_BASE}?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(`https://${domain}`)}&size=${String(size)}`;
38
+ }
@@ -4,10 +4,10 @@ import { isRecord } from './is-record.js';
4
4
  export const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
5
5
  export const SKILL_HASH = /^[a-f0-9]{64}$/;
6
6
  export const SKILL_COMMIT = /^[a-f0-9]{40}$/;
7
- export const SKILL_FILE_MAX = 256 * 1024;
8
- export const SKILL_TOTAL_MAX = 8 * 1024 * 1024;
9
- export const SKILL_FILES_MAX = 256;
10
- export const SKILL_MANIFEST_MAX = 128 * 1024;
7
+ export const SKILL_FILE_MAX = 512 * 1024;
8
+ export const SKILL_TOTAL_MAX = 32 * 1024 * 1024;
9
+ export const SKILL_FILES_MAX = 512;
10
+ export const SKILL_MANIFEST_MAX = 256 * 1024;
11
11
 
12
12
  export interface SkillSource {
13
13
  repository: string;
@@ -48,7 +48,7 @@ export const skillRelease = (root: string, id: string): string => {
48
48
  export function skillPath(path: string): boolean {
49
49
  const parts = path.split('/');
50
50
  return path.length <= 240 && parts.length >= 2 && parts.length <= 8 && SKILL_NAME.test(parts[0] ?? '') &&
51
- parts.every((part) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(part)) &&
51
+ parts.every((part) => /^[A-Za-z0-9_](?:[A-Za-z0-9 ._()-]{0,126}[A-Za-z0-9_.()-])?$/.test(part)) &&
52
52
  !parts.slice(1, -1).includes('SKILL.md') && !(parts.length > 2 && parts.at(-1) === 'SKILL.md');
53
53
  }
54
54
 
@@ -42,7 +42,7 @@
42
42
  "zod": "^3.23.8"
43
43
  },
44
44
  "devDependencies": {
45
- "@stage-labs/config": "0.1.0-beta.3",
45
+ "@stage-labs/config": "0.1.0-beta.5",
46
46
  "@types/ws": "^8.18.0",
47
47
  "eslint": "^10.3.0",
48
48
  "knip": "^6.15.0",
@@ -142,7 +142,7 @@ function immutableCommit(data: unknown): { sha: string; tree: string } {
142
142
 
143
143
  function regularFile(entry: Entry): void {
144
144
  if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode)) throw new GitHubFailure('Symlinks, submodules and special files are not supported in skills.');
145
- if (!skillPath(entry.path) || entry.size < 0 || entry.size > SKILL_FILE_MAX || !Number.isInteger(entry.size)) throw new GitHubFailure('A skills path or file size is not supported. Use skill-name/SKILL.md and regular resources, up to 256 KiB each.');
145
+ if (!skillPath(entry.path) || entry.size < 0 || entry.size > SKILL_FILE_MAX || !Number.isInteger(entry.size)) throw new GitHubFailure(`A skills path or file size is not supported. Use skill-name/SKILL.md and regular resources, up to ${SKILL_FILE_MAX / 1024} KiB each.`);
146
146
  }
147
147
 
148
148
  function checkedEntries(entries: Entry[]): Entry[] {
@@ -154,7 +154,7 @@ function checkedEntries(entries: Entry[]): Entry[] {
154
154
  total += entry.size;
155
155
  files.push(entry);
156
156
  }
157
- if (files.length > SKILL_FILES_MAX || total > SKILL_TOTAL_MAX) throw new GitHubFailure('Skills exceed 256 files or 8 MiB.');
157
+ if (files.length > SKILL_FILES_MAX || total > SKILL_TOTAL_MAX) throw new GitHubFailure(`Skills exceed ${SKILL_FILES_MAX} files or ${SKILL_TOTAL_MAX / 1024 / 1024} MiB.`);
158
158
  if (new Set(files.map((entry) => entry.path.toLowerCase())).size !== files.length) throw new GitHubFailure('Skills contain duplicate file paths.');
159
159
  return files;
160
160
  }
@@ -4,7 +4,7 @@ import { Readable } from 'node:stream';
4
4
  import { log } from '@metro-labs/core/log';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import { writeSecure } from '@metro-labs/core/secure-fs';
7
- import { SKILL_HASH, SKILL_MANIFEST_MAX, skillGeneration, skillRelease, skillSourceRoot, type SkillGeneration } from '@metro-labs/core/skill-source';
7
+ import { SKILL_FILE_MAX, SKILL_HASH, SKILL_MANIFEST_MAX, skillGeneration, skillRelease, skillSourceRoot, type SkillGeneration } from '@metro-labs/core/skill-source';
8
8
  import { ApiError } from '@metro-labs/http/api-error';
9
9
  import { readFileSync, statSync } from '../agent-user/agent-fs.js';
10
10
  import { receiveHomeFile, writeHomeText } from '../agent-user/home-fs.js';
@@ -135,7 +135,7 @@ export class GitHubSkills {
135
135
  read(id: string): ClaudeSkill & { text: string } {
136
136
  const row = this.rows().find((skill) => skill.id === id);
137
137
  if (row === undefined) throw new ApiError('no such skill', 404);
138
- if (statSync(row.path).size > 256 * 1024) throw new ApiError('The managed skill changed on disk. Sync it again.', 409);
138
+ if (statSync(row.path).size > SKILL_FILE_MAX) throw new ApiError('The managed skill changed on disk. Sync it again.', 409);
139
139
  return { ...row, text: readFileSync(row.path, 'utf8') };
140
140
  }
141
141
 
@@ -28,5 +28,5 @@ export function inSessionScope(command: [string, string[]], host: ScopeHost = re
28
28
  const asAgent = file === 'sudo' && args[0] === '-n' && args[1] === '-u' && args[3] === '--';
29
29
  if (!host.metro || !host.systemd || !asAgent) return command;
30
30
  const limit = sessionMemoryLimit(host.total);
31
- return helperArgv(['as-agent-scope', String(limit), String(Math.floor(limit * 0.9)), ...args.slice(4)]);
31
+ return helperArgv(['as-agent-scope', String(limit), String(limit), ...args.slice(4)]);
32
32
  }
@@ -41,7 +41,7 @@ Keep the change to what the task asks for. A pre-existing bug, a performance pro
41
41
 
42
42
  Never call AskUserQuestion or ExitPlanMode. They are blocked by policy, because nobody is watching the terminal around the clock and a blocking prompt stalls indefinitely. When you hit an ambiguity: do everything that does not depend on it, then choose the most reasonable interpretation, state the assumption explicitly in your report, and continue.
43
43
 
44
- Your final message is a report to the orchestrator, not a message to a human. It gets relayed onward, so make it self-contained and concise. Lead with the outcome. Include concrete evidence, command output, file paths with line numbers, test results, for anything you claim to have verified. Report failures faithfully: if a test failed, say so and show the output; if you skipped a step, say that too.
44
+ Your final message is a report to the orchestrator, not a message to a human. It stays in the orchestrator's context for the rest of the conversation and makes every later answer slower, so keep it short: about 150 words at most, self-contained. Lead with the outcome, then the key evidence for what you claim to have verified, such as the test result or the file you changed. Put anything longer, such as full logs, tables or detailed findings, in a file under ~/work and give its path. Report failures faithfully: if a test failed, say so with its key line of output; if you skipped a step, say that too.
45
45
  `;
46
46
 
47
47
  export interface SetupDeps {
@@ -129,7 +129,10 @@ export function setSystemPrompt(text: string, agents = agentsDir()): void {
129
129
  writeAtomic(promptPath(agents), `${trimmed}\n`, 0o600);
130
130
  }
131
131
 
132
- const PRIOR_WORKER: ReadonlySet<string> = new Set(['08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef']);
132
+ const PRIOR_WORKER: ReadonlySet<string> = new Set([
133
+ '08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef',
134
+ '9db999a7bb7cf1d6c12a679520e21c78d038999d5454d7abbc6b87a87c136c54',
135
+ ]);
133
136
  const PRIOR_METRO: ReadonlySet<string> = new Set([
134
137
  '0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad',
135
138
  '36f4fb57f231a119d717b5e3d6ccb654f94960fcca708683f376679aead8df25',
@@ -142,6 +145,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
142
145
  '256c711a6175266c216e32c88b7190d0f98f5e6b702abe03220b422aba426574',
143
146
  '9e164a956437d60d30ea1984115a17d303940d191f5efed04954f244fc89d48f',
144
147
  '8b6d7c5a9bc8d02d5e3ae5e5931b36aba9f3d0eb7fd38b371294c2abaa0c94ee',
148
+ '8b8f21cb1b7608540d8b5ed9557e1644b03ce4809a7f320ba134f28129f08ddb',
145
149
  ]);
146
150
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
147
151
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
@@ -17,7 +17,7 @@
17
17
  "discord.js": "^14.26.4"
18
18
  },
19
19
  "devDependencies": {
20
- "@stage-labs/config": "0.1.0-beta.3",
20
+ "@stage-labs/config": "0.1.0-beta.5",
21
21
  "eslint": "^10.3.0",
22
22
  "knip": "^6.15.0",
23
23
  "madge": "^8.0.0",
@@ -18,7 +18,7 @@
18
18
  "@metro-labs/core": "workspace:*"
19
19
  },
20
20
  "devDependencies": {
21
- "@stage-labs/config": "0.1.0-beta.3",
21
+ "@stage-labs/config": "0.1.0-beta.5",
22
22
  "eslint": "^10.3.0",
23
23
  "knip": "^6.15.0",
24
24
  "madge": "^8.0.0",
@@ -35,7 +35,7 @@
35
35
  "@metro-labs/core": "workspace:*"
36
36
  },
37
37
  "devDependencies": {
38
- "@stage-labs/config": "0.1.0-beta.3",
38
+ "@stage-labs/config": "0.1.0-beta.5",
39
39
  "eslint": "^10.3.0",
40
40
  "knip": "^6.15.0",
41
41
  "madge": "^8.0.0",
@@ -16,6 +16,7 @@ const SIGN_IN_AGAIN = new Set(['invalid_grant', 'invalid_client', 'unauthorized_
16
16
  export interface OAuthClient {
17
17
  clientId: string;
18
18
  clientSecret?: string;
19
+ redirectUri?: string;
19
20
  }
20
21
 
21
22
  export function newVerifier(): string {
@@ -1,3 +1,4 @@
1
+ import type { ConnectorIcon } from '@metro-labs/core/brands';
1
2
  import { isRecord } from '@metro-labs/core/is-record';
2
3
  import { errMsg, log } from '@metro-labs/core/log';
3
4
  import type { FetchLike } from '@metro-labs/core/stations/oauth';
@@ -14,7 +15,16 @@ export interface RemoteTool {
14
15
  inputSchema?: Record<string, unknown>;
15
16
  }
16
17
 
18
+ export interface RemoteServer {
19
+ tools: RemoteTool[];
20
+ icons: ConnectorIcon[];
21
+ }
22
+
17
23
  const TIMEOUT_MS = 15_000;
24
+ const ICONS_MAX = 4;
25
+ const ICON_URL_MAX = 2048;
26
+ const MIME_RE = /^image\/[a-z0-9.+-]{1,64}$/i;
27
+ const SIZE_RE = /^(any|\d{1,4}x\d{1,4})$/;
18
28
  const MAX_PAGES = 100;
19
29
  const MAX_TOOLS = 2000;
20
30
 
@@ -86,14 +96,44 @@ async function walkTools(url: URL, auth: ConnectorAuth, session: Map<string, str
86
96
  throw refused('The provider did not return a complete tool list.');
87
97
  }
88
98
 
89
- export async function listRemoteTools(url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
99
+ function iconSrc(raw: unknown): string | null {
100
+ if (typeof raw !== 'string' || raw.length > ICON_URL_MAX) return null;
101
+ try {
102
+ const src = new URL(raw);
103
+ return src.protocol === 'https:' && src.username === '' && src.password === '' ? src.toString() : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function iconOf(raw: unknown): ConnectorIcon | null {
110
+ const src = isRecord(raw) ? iconSrc(raw.src) : null;
111
+ if (!isRecord(raw) || src === null) return null;
112
+ const sizes = Array.isArray(raw.sizes) ? raw.sizes.filter((size): size is string => typeof size === 'string' && SIZE_RE.test(size)).slice(0, ICONS_MAX) : [];
113
+ return {
114
+ src,
115
+ ...(typeof raw.mimeType === 'string' && MIME_RE.test(raw.mimeType) ? { mimeType: raw.mimeType } : {}),
116
+ ...(sizes.length > 0 ? { sizes } : {}),
117
+ ...(raw.theme === 'light' || raw.theme === 'dark' ? { theme: raw.theme } : {}),
118
+ };
119
+ }
120
+
121
+ function serverIconsOf(init: Record<string, unknown>): ConnectorIcon[] {
122
+ const info = isRecord(init.serverInfo) ? init.serverInfo : {};
123
+ if (!Array.isArray(info.icons)) return [];
124
+ return info.icons.map(iconOf).filter((icon): icon is ConnectorIcon => icon !== null).slice(0, ICONS_MAX);
125
+ }
126
+
127
+ export const listRemoteTools = async (url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteTool[]> => (await listRemoteServer(url, auth, fetchImpl)).tools;
128
+
129
+ export async function listRemoteServer(url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteServer> {
90
130
  const session = new Map<string, string>();
91
131
  try {
92
132
  const init = await rpc(url, auth, session, INITIALIZE, fetchImpl);
93
133
  if (init === null || typeof init.protocolVersion !== 'string') throw refused(`${url.hostname} answered, but it does not speak MCP.`);
94
134
  session.set('mcp-protocol-version', init.protocolVersion);
95
135
  await rpc(url, auth, session, { jsonrpc: '2.0', method: 'notifications/initialized' }, fetchImpl);
96
- return await walkTools(url, auth, session, fetchImpl);
136
+ return { tools: await walkTools(url, auth, session, fetchImpl), icons: serverIconsOf(init) };
97
137
  } finally {
98
138
  await endSession(url, auth, session, fetchImpl);
99
139
  }
@@ -33,7 +33,7 @@
33
33
  "@metro-labs/http": "workspace:*"
34
34
  },
35
35
  "devDependencies": {
36
- "@stage-labs/config": "0.1.0-beta.3",
36
+ "@stage-labs/config": "0.1.0-beta.5",
37
37
  "eslint": "^10.3.0",
38
38
  "knip": "^6.15.0",
39
39
  "madge": "^8.0.0",
@@ -16,7 +16,7 @@
16
16
  "@metro-labs/core": "workspace:*"
17
17
  },
18
18
  "devDependencies": {
19
- "@stage-labs/config": "0.1.0-beta.3",
19
+ "@stage-labs/config": "0.1.0-beta.5",
20
20
  "eslint": "^10.3.0",
21
21
  "knip": "^6.15.0",
22
22
  "madge": "^8.0.0",
@@ -17,7 +17,7 @@
17
17
  "@mtcute/bun": "0.30.1"
18
18
  },
19
19
  "devDependencies": {
20
- "@stage-labs/config": "0.1.0-beta.3",
20
+ "@stage-labs/config": "0.1.0-beta.5",
21
21
  "eslint": "^10.3.0",
22
22
  "knip": "^6.15.0",
23
23
  "madge": "^8.0.0",
@@ -16,7 +16,7 @@
16
16
  "@metro-labs/core": "workspace:*"
17
17
  },
18
18
  "devDependencies": {
19
- "@stage-labs/config": "0.1.0-beta.3",
19
+ "@stage-labs/config": "0.1.0-beta.5",
20
20
  "eslint": "^10.3.0",
21
21
  "knip": "^6.15.0",
22
22
  "madge": "^8.0.0",
@@ -17,7 +17,7 @@
17
17
  "tweetnacl": "^1.0.3"
18
18
  },
19
19
  "devDependencies": {
20
- "@stage-labs/config": "0.1.0-beta.3",
20
+ "@stage-labs/config": "0.1.0-beta.5",
21
21
  "eslint": "^10.3.0",
22
22
  "knip": "^6.15.0",
23
23
  "madge": "^8.0.0",
@@ -14,7 +14,7 @@
14
14
  "@metro-labs/core": "workspace:*"
15
15
  },
16
16
  "devDependencies": {
17
- "@stage-labs/config": "0.1.0-beta.3",
17
+ "@stage-labs/config": "0.1.0-beta.5",
18
18
  "eslint": "^10.3.0",
19
19
  "knip": "^6.15.0",
20
20
  "madge": "^8.0.0",
@@ -17,7 +17,7 @@
17
17
  "baileys": "7.0.0-rc14"
18
18
  },
19
19
  "devDependencies": {
20
- "@stage-labs/config": "0.1.0-beta.3",
20
+ "@stage-labs/config": "0.1.0-beta.5",
21
21
  "eslint": "^10.3.0",
22
22
  "knip": "^6.15.0",
23
23
  "madge": "^8.0.0",
@@ -25,7 +25,7 @@
25
25
  "viem": "^2.52.2"
26
26
  },
27
27
  "devDependencies": {
28
- "@stage-labs/config": "0.1.0-beta.3",
28
+ "@stage-labs/config": "0.1.0-beta.5",
29
29
  "eslint": "^10.3.0",
30
30
  "knip": "^6.15.0",
31
31
  "madge": "^8.0.0",
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.326"
2
+ "version": "0.1.0-beta.328"
3
3
  }
@@ -8,6 +8,8 @@
8
8
  "./log": "./src/log.ts",
9
9
  "./events": "./src/events.ts",
10
10
  "./is-record": "./src/is-record.ts",
11
+ "./favicon": "./src/favicon.ts",
12
+ "./brands": "./src/brands.ts",
11
13
  "./runner-activity": "./src/runner-activity.ts",
12
14
  "./automation-store": "./src/automation-store.ts",
13
15
  "./automation-types": "./src/automation-types.ts",
@@ -52,7 +54,7 @@
52
54
  "pino-pretty": "^13.1.3"
53
55
  },
54
56
  "devDependencies": {
55
- "@stage-labs/config": "0.1.0-beta.3",
57
+ "@stage-labs/config": "0.1.0-beta.5",
56
58
  "eslint": "^10.3.0",
57
59
  "knip": "^6.15.0",
58
60
  "madge": "^8.0.0",
@@ -0,0 +1,100 @@
1
+ import { baseDomain } from './favicon.js';
2
+
3
+ interface BrandLogo {
4
+ light: string;
5
+ dark?: string;
6
+ }
7
+
8
+ const BY_HOST: Record<string, string> = {
9
+ 'gemini.google.com': 'gemini',
10
+ 'aws.amazon.com': 'aws',
11
+ 'mail.google.com': 'gmail-0a242131',
12
+ 'gmailmcp.googleapis.com': 'gmail-0a242131',
13
+ 'drive.google.com': 'googledrive',
14
+ };
15
+
16
+ const BY_DOMAIN: Record<string, string> = {
17
+ 'xmtp.org': 'xmtp',
18
+ 'whatsapp.com': 'whatsapp',
19
+ 'whatsapp.net': 'whatsapp',
20
+ 'telegram.org': 'telegram',
21
+ 't.me': 'telegram',
22
+ 'discord.com': 'discord',
23
+ 'discord.gg': 'discord',
24
+ 'threema.ch': 'threema',
25
+ 'outlook.com': 'outlook',
26
+ 'office.com': 'outlook',
27
+ 'live.com': 'outlook',
28
+ 'microsoft.com': 'microsoft',
29
+ 'sharepoint.com': 'sharepoint',
30
+ 'linear.app': 'linear',
31
+ 'notion.so': 'notion',
32
+ 'notion.com': 'notion',
33
+ 'claude.ai': 'claude',
34
+ 'anthropic.com': 'claude',
35
+ 'slack.com': 'slack',
36
+ 'github.com': 'github',
37
+ 'openrouter.ai': 'openrouter',
38
+ 'routeway.ai': 'routeway',
39
+ 'openai.com': 'openai',
40
+ 'mistral.ai': 'mistral',
41
+ 'qwen.ai': 'qwen',
42
+ 'qwenlm.ai': 'qwen',
43
+ 'chatgpt.com': 'openai',
44
+ 'x.ai': 'grok',
45
+ 'grok.com': 'grok',
46
+ 'atlassian.com': 'atlassian',
47
+ 'atlassian.net': 'atlassian',
48
+ 'hubspot.com': 'hubspot',
49
+ 'stripe.com': 'stripe',
50
+ 'sentry.io': 'sentry',
51
+ 'asana.com': 'asana',
52
+ 'intercom.com': 'intercom',
53
+ 'intercom.io': 'intercom',
54
+ 'cloudflare.com': 'cloudflare',
55
+ 'figma.com': 'figma',
56
+ 'box.com': 'box',
57
+ 'zapier.com': 'zapier',
58
+ };
59
+
60
+ const WITH_DARK = new Set(['github', 'openrouter', 'aws', 'openai', 'grok']);
61
+ const RASTER = new Set(['routeway']);
62
+
63
+ function logoOf(slug: string): BrandLogo {
64
+ const light = `/brands/${slug}.${RASTER.has(slug) ? 'png' : 'svg'}`;
65
+ return WITH_DARK.has(slug) ? { light, dark: `/brands/${slug}-dark.svg` } : { light };
66
+ }
67
+
68
+ export function brandLogo(url: string): BrandLogo | null {
69
+ let host: string;
70
+ try {
71
+ host = new URL(url).hostname.toLowerCase();
72
+ } catch {
73
+ return null;
74
+ }
75
+ const slug = BY_HOST[host] ?? BY_DOMAIN[baseDomain(host)];
76
+ return slug === undefined ? null : logoOf(slug);
77
+ }
78
+
79
+ export function brandSrc(url: string, dark: boolean): string | null {
80
+ const logo = brandLogo(url);
81
+ if (logo === null) return null;
82
+ return dark ? (logo.dark ?? logo.light) : logo.light;
83
+ }
84
+
85
+ export interface ConnectorIcon {
86
+ src: string;
87
+ mimeType?: string;
88
+ sizes?: string[];
89
+ theme?: 'light' | 'dark';
90
+ }
91
+
92
+ const BRAND_ORIGIN = 'https://metro.box';
93
+
94
+ const brandIcon = (path: string, theme?: 'light' | 'dark'): ConnectorIcon => ({ src: `${BRAND_ORIGIN}${path}`, mimeType: path.endsWith('.png') ? 'image/png' : 'image/svg+xml', ...(theme === undefined ? {} : { theme }) });
95
+
96
+ export function brandIcons(url: string): ConnectorIcon[] {
97
+ const logo = brandLogo(url);
98
+ if (logo === null) return [];
99
+ return logo.dark === undefined ? [brandIcon(logo.light)] : [brandIcon(logo.light, 'light'), brandIcon(logo.dark, 'dark')];
100
+ }
@@ -0,0 +1,38 @@
1
+ const CHECKED_BASE = 'https://t1.gstatic.com/faviconV2';
2
+
3
+ const MULTI_LABEL_SUFFIXES = new Set([
4
+ 'ac.uk', 'co.uk', 'gov.uk', 'me.uk', 'net.uk', 'org.uk',
5
+ 'com.au', 'edu.au', 'net.au', 'org.au',
6
+ 'co.nz', 'net.nz', 'org.nz',
7
+ 'ac.jp', 'co.jp', 'ne.jp', 'or.jp',
8
+ 'co.kr', 'co.za', 'co.il', 'co.in', 'net.in', 'org.in',
9
+ 'com.ar', 'com.br', 'net.br', 'org.br',
10
+ 'com.cn', 'net.cn', 'org.cn',
11
+ 'com.hk', 'com.mx', 'com.pl', 'com.sg', 'com.tr', 'com.tw',
12
+ ]);
13
+
14
+ export function baseDomain(host: string): string {
15
+ const labels = host
16
+ .toLowerCase()
17
+ .replace(/\.$/, '')
18
+ .split('.')
19
+ .filter((label) => label !== '');
20
+ if (labels.length <= 2) return labels.join('.');
21
+ const lastTwo = labels.slice(-2).join('.');
22
+ if (MULTI_LABEL_SUFFIXES.has(lastTwo)) return labels.slice(-3).join('.');
23
+ return lastTwo;
24
+ }
25
+
26
+ export function siteDomain(url: string): string {
27
+ try {
28
+ const domain = baseDomain(new URL(url).hostname);
29
+ return domain.includes('.') ? domain : '';
30
+ } catch {
31
+ return '';
32
+ }
33
+ }
34
+
35
+ export function checkedFaviconUrl(url: string, size: number): string {
36
+ const domain = siteDomain(url);
37
+ return domain === '' ? '' : `${CHECKED_BASE}?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(`https://${domain}`)}&size=${String(size)}`;
38
+ }
@@ -4,10 +4,10 @@ import { isRecord } from './is-record.js';
4
4
  export const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
5
5
  export const SKILL_HASH = /^[a-f0-9]{64}$/;
6
6
  export const SKILL_COMMIT = /^[a-f0-9]{40}$/;
7
- export const SKILL_FILE_MAX = 256 * 1024;
8
- export const SKILL_TOTAL_MAX = 8 * 1024 * 1024;
9
- export const SKILL_FILES_MAX = 256;
10
- export const SKILL_MANIFEST_MAX = 128 * 1024;
7
+ export const SKILL_FILE_MAX = 512 * 1024;
8
+ export const SKILL_TOTAL_MAX = 32 * 1024 * 1024;
9
+ export const SKILL_FILES_MAX = 512;
10
+ export const SKILL_MANIFEST_MAX = 256 * 1024;
11
11
 
12
12
  export interface SkillSource {
13
13
  repository: string;
@@ -48,7 +48,7 @@ export const skillRelease = (root: string, id: string): string => {
48
48
  export function skillPath(path: string): boolean {
49
49
  const parts = path.split('/');
50
50
  return path.length <= 240 && parts.length >= 2 && parts.length <= 8 && SKILL_NAME.test(parts[0] ?? '') &&
51
- parts.every((part) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(part)) &&
51
+ parts.every((part) => /^[A-Za-z0-9_](?:[A-Za-z0-9 ._()-]{0,126}[A-Za-z0-9_.()-])?$/.test(part)) &&
52
52
  !parts.slice(1, -1).includes('SKILL.md') && !(parts.length > 2 && parts.at(-1) === 'SKILL.md');
53
53
  }
54
54
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.326",
2
+ "version": "0.1.0-beta.328",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -63,7 +63,7 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
63
63
  tools = metroTools(link);
64
64
  const resume = store.resumable();
65
65
  log.info({ resume, model: cfg.model, mode: cfg.permissionMode }, 'sdk-runner: starting the Agent SDK session');
66
- const approvals = approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); }, (tool, input) => runner.calls.approval(tool, input));
66
+ const approvals = approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); runner.approval(id, ask); }, (tool, input) => runner.calls.approval(tool, input));
67
67
  runner.start(runnerOptions(cfg, tools, approvals, resume, hooks.env));
68
68
  return { runner, link, done: runner.run(hooks.observe), stop };
69
69
  } catch (err) {
@@ -7,7 +7,6 @@ import { isRecord } from '@metro-labs/core/is-record';
7
7
  import type { AutomationStore } from '@metro-labs/core/automation-store';
8
8
  import type { Activity } from './activity.js';
9
9
  import { Automation } from './automation.js';
10
- import { ActivityTasks } from './activity-tasks.js';
11
10
  import { RunnerCalls } from './calls.js';
12
11
  import { channelText, type ChannelEvent } from './channel-text.js';
13
12
  import type { RunnerConfig } from './config.js';
@@ -21,7 +20,8 @@ import { METRO_SERVER, type MetroTools } from './tool-proxy.js';
21
20
  import { debugFailure, failureSummary, SessionError, StartupDiagnostics } from './errors.js';
22
21
 
23
22
  const STARTUP_WAIT = 'CLAUDE_CODE_MCP_STARTUP_WAIT_MS';
24
- export const COMPACT_AT = 120_000;
23
+ export const COMPACT_AT = 80_000;
24
+ const URGENT_FACTOR = 1.5;
25
25
  export const COMPACT_IDLE_MS = 60_000;
26
26
 
27
27
  const MODEL_PINS: ReadonlySet<string> = new Set(['ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']);
@@ -97,7 +97,6 @@ export class Runner {
97
97
  private readonly recovery: TaskRecovery;
98
98
  private readonly watch: SessionWatch;
99
99
  private readonly diagnostics = new StartupDiagnostics();
100
- private readonly tasks = new ActivityTasks(() => undefined);
101
100
  private readonly skillWork = new SkillWork();
102
101
  private compactTimer: ReturnType<typeof setTimeout> | null = null;
103
102
  private session: Query | null = null;
@@ -108,11 +107,12 @@ export class Runner {
108
107
  private model: string | null = null;
109
108
  private switching: Promise<void> = Promise.resolve();
110
109
  private ended = false;
110
+ private readonly mainAsks = new Set<string>();
111
111
  private readonly interrupted: number;
112
112
  private interruptionReported = false;
113
113
 
114
114
  constructor(private readonly parts: RunnerParts) {
115
- this.watch = new SessionWatch(parts.readOnly);
115
+ this.watch = new SessionWatch(parts.readOnly, () => this.mainAsks.size > 0);
116
116
  this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
117
117
  ready: () => { parts.skills?.check(); return this.watch.admitting && parts.skills?.busy !== true; },
118
118
  dispatch: (message) => { this.automation?.dispatched(message.uuid); this.watch.dispatched(message.uuid); },
@@ -167,6 +167,12 @@ export class Runner {
167
167
  return this.session;
168
168
  }
169
169
 
170
+ approval(id: string, ask: { worker: string | null } | null): void {
171
+ if (ask === null) this.mainAsks.delete(id);
172
+ else if (ask.worker === null) this.mainAsks.add(id);
173
+ this.inbox.notify();
174
+ }
175
+
170
176
  switchModel(model: string | null): Promise<void> {
171
177
  this.queueModel(model);
172
178
  return this.switching;
@@ -178,7 +184,6 @@ export class Runner {
178
184
  for await (const message of this.session) {
179
185
  if (this.ended) break;
180
186
  this.cancelCompact();
181
- if (message.type === 'system') this.tasks.observe(message);
182
187
  const m: Record<string, unknown> = { ...message };
183
188
  this.recovery.observe(m);
184
189
  this.automation?.observe(m);
@@ -186,8 +191,8 @@ export class Runner {
186
191
  this.observeWork(m);
187
192
  this.note(m);
188
193
  this.parts.activity?.observe(message);
189
- this.inbox.notify();
190
194
  this.maybeCompact();
195
+ this.inbox.notify();
191
196
  observe?.(message);
192
197
  }
193
198
  } catch (err) {
@@ -318,14 +323,19 @@ export class Runner {
318
323
  return this.ended || this.parts.skills?.busy === true || this.compactAsked !== null || !this.watch.safe || !this.watch.admitting;
319
324
  }
320
325
 
326
+ private get compactUrgent(): boolean {
327
+ return this.watch.context >= (this.parts.compactAt ?? COMPACT_AT) * URGENT_FACTOR;
328
+ }
329
+
321
330
  private compactReady(resuming = false): boolean {
322
- const busy = this.maintenanceBusy || (!resuming && (this.calls.live || this.inbox.pending > 0 || this.tasks.running > 0));
331
+ const waiting = this.inbox.pending > 0 && !this.compactUrgent;
332
+ const busy = this.maintenanceBusy || (!resuming && (this.calls.live || waiting));
323
333
  return compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy });
324
334
  }
325
335
 
326
336
  private maybeCompact(resuming = false): void {
327
337
  if (!this.compactReady(resuming)) return;
328
- if (resuming) { this.askCompact(true); return; }
338
+ if (resuming || this.compactUrgent) { this.askCompact(resuming); return; }
329
339
  this.compactTimer = setTimeout(() => {
330
340
  this.compactTimer = null;
331
341
  if (this.compactReady()) this.askCompact(false);
@@ -335,7 +345,7 @@ export class Runner {
335
345
 
336
346
  private askCompact(resuming: boolean): void {
337
347
  this.compactAsked = this.inbox.compact();
338
- log.info({ context: this.watch.context, resuming }, 'sdk-runner: compacting at a safe idle boundary');
348
+ log.info({ context: this.watch.context, resuming, urgent: this.compactUrgent, waiting: this.inbox.pending }, 'sdk-runner: compacting at a safe boundary');
339
349
  }
340
350
 
341
351
  private cancelCompact(): void {
@@ -35,7 +35,7 @@ export class SessionWatch {
35
35
  private lastContext = 0;
36
36
  private streamUsage: Record<string, unknown> = {};
37
37
 
38
- constructor(private readonly readOnly: (tool: string) => boolean) {}
38
+ constructor(private readonly readOnly: (tool: string) => boolean, private readonly mainAsking: () => boolean = () => true) {}
39
39
 
40
40
  get compacting(): boolean {
41
41
  return this.compactingNow;
@@ -52,7 +52,7 @@ export class SessionWatch {
52
52
  get safe(): boolean { return !this.busy && !this.writing && !this.compacting; }
53
53
 
54
54
  get admitting(): boolean {
55
- return this.awaitingInput === null && !this.requiresAction && !this.compacting;
55
+ return this.awaitingInput === null && !(this.requiresAction && this.mainAsking()) && !this.compacting;
56
56
  }
57
57
 
58
58
  dispatched(uuid?: string): void {
package/skills/METRO.md CHANGED
@@ -22,7 +22,7 @@ You are the main thread of a Claude Code session that is connected to chat throu
22
22
  1. `tool_search` finds a tool and returns its full definition: `select:notion.search` for an exact name, words (`notion create page`) to rank, or `+linear issue` to require `linear` in the name. Each name is `<connector>.<tool>`.
23
23
  2. `tool_call` runs it, with the name exactly as found and arguments that match its schema. After `tool_search` with `select:notion.search` returned `notion.search`: `{"calls": [{"tool": "notion.search", "arguments": {"query": "roadmap"}}]}`.
24
24
 
25
- Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`. `tool_search` may run on this thread, but never call `tool_call` here: delegate it to a worker, as all other work. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker repeat the same call with `"approval": "<id>"` added to it.
25
+ Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`. Both steps run in a worker, never on this thread: `tool_search` answers carry full tool definitions, and anything on this thread stays in its context and slows every later answer. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker repeat the same call with `"approval": "<id>"` added to it.
26
26
 
27
27
  **Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
28
28
 
@@ -30,6 +30,6 @@ The exception is a person describing a problem, asking a question or thinking ou
30
30
 
31
31
  **The whole request is the deliverable.** Do not quietly make it smaller, larger or different. Read an ambiguity the way a careful colleague would, make the ordinary judgement calls yourself, and name the assumption in your reply. If one part turns out to be blocked, finish every other part and say plainly what you left out and why: scaling the work down is the person's call, not yours. Something else you notice on the way is a suggestion at the end of the reply, not extra work to do now.
32
32
 
33
- **Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
33
+ **Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Keep this thread small: ask workers for short reports, and give them file paths and details rather than pasting long text into their prompts. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
34
34
 
35
35
  **Keep going.** Long tasks run as background agents and loops; check in over chat when something lands rather than going quiet. The session is restarted by the daemon if it exits, so an interrupted task is resumed from chat, not from memory.