@stage-labs/metro 0.1.0-beta.4 → 0.1.0-beta.5

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.
@@ -0,0 +1,203 @@
1
+ /** Dispatcher: owns chat stations + agent stations; routes inbounds (suffix "with X" overrides line default). */
2
+ import { copyFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import pkg from '../package.json' with { type: 'json' };
6
+ import { ClaudeStation } from './stations/claude/index.js';
7
+ import { CodexStation } from './stations/codex/index.js';
8
+ import { DiscordStation } from './stations/discord/index.js';
9
+ import { GitHubStation } from './stations/github/index.js';
10
+ import { TelegramStation } from './stations/telegram/index.js';
11
+ import { getAgentThread, getLastAgent, linesForStation, setAgentThread, setLastAgent, setLastSeen, setName, } from './helpers/scope-cache.js';
12
+ import { StreamScheduler } from './helpers/streaming.js';
13
+ import { runTurn, triggerStop } from './helpers/turn.js';
14
+ import { errMsg, log } from './log.js';
15
+ import { acquireLock, configuredPlatforms, loadMetroEnv, STATE_DIR, requireConfiguredPlatform } from './paths.js';
16
+ loadMetroEnv();
17
+ const platforms = configuredPlatforms();
18
+ requireConfiguredPlatform(platforms);
19
+ acquireLock(join(STATE_DIR, '.tail-lock'));
20
+ /** Install AGENTS.md skill into state dir so the agent has a stable path to consult. Refreshed every start so upgrades land. */
21
+ const AGENTS_MD = join(STATE_DIR, 'AGENTS.md');
22
+ try {
23
+ copyFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'agents.md'), AGENTS_MD);
24
+ }
25
+ catch (err) {
26
+ log.warn({ err: errMsg(err) }, 'failed to install agent skill');
27
+ }
28
+ const bootstrapped = new Set();
29
+ const codex = new CodexStation(pkg.version);
30
+ const claude = new ClaudeStation();
31
+ const discord = new DiscordStation();
32
+ const telegram = new TelegramStation();
33
+ const github = new GitHubStation();
34
+ const available = {};
35
+ const scheduler = new StreamScheduler();
36
+ async function startAgents() {
37
+ await Promise.allSettled([
38
+ codex.start().then(() => { available.codex = codex; }).catch(err => log.warn({ err: errMsg(err) }, 'codex unavailable')),
39
+ claude.start().then(() => { available.claude = claude; }).catch(err => log.warn({ err: errMsg(err) }, 'claude unavailable')),
40
+ ]);
41
+ if (!Object.keys(available).length) {
42
+ log.fatal('no agents available');
43
+ process.exit(2);
44
+ }
45
+ log.info({ agents: Object.keys(available) }, 'agents ready');
46
+ }
47
+ const SUFFIX_RE = /(?:^|\s)with\s+(claude|codex)\s*$/i;
48
+ function parseAgentSuffix(text) {
49
+ const t = text.trimEnd();
50
+ const m = SUFFIX_RE.exec(t);
51
+ return m ? { kind: m[1].toLowerCase(), cleanText: t.slice(0, m.index).trimEnd() } : { kind: null, cleanText: text };
52
+ }
53
+ function pickAgent(line, req) {
54
+ if (req)
55
+ return available[req] ? { kind: req } : { error: `${req} is not available on this metro instance` };
56
+ const last = line ? getLastAgent(line) : undefined;
57
+ if (last && available[last])
58
+ return { kind: last };
59
+ if (available.claude)
60
+ return { kind: 'claude' };
61
+ if (available.codex)
62
+ return { kind: 'codex' };
63
+ return { error: 'no agents available' };
64
+ }
65
+ /** Resolve agent session for `line`, allocating if new, then run the turn. */
66
+ async function dispatch(line, text, attachments, kind, messageId, adapter, lineName) {
67
+ setLastSeen(line, messageId);
68
+ let threadId = getAgentThread(line, kind);
69
+ if (threadId)
70
+ setLastAgent(line, kind);
71
+ else {
72
+ threadId = await available[kind].createThread();
73
+ setAgentThread(line, kind, threadId);
74
+ log.info({ line, agent: kind, thread: threadId }, 'allocated agent session');
75
+ }
76
+ if (lineName)
77
+ setName(line, lineName);
78
+ await runTurn(available[kind], threadId, withContext(line, text), attachments, adapter, scheduler);
79
+ }
80
+ /** Tell the agent its line + the one rule: write normally to reply here, only use `metro send` for OTHER lines. */
81
+ const withContext = (line, text) => `[metro: this turn is on ${line}. To reply HERE just write text — metro streams it back automatically; do NOT use \`metro send\` for this line. Use \`metro send <other-line> <text>\` only to post to a DIFFERENT conversation (list with \`metro lines\`). Full guide: ${AGENTS_MD}]\n\n${text}`;
82
+ const adapterFor = (station, line) => ({
83
+ send: (t, stopId) => station.send(line, t, { stopId }),
84
+ edit: async (id, t, stopId) => { await station.edit(line, id, t, { stopId }); },
85
+ });
86
+ /** Dispatch into an existing agent on `m.line`, or (on `@`-mention) allocate a new session, optionally via station-specific bootstrap. */
87
+ async function routeInbound(m, station, bootstrap) {
88
+ const hasAgent = !!(getAgentThread(m.line, 'codex') ?? getAgentThread(m.line, 'claude'));
89
+ const { kind: req, cleanText } = parseAgentSuffix(m.text);
90
+ const postErr = (msg) => station.send(m.line, `⚠️ ${msg}`).then(() => { }).catch(err => log.warn({ err: errMsg(err), station: station.name }, 'error post failed'));
91
+ if (hasAgent) {
92
+ const c = pickAgent(m.line, req);
93
+ return 'error' in c ? postErr(c.error) : dispatch(m.line, cleanText, m.attachments, c.kind, m.messageId, adapterFor(station, m.line), m.lineName);
94
+ }
95
+ if (!m.mentionsBot || bootstrapped.has(m.messageId))
96
+ return;
97
+ bootstrapped.add(m.messageId);
98
+ const choice = pickAgent(null, req);
99
+ if ('error' in choice)
100
+ return postErr(choice.error);
101
+ /** Bootstrap creates a new chat-side scope (Discord thread / Telegram topic) — we know its name from `cleanText`. */
102
+ const line = bootstrap ? await bootstrap(m, cleanText).catch(err => { log.warn({ err: errMsg(err), station: station.name }, 'bootstrap failed'); return null; }) : m.line;
103
+ if (!line)
104
+ return;
105
+ await dispatch(line, cleanText, m.attachments, choice.kind, m.messageId, adapterFor(station, line), bootstrap ? makeThreadName(cleanText) : m.lineName);
106
+ }
107
+ const onDiscordInbound = (m) => {
108
+ /** DM: the channel IS the conversation, no thread to create. Guild: bootstrap a thread on @-mention. */
109
+ if (!m.meta.inGuild)
110
+ return routeInbound(m, discord, null);
111
+ return routeInbound(m, discord, (m, cleanText) => discord.createThreadFromMessage(m.line, m.messageId, makeThreadName(cleanText)));
112
+ };
113
+ const onTelegramInbound = (m) => {
114
+ if (!m.meta.isPrivate && !m.meta.inForum)
115
+ return Promise.resolve();
116
+ /** Forum General → bootstrap a new topic; private/topic → route into `m.line`. */
117
+ if (m.meta.inForum && !m.meta.isForumTopic)
118
+ return routeInbound(m, telegram, telegramTopicBootstrap);
119
+ return routeInbound(m, telegram, null);
120
+ };
121
+ const telegramTopicBootstrap = async (m, cleanText) => {
122
+ const topicName = makeThreadName(cleanText);
123
+ let topicLine;
124
+ try {
125
+ topicLine = await telegram.createForumTopic(m.line, topicName);
126
+ }
127
+ catch (err) {
128
+ await telegram.send(m.line, `⚠️ couldn't create topic — bot needs Manage Topics admin permission. (${errMsg(err)})`).catch(() => { });
129
+ return null;
130
+ }
131
+ /** Post a deep link back in General as a reply to the @-mention so it threads visually. */
132
+ const link = telegram.topicLink(topicLine);
133
+ if (link)
134
+ await telegram.send(m.line, `→ [${topicName}](${link})`, { replyTo: m.messageId }).catch(err => log.warn({ err: errMsg(err) }, 'telegram: failed to post topic link in General'));
135
+ return topicLine;
136
+ };
137
+ const onGithubInbound = (m) => {
138
+ /** Prepend issue/PR context so the agent has it without us bridging to a different chat. */
139
+ const header = `**@${m.meta.authorUsername}** on GitHub ${m.meta.isPR ? 'PR' : 'issue'} ${m.meta.repoFullName}#${m.meta.issueNumber} (<${m.meta.url}>):`;
140
+ return routeInbound({ ...m, text: `${header}\n\n${m.text}` }, github, null);
141
+ };
142
+ /** Strip mention syntax + normalize whitespace; cap at 100 chars (Discord limit). */
143
+ function makeThreadName(rawText) {
144
+ const cleaned = rawText.replace(/<@!?\d+>|<@&\d+>|<#\d+>|<a?:[^:]+:\d+>|@\w+/g, '').replace(/\s+/g, ' ').trim() || 'metro';
145
+ return cleaned.length <= 100 ? cleaned : cleaned.slice(0, 99) + '…';
146
+ }
147
+ async function catchupDiscord() {
148
+ for (const { line, entry } of linesForStation('discord')) {
149
+ if (!entry.lastSeenMessageId)
150
+ continue;
151
+ try {
152
+ const missed = (await discord.fetchMessagesSince(line, entry.lastSeenMessageId)).filter(m => !m.authorIsBot && m.text);
153
+ if (!missed.length)
154
+ continue;
155
+ log.info({ line, count: missed.length }, 'discord catchup');
156
+ for (const m of missed)
157
+ await onDiscordInbound({ station: 'discord', line, messageId: m.messageId, text: m.text, attachments: [], mentionsBot: false, meta: { inGuild: true } });
158
+ }
159
+ catch (err) {
160
+ log.warn({ err: errMsg(err), line }, 'discord catchup skipped');
161
+ }
162
+ }
163
+ }
164
+ async function main() {
165
+ await startAgents();
166
+ if (platforms.discord) {
167
+ await discord.start();
168
+ log.info({ bot: (await discord.getMe()).username }, 'discord ready');
169
+ discord.onMessage(m => void onDiscordInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'discord inbound failed')));
170
+ discord.onStop(triggerStop);
171
+ void catchupDiscord().catch(err => log.warn({ err: errMsg(err) }, 'discord catchup failed'));
172
+ }
173
+ if (platforms.telegram) {
174
+ log.info({ bot: `@${(await telegram.getMe()).username}` }, 'telegram ready');
175
+ telegram.onMessage(m => void onTelegramInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'telegram inbound failed')));
176
+ telegram.onStop(triggerStop);
177
+ await telegram.start();
178
+ }
179
+ if (github.isConfigured()) {
180
+ await github.start();
181
+ github.onMessage(m => void onGithubInbound(m).catch(err => log.warn({ err: errMsg(err) }, 'github inbound failed')));
182
+ }
183
+ log.info('dispatcher ready');
184
+ }
185
+ let shuttingDown = false;
186
+ async function shutdown() {
187
+ if (shuttingDown)
188
+ return;
189
+ shuttingDown = true;
190
+ log.info('dispatcher shutting down');
191
+ await Promise.allSettled([available.codex?.stop(), available.claude?.stop()]);
192
+ if (platforms.discord)
193
+ await discord.stop().catch(() => { });
194
+ if (platforms.telegram)
195
+ await telegram.stop().catch(() => { });
196
+ await github.stop().catch(() => { });
197
+ process.exit(0);
198
+ }
199
+ process.stdin.on('end', shutdown);
200
+ process.stdin.on('close', shutdown);
201
+ process.on('SIGINT', shutdown);
202
+ process.on('SIGTERM', shutdown);
203
+ await main();
@@ -0,0 +1,41 @@
1
+ /** Bridge a push-based event source (callbacks) into a pull-based AsyncIterable. */
2
+ export class AsyncQueue {
3
+ queue = [];
4
+ resolveNext = null;
5
+ done = false;
6
+ error = null;
7
+ push(item) {
8
+ if (this.done)
9
+ return;
10
+ this.queue.push(item);
11
+ this.flush();
12
+ }
13
+ finish() {
14
+ if (this.done)
15
+ return;
16
+ this.done = true;
17
+ this.flush();
18
+ }
19
+ fail(err) {
20
+ if (this.done)
21
+ return;
22
+ this.error = err;
23
+ this.done = true;
24
+ this.flush();
25
+ }
26
+ flush() { const r = this.resolveNext; this.resolveNext = null; r?.(); }
27
+ async *[Symbol.asyncIterator]() {
28
+ while (true) {
29
+ if (this.queue.length) {
30
+ yield this.queue.shift();
31
+ continue;
32
+ }
33
+ if (this.done) {
34
+ if (this.error)
35
+ throw this.error;
36
+ return;
37
+ }
38
+ await new Promise(r => { this.resolveNext = r; });
39
+ }
40
+ }
41
+ }
@@ -1,8 +1,9 @@
1
- /** Per-machine scope→{thread ids,last-used} cache. Keys: `discord:<id>` / `telegram:<chat>:<topic>`. */
1
+ /** Per-machine Line {agent threads, last-used} cache. Keys are URI lines (see docs/uri-scheme.md). */
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { errMsg, log } from '../log.js';
5
5
  import { STATE_DIR } from '../paths.js';
6
+ import { station as stationOf } from '../stations/line.js';
6
7
  const cacheFile = join(STATE_DIR, 'scopes.json');
7
8
  function read() {
8
9
  if (!existsSync(cacheFile))
@@ -23,43 +24,44 @@ function write(cache) {
23
24
  log.warn({ err: errMsg(err), path: cacheFile }, 'scope cache write failed');
24
25
  }
25
26
  }
26
- function ensure(cache, scopeKey) {
27
- if (!cache[scopeKey])
28
- cache[scopeKey] = { createdAt: new Date().toISOString(), agents: {} };
29
- if (!cache[scopeKey].agents)
30
- cache[scopeKey].agents = {};
31
- return cache[scopeKey];
27
+ function ensure(cache, line) {
28
+ if (!cache[line])
29
+ cache[line] = { createdAt: new Date().toISOString(), agents: {} };
30
+ if (!cache[line].agents)
31
+ cache[line].agents = {};
32
+ return cache[line];
32
33
  }
33
- export function getAgentThread(scopeKey, kind) {
34
- return read()[scopeKey]?.agents?.[kind];
35
- }
36
- export function setAgentThread(scopeKey, kind, threadId) {
34
+ export const getAgentThread = (line, kind) => read()[line]?.agents?.[kind];
35
+ export function setAgentThread(line, kind, threadId) {
37
36
  const cache = read();
38
- const entry = ensure(cache, scopeKey);
37
+ const entry = ensure(cache, line);
39
38
  entry.agents[kind] = threadId;
40
39
  entry.lastAgent = kind;
41
40
  write(cache);
42
41
  }
43
- export function getLastAgent(scopeKey) {
44
- return read()[scopeKey]?.lastAgent;
45
- }
46
- export function setLastAgent(scopeKey, kind) {
42
+ export const getLastAgent = (line) => read()[line]?.lastAgent;
43
+ export function setLastAgent(line, kind) {
47
44
  const cache = read();
48
- if (!cache[scopeKey])
45
+ if (!cache[line])
49
46
  return;
50
- cache[scopeKey].lastAgent = kind;
47
+ cache[line].lastAgent = kind;
51
48
  write(cache);
52
49
  }
53
- export function setLastSeen(scopeKey, messageId) {
50
+ export function setLastSeen(line, messageId) {
54
51
  const cache = read();
55
- if (!cache[scopeKey])
52
+ if (!cache[line])
56
53
  return;
57
- cache[scopeKey].lastSeenMessageId = messageId;
54
+ cache[line].lastSeenMessageId = messageId;
55
+ cache[line].lastSeenAt = new Date().toISOString();
58
56
  write(cache);
59
57
  }
60
- export function listScopes() {
61
- return Object.entries(read()).map(([scopeKey, entry]) => ({ scopeKey, entry }));
58
+ export function setName(line, name) {
59
+ const cache = read();
60
+ const entry = ensure(cache, line);
61
+ if (entry.name === name)
62
+ return;
63
+ entry.name = name;
64
+ write(cache);
62
65
  }
63
- export const discordScopeKey = (threadChannelId) => `discord:${threadChannelId}`;
64
- export const discordChannelFromScopeKey = (scopeKey) => scopeKey.startsWith('discord:') ? scopeKey.slice('discord:'.length) : null;
65
- export const telegramScopeKey = (chatId, topicId) => `telegram:${chatId}:${topicId ?? 'main'}`;
66
+ export const listLines = () => Object.entries(read()).map(([line, entry]) => ({ line: line, entry }));
67
+ export const linesForStation = (name) => listLines().filter(({ line }) => stationOf(line) === name);
@@ -39,17 +39,16 @@ export class StreamScheduler {
39
39
  }
40
40
  /** Break embedded triple backticks so they can't close our fenced block early. */
41
41
  const escapeFence = (s) => s.replace(/```/g, '`​`​`');
42
- /** Cap output by lines + chars; return `body` to embed and a `_(N more …)_` overflow note (or ''). */
42
+ /** Cap output by lines + chars; returns the truncated body and an `_(N more …)_` overflow note (or ''). */
43
43
  function truncateResult(s) {
44
44
  const lines = s.split('\n');
45
45
  let body = lines.slice(0, MAX_RESULT_LINES).join('\n');
46
- const droppedLines = Math.max(0, lines.length - MAX_RESULT_LINES);
46
+ const dropped = Math.max(0, lines.length - MAX_RESULT_LINES);
47
47
  if (body.length > MAX_RESULT_CHARS)
48
48
  body = body.slice(0, MAX_RESULT_CHARS) + '…';
49
- if (droppedLines === 0 && body === s)
49
+ if (dropped === 0 && body === s)
50
50
  return { body: s, overflow: '' };
51
- const noun = droppedLines > 0 ? `${droppedLines} more line${droppedLines === 1 ? '' : 's'}` : 'output truncated';
52
- return { body, overflow: `_(${noun})_` };
51
+ return { body, overflow: `_(${dropped > 0 ? `${dropped} more line${dropped === 1 ? '' : 's'}` : 'output truncated'})_` };
53
52
  }
54
53
  export class StreamingMessage {
55
54
  adapter;
@@ -60,6 +59,8 @@ export class StreamingMessage {
60
59
  flushing = false;
61
60
  flushAgain = false;
62
61
  finalized = false;
62
+ /** Non-null while a stop button should be shown on the last segment. */
63
+ stopId = null;
63
64
  constructor(adapter, scheduler) {
64
65
  this.adapter = adapter;
65
66
  this.scheduler = scheduler;
@@ -74,10 +75,19 @@ export class StreamingMessage {
74
75
  this.blocks.push({ kind: 'text', text: delta });
75
76
  this.scheduler.request(this);
76
77
  }
78
+ /** Transient status (Thinking…, Reasoning…). Italic+bold so it reads as ambient, not a header. */
77
79
  setStatus(status) {
78
80
  if (this.finalized)
79
81
  return;
80
- this.statusLine = status;
82
+ this.statusLine = status ? `_**${status}**_` : null;
83
+ this.scheduler.request(this);
84
+ }
85
+ /** Set/clear the stop-button id rendered on the last segment by the adapter. */
86
+ setStopId(id) {
87
+ if (this.finalized || this.stopId === id)
88
+ return;
89
+ this.stopId = id;
90
+ this.segments[this.segments.length - 1].dirty = true;
81
91
  this.scheduler.request(this);
82
92
  }
83
93
  /** Add a tool block keyed by `id`; rendered immediately as a header, output filled in via appendToolResult. */
@@ -96,11 +106,12 @@ export class StreamingMessage {
96
106
  tool.result = result;
97
107
  this.scheduler.request(this);
98
108
  }
109
+ /** "Interrupted" is a user-triggered stop — render as a calm italic+bold notice, not a `⚠️` crash. */
99
110
  appendError(message) {
100
111
  if (this.finalized)
101
112
  return;
102
113
  this.statusLine = null;
103
- this.blocks.push({ kind: 'text', text: `⚠️ ${message}` });
114
+ this.blocks.push({ kind: 'text', text: message === 'Interrupted' ? '_**Interrupted**_' : `⚠️ ${message}` });
104
115
  this.scheduler.request(this);
105
116
  }
106
117
  async finalize() {
@@ -116,7 +127,7 @@ export class StreamingMessage {
116
127
  renderBody() {
117
128
  return this.blocks.map(b => b.kind === 'text' ? b.text : this.renderToolBlock(b)).join('\n\n').trim();
118
129
  }
119
- /** Plain header + fenced input block + fenced output block. Each fence is fully visible (no collapse). */
130
+ /** Plain header + fenced input block + fenced output block (each fully visible no collapse). */
120
131
  renderToolBlock(b) {
121
132
  const parts = [`🛠 **${b.name}**`];
122
133
  if (b.detail)
@@ -131,14 +142,12 @@ export class StreamingMessage {
131
142
  }
132
143
  /** Redistribute the rendered body across segments, keeping existing segment ids stable. */
133
144
  redistribute() {
134
- const fullBody = this.renderBody();
135
- const chunks = this.chunkify(fullBody, MAX_BODY_LEN - STATUS_RESERVE);
145
+ const chunks = this.chunkify(this.renderBody(), MAX_BODY_LEN - STATUS_RESERVE);
136
146
  if (!chunks.length)
137
147
  chunks.push('');
138
148
  for (let i = 0; i < chunks.length; i++) {
139
- if (i >= this.segments.length) {
149
+ if (i >= this.segments.length)
140
150
  this.segments.push({ id: null, text: chunks[i], dirty: true });
141
- }
142
151
  else if (this.segments[i].text !== chunks[i]) {
143
152
  this.segments[i].text = chunks[i];
144
153
  this.segments[i].dirty = true;
@@ -149,10 +158,8 @@ export class StreamingMessage {
149
158
  if (s.length <= cap)
150
159
  return [s];
151
160
  const out = [];
152
- for (let r = s; r.length > 0;) {
153
- const take = r.length > cap ? this.sliceAtBoundary(r, cap) : r;
154
- out.push(take);
155
- r = r.slice(take.length);
161
+ for (let r = s; r.length > 0; r = r.slice(out.at(-1).length)) {
162
+ out.push(r.length > cap ? this.sliceAtBoundary(r, cap) : r);
156
163
  }
157
164
  return out;
158
165
  }
@@ -180,14 +187,17 @@ export class StreamingMessage {
180
187
  this.redistribute();
181
188
  for (let i = 0; i < this.segments.length; i++) {
182
189
  const s = this.segments[i];
183
- const body = this.render(s, i === this.segments.length - 1);
190
+ const isLast = i === this.segments.length - 1;
191
+ const body = this.render(s, isLast);
184
192
  if (!body)
185
193
  continue;
194
+ /** Stop button only lives on the final segment; frozen segments never carry it. */
195
+ const stopId = isLast ? this.stopId : null;
186
196
  try {
187
197
  if (s.id === null)
188
- s.id = await this.adapter.send(body);
198
+ s.id = await this.adapter.send(body, stopId);
189
199
  else if (s.dirty)
190
- await this.adapter.edit(s.id, body);
200
+ await this.adapter.edit(s.id, body, stopId);
191
201
  s.dirty = false;
192
202
  }
193
203
  catch (err) {
@@ -1,40 +1,62 @@
1
1
  /** Run a turn; stream response via adapter. In-flight follow-ups queue and drain as one combined turn. */
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { errMsg, log } from '../log.js';
3
4
  import { StreamingMessage } from './streaming.js';
4
- const inFlight = new Set();
5
- const queued = new Map();
6
- export async function runTurn(agent, threadId, text, adapter, scheduler) {
7
- const dispatch = (t) => runTurn(agent, threadId, t, adapter, scheduler);
8
- if (inFlight.has(threadId)) {
9
- const q = queued.get(threadId);
10
- if (q)
11
- q.texts.push(text);
12
- else
13
- queued.set(threadId, { texts: [text], dispatch });
5
+ /** Per-threadId active-turn state. Presence in the map means a turn is running; `texts` are queued follow-ups. */
6
+ const inFlight = new Map();
7
+ /** stopId AbortController. Populated at turn start; fired by `triggerStop` on platform button clicks. */
8
+ const stoppers = new Map();
9
+ /** Invoked by chat stations when a stop button is pressed. Returns true if a turn was actually cancelled. */
10
+ export async function triggerStop(stopId) {
11
+ const ctrl = stoppers.get(stopId);
12
+ if (!ctrl)
13
+ return false;
14
+ stoppers.delete(stopId);
15
+ ctrl.abort();
16
+ return true;
17
+ }
18
+ export async function runTurn(agent, threadId, text, attachments, adapter, scheduler) {
19
+ /** Queued follow-ups carry only text (next turn re-fetches its own attachments). */
20
+ const dispatch = (t) => runTurn(agent, threadId, t, [], adapter, scheduler);
21
+ const existing = inFlight.get(threadId);
22
+ if (existing) {
23
+ existing.texts.push(text);
14
24
  return;
15
25
  }
16
- inFlight.add(threadId);
26
+ inFlight.set(threadId, { texts: [], dispatch });
17
27
  const stream = new StreamingMessage(adapter, scheduler);
18
- const finishAndDrain = async () => {
28
+ const stopId = `stop-${randomUUID()}`;
29
+ const controller = new AbortController();
30
+ stoppers.set(stopId, controller);
31
+ stream.setStopId(stopId);
32
+ try {
33
+ for await (const ev of agent.sendTurn({ threadId, text, attachments, signal: controller.signal })) {
34
+ if (ev.type === 'delta')
35
+ stream.appendDelta(ev.text);
36
+ else if (ev.type === 'tool-start') {
37
+ if (ev.activity.transient)
38
+ stream.setStatus(ev.activity.name);
39
+ else
40
+ stream.appendToolCall(ev.activity.id, ev.activity.name, ev.activity.detail);
41
+ }
42
+ else if (ev.type === 'tool-end') {
43
+ if (ev.result)
44
+ stream.appendToolResult(ev.id, ev.result);
45
+ stream.setStatus(null);
46
+ }
47
+ }
48
+ }
49
+ catch (err) {
50
+ log.warn({ err: errMsg(err) }, 'agent turn failed');
51
+ stream.appendError(errMsg(err) || 'agent turn failed');
52
+ }
53
+ finally {
54
+ stoppers.delete(stopId);
55
+ stream.setStopId(null);
19
56
  await stream.finalize();
57
+ const entry = inFlight.get(threadId);
20
58
  inFlight.delete(threadId);
21
- const q = queued.get(threadId);
22
- if (!q?.texts.length)
23
- return;
24
- queued.delete(threadId);
25
- await q.dispatch(q.texts.join('\n\n')).catch(err => log.warn({ err: errMsg(err) }, 'queued turn failed'));
26
- };
27
- const callbacks = {
28
- onDelta: d => stream.appendDelta(d),
29
- onToolStart: a => a.transient ? stream.setStatus(a.name) : stream.appendToolCall(a.id, a.name, a.detail),
30
- onToolEnd: (id, result) => { if (result)
31
- stream.appendToolResult(id, result); stream.setStatus(null); },
32
- onComplete: () => { void finishAndDrain(); },
33
- onError: err => {
34
- log.warn({ err: errMsg(err) }, 'agent turn failed');
35
- stream.appendError(errMsg(err) || 'agent turn failed');
36
- void finishAndDrain();
37
- },
38
- };
39
- await agent.sendTurn(threadId, text, callbacks);
59
+ if (entry?.texts.length)
60
+ await entry.dispatch(entry.texts.join('\n\n')).catch(err => log.warn({ err: errMsg(err) }, 'queued turn failed'));
61
+ }
40
62
  }
package/dist/log.js CHANGED
@@ -1,6 +1,11 @@
1
- /** Pino → stderr. Stdout reserved for command output / --json. */
1
+ /** Pino → stderr. TTY pino-pretty (colorized, hostname/pid stripped); else JSON. */
2
2
  import pino from 'pino';
3
- export const log = pino({ name: 'metro', level: process.env.METRO_LOG_LEVEL || 'info' }, pino.destination(2));
3
+ import pinoPretty from 'pino-pretty';
4
+ const stream = process.stderr.isTTY
5
+ ? pinoPretty({ colorize: true, translateTime: 'HH:MM:ss', ignore: 'pid,hostname,name', destination: 2 })
6
+ : pino.destination(2);
7
+ /** `base: { name }` strips pino's default pid+hostname from JSON output too (not just from the TTY pretty stream). */
8
+ export const log = pino({ base: { name: 'metro' }, level: process.env.METRO_LOG_LEVEL || 'info' }, stream);
4
9
  export const errMsg = (err) => {
5
10
  if (err instanceof Error)
6
11
  return err.message;