@stage-labs/metro 0.1.0-beta.259 → 0.1.0-beta.260
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/dist/agent.js +128 -0
- package/dist/background.js +19 -1
- package/dist/claude.js +11 -7
- package/dist/cli.js +8 -0
- package/dist/route.js +17 -9
- package/dist/runtime-install.js +3 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +5 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/agent-fs.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +17 -120
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-activity.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner-process.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +90 -31
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-state.ts +18 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +21 -32
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +12 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +1 -6
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +52 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +121 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/ids.ts +28 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/is-record.ts +2 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/lines.ts +78 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/log.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/protocol.ts +227 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +103 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/secure-fs.ts +74 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/station-names.ts +14 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-files.ts +41 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/account-store.ts +83 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/attachments.ts +195 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-text.ts +72 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail-trust.ts +29 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/mail.ts +212 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +16 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/oauth.ts +126 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/profile.ts +58 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/sender-profile.ts +64 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/station-runtime.ts +59 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-events.ts +84 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/types.ts +125 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/str.ts +5 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +37 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/train-error.ts +53 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/version.ts +2 -0
- package/runtime/sdk-runner/runner.json +10 -0
- package/runtime/sdk-runner/src/activity-tasks.ts +142 -0
- package/runtime/sdk-runner/src/activity.ts +293 -0
- package/runtime/sdk-runner/src/app.ts +77 -0
- package/runtime/sdk-runner/src/approvals.ts +28 -0
- package/runtime/sdk-runner/src/channel-text.ts +26 -0
- package/runtime/sdk-runner/src/config.ts +50 -0
- package/runtime/sdk-runner/src/inbox.ts +94 -0
- package/runtime/sdk-runner/src/link.ts +130 -0
- package/runtime/sdk-runner/src/main.ts +44 -0
- package/runtime/sdk-runner/src/rules.ts +11 -0
- package/runtime/sdk-runner/src/runner.ts +251 -0
- package/runtime/sdk-runner/src/session-store.ts +71 -0
- package/runtime/sdk-runner/src/session-watch.ts +111 -0
- package/runtime/sdk-runner/src/singleton.ts +15 -0
- package/runtime/sdk-runner/src/speech.ts +79 -0
- package/runtime/sdk-runner/src/tool-proxy.ts +40 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Line } from './lines.js';
|
|
2
|
+
import { errMsg, log } from './log.js';
|
|
3
|
+
|
|
4
|
+
export const userSelf = (): Line =>
|
|
5
|
+
(process.env.METRO_FROM ?? 'metro://user') as Line;
|
|
6
|
+
|
|
7
|
+
export type StructuredEvent =
|
|
8
|
+
| { type: 'msg' }
|
|
9
|
+
| { type: 'react'; emoji?: string; targetId?: string }
|
|
10
|
+
| { type: 'edit'; targetId?: string }
|
|
11
|
+
| { type: 'delete'; targetId?: string }
|
|
12
|
+
| { type: 'reply'; replyTo?: string }
|
|
13
|
+
| { type: 'system'; source?: string; eventName?: string };
|
|
14
|
+
|
|
15
|
+
export interface MetroEvent {
|
|
16
|
+
id: string;
|
|
17
|
+
ts: string;
|
|
18
|
+
station: string;
|
|
19
|
+
line: Line;
|
|
20
|
+
lineName?: string;
|
|
21
|
+
from: Line;
|
|
22
|
+
fromName?: string;
|
|
23
|
+
fromDisplayName?: string;
|
|
24
|
+
to: Line;
|
|
25
|
+
text?: string;
|
|
26
|
+
messageId?: string;
|
|
27
|
+
replyTo?: string;
|
|
28
|
+
isPrivate?: boolean;
|
|
29
|
+
mentionsSelf?: boolean;
|
|
30
|
+
replyToSelf?: boolean;
|
|
31
|
+
senderVerified?: boolean;
|
|
32
|
+
payload?: unknown;
|
|
33
|
+
display?: string;
|
|
34
|
+
event?: StructuredEvent;
|
|
35
|
+
agent?: string;
|
|
36
|
+
seq?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isExternalWebhook(e: MetroEvent): boolean {
|
|
40
|
+
return e.station === 'webhook' && !Line.isLocal(e.from);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function webhookEventName(e: MetroEvent): string | undefined {
|
|
44
|
+
const headers = (e.payload as { headers?: Record<string, string> } | undefined)
|
|
45
|
+
?.headers;
|
|
46
|
+
return headers?.['x-github-event'] ?? headers?.['x-intercom-topic'];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyEvent(e: MetroEvent): StructuredEvent {
|
|
50
|
+
if (isExternalWebhook(e)) {
|
|
51
|
+
return {
|
|
52
|
+
type: 'system',
|
|
53
|
+
source: 'webhook',
|
|
54
|
+
eventName: webhookEventName(e),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const emoji =
|
|
58
|
+
(e.payload as { emoji?: string } | undefined)?.emoji ??
|
|
59
|
+
e.text?.match(/^\[react (.+)\]$/)?.[1];
|
|
60
|
+
if (emoji) return { type: 'react', emoji, targetId: e.replyTo };
|
|
61
|
+
if (e.replyTo) return { type: 'reply', replyTo: e.replyTo };
|
|
62
|
+
return { type: 'msg' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatDisplay(e: MetroEvent): string {
|
|
66
|
+
const headerFor = (icon: string, parts: (string | undefined)[]): string =>
|
|
67
|
+
`**${icon} ${parts.filter(Boolean).join(' · ')}**`;
|
|
68
|
+
const body = e.text ?? '';
|
|
69
|
+
if (isExternalWebhook(e)) {
|
|
70
|
+
const ev = webhookEventName(e);
|
|
71
|
+
return `${headerFor('🪝', ['webhook', e.lineName, ev])}\n> ${body}`;
|
|
72
|
+
}
|
|
73
|
+
if (Line.isLocal(e.from)) {
|
|
74
|
+
return `${headerFor('📤', [e.station, '→', e.fromName ?? e.to])}\n> ${body}`;
|
|
75
|
+
}
|
|
76
|
+
return `${headerFor('📩', [e.station, e.fromName ?? e.from, e.lineName])}\n> ${body}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { mintId } from './ids.js';
|
|
80
|
+
|
|
81
|
+
export type BusListener = (event: MetroEvent, busSeq: number) => void;
|
|
82
|
+
|
|
83
|
+
export interface BufferedEvent {
|
|
84
|
+
busSeq: number;
|
|
85
|
+
event: MetroEvent;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const BUS_BUFFER_MAX = 500;
|
|
89
|
+
|
|
90
|
+
const listeners = new Set<BusListener>();
|
|
91
|
+
const buffer: BufferedEvent[] = [];
|
|
92
|
+
let busSeq = 0;
|
|
93
|
+
|
|
94
|
+
export function publishEvent(event: MetroEvent): void {
|
|
95
|
+
busSeq += 1;
|
|
96
|
+
const seq = busSeq;
|
|
97
|
+
buffer.push({ busSeq: seq, event });
|
|
98
|
+
if (buffer.length > BUS_BUFFER_MAX) buffer.shift();
|
|
99
|
+
for (const fn of [...listeners]) {
|
|
100
|
+
try {
|
|
101
|
+
fn(event, seq);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
log.error({ busSeq: seq, err: errMsg(err) }, 'bus: a listener threw');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function currentBusSeq(): number {
|
|
109
|
+
return busSeq;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function bufferedSince(afterBusSeq: number): BufferedEvent[] {
|
|
113
|
+
return buffer.filter((b) => b.busSeq > afterBusSeq);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function subscribeEvents(fn: BusListener): () => void {
|
|
117
|
+
listeners.add(fn);
|
|
118
|
+
return () => {
|
|
119
|
+
listeners.delete(fn);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const ID_BYTES = 8;
|
|
4
|
+
const REROLLS = 16;
|
|
5
|
+
const APPROVAL_LETTERS = 'abcdefghijkmnopqrstuvwxyz';
|
|
6
|
+
const APPROVAL_LENGTH = 5;
|
|
7
|
+
|
|
8
|
+
export const approvalId = (): string => Array.from({ length: APPROVAL_LENGTH }, () => APPROVAL_LETTERS[randomInt(APPROVAL_LETTERS.length)]).join('');
|
|
9
|
+
|
|
10
|
+
export const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{10}$/;
|
|
11
|
+
|
|
12
|
+
export const mintId = (): string =>
|
|
13
|
+
`msg_${randomBytes(6).toString('base64url')}`;
|
|
14
|
+
|
|
15
|
+
export function newId(): string {
|
|
16
|
+
for (let attempt = 0; attempt < REROLLS; attempt += 1) {
|
|
17
|
+
const id = randomBytes(ID_BYTES).toString('base64url');
|
|
18
|
+
if (ID_RE.test(id)) return id;
|
|
19
|
+
}
|
|
20
|
+
throw new Error('could not generate an id');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseId(raw: unknown): string | null {
|
|
24
|
+
if (typeof raw !== 'string') return null;
|
|
25
|
+
return ID_RE.test(raw) ? raw : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{1,31}$/;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type Line = string & { readonly __line: unique symbol };
|
|
2
|
+
export const asLine = (s: string): Line => s as Line;
|
|
3
|
+
|
|
4
|
+
const PREFIX = 'metro://';
|
|
5
|
+
const build = (station: string, ...seg: (string | number)[]): Line =>
|
|
6
|
+
asLine(`${PREFIX}${station}/${seg.map(String).join('/')}`);
|
|
7
|
+
|
|
8
|
+
function parseAccountScoped(
|
|
9
|
+
line: Line | string,
|
|
10
|
+
station: string,
|
|
11
|
+
validate?: (resource: string) => boolean,
|
|
12
|
+
): { accountId: string; resource: string } | null {
|
|
13
|
+
const p = Line.parse(line);
|
|
14
|
+
if (p?.station !== station || p.path.length !== 2) return null;
|
|
15
|
+
const [accountId, resource] = p.path;
|
|
16
|
+
if (accountId === undefined || resource === undefined) return null;
|
|
17
|
+
if (validate && !validate(resource)) return null;
|
|
18
|
+
return { accountId, resource };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isSnowflake = (s: string): boolean => /^\d+$/.test(s);
|
|
22
|
+
const isThreemaResource = (s: string): boolean =>
|
|
23
|
+
/^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})(?:-[0-9a-f]{16})?$/i.test(s);
|
|
24
|
+
const isMailResource = (s: string): boolean => /^[A-Za-z0-9=_%.@+-]+$/.test(s);
|
|
25
|
+
const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
|
|
26
|
+
|
|
27
|
+
const topicPart = (topicId: string | undefined): { topicId?: number } | null => {
|
|
28
|
+
if (topicId === undefined) return {};
|
|
29
|
+
return isSnowflake(topicId) ? { topicId: Number(topicId) } : null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function parseTelegramLine(
|
|
33
|
+
line: Line | string,
|
|
34
|
+
): { accountId: string; chatId: number; topicId?: number } | null {
|
|
35
|
+
const p = Line.parse(line);
|
|
36
|
+
if (p?.station !== 'telegram-bot') return null;
|
|
37
|
+
const [accountId, chatId, topicId, ...extra] = p.path;
|
|
38
|
+
if (accountId === undefined || chatId === undefined || extra.length > 0) return null;
|
|
39
|
+
if (!isSignedInt(chatId)) return null;
|
|
40
|
+
const topic = topicPart(topicId);
|
|
41
|
+
return topic === null ? null : { accountId, chatId: Number(chatId), ...topic };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Line = {
|
|
45
|
+
claude: (orgId: string, sessionId: string): Line =>
|
|
46
|
+
build('claude', orgId, sessionId),
|
|
47
|
+
webhook: (endpointId: string): Line => build('webhook', endpointId),
|
|
48
|
+
user: (station: string, id: string | number): Line =>
|
|
49
|
+
build(station, 'user', id),
|
|
50
|
+
|
|
51
|
+
parse(line: Line | string): { station: string; path: string[] } | null {
|
|
52
|
+
if (!line.startsWith(PREFIX)) return null;
|
|
53
|
+
const rest = line.slice(PREFIX.length);
|
|
54
|
+
const slash = rest.indexOf('/');
|
|
55
|
+
if (slash <= 0) return null;
|
|
56
|
+
const path = rest
|
|
57
|
+
.slice(slash + 1)
|
|
58
|
+
.split('/')
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
return path.length ? { station: rest.slice(0, slash), path } : null;
|
|
61
|
+
},
|
|
62
|
+
station: (line: Line | string): string | null =>
|
|
63
|
+
Line.parse(line)?.station ?? null,
|
|
64
|
+
isLocal: (line: Line | string): boolean => Line.station(line) === 'claude',
|
|
65
|
+
|
|
66
|
+
parseXmtp: (line: Line | string) => parseAccountScoped(line, 'xmtp'),
|
|
67
|
+
|
|
68
|
+
parseDiscord: (line: Line | string) =>
|
|
69
|
+
parseAccountScoped(line, 'discord-bot', isSnowflake),
|
|
70
|
+
|
|
71
|
+
parseTelegram: (line: Line | string) => parseTelegramLine(line),
|
|
72
|
+
|
|
73
|
+
parseThreema: (line: Line | string) =>
|
|
74
|
+
parseAccountScoped(line, 'threema', isThreemaResource),
|
|
75
|
+
|
|
76
|
+
parseMail: (line: Line | string, station: 'outlook' | 'gmail') =>
|
|
77
|
+
parseAccountScoped(line, station, isMailResource),
|
|
78
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { writeSync } from 'node:fs';
|
|
2
|
+
import pino from 'pino';
|
|
3
|
+
import pinoPretty from 'pino-pretty';
|
|
4
|
+
|
|
5
|
+
const stream = process.stderr.isTTY
|
|
6
|
+
? pinoPretty({
|
|
7
|
+
colorize: true,
|
|
8
|
+
translateTime: 'HH:MM:ss',
|
|
9
|
+
ignore: 'pid,hostname,name',
|
|
10
|
+
destination: 2,
|
|
11
|
+
})
|
|
12
|
+
: pino.destination(2);
|
|
13
|
+
|
|
14
|
+
export const log = pino(
|
|
15
|
+
{ base: { name: 'metro' }, level: process.env.METRO_LOG_LEVEL ?? 'info' },
|
|
16
|
+
stream,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export const logFatalSync = (
|
|
20
|
+
fields: Record<string, unknown>,
|
|
21
|
+
msg: string,
|
|
22
|
+
): void => {
|
|
23
|
+
const line = JSON.stringify({
|
|
24
|
+
level: 60,
|
|
25
|
+
time: Date.now(),
|
|
26
|
+
name: 'metro',
|
|
27
|
+
...fields,
|
|
28
|
+
msg,
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
writeSync(2, `${line}\n`);
|
|
32
|
+
} catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const errMsg = (err: unknown): string => {
|
|
38
|
+
if (err instanceof Error) return err.message;
|
|
39
|
+
if (err && typeof err === 'object' && 'message' in err)
|
|
40
|
+
return String(err.message);
|
|
41
|
+
return String(err);
|
|
42
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, parse as parsePath } from 'node:path';
|
|
3
|
+
import { errMsg, log } from './log.js';
|
|
4
|
+
import { coerceErrorInfo, type TrainErrorInfo } from './train-error.js';
|
|
5
|
+
import type { StructuredEvent } from './events.js';
|
|
6
|
+
import type { CallMsg } from './stations/station-runtime.js';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
TrainError,
|
|
10
|
+
serializeTrainError,
|
|
11
|
+
type TrainErrorInfo,
|
|
12
|
+
} from './train-error.js';
|
|
13
|
+
|
|
14
|
+
const CALL_TIMEOUT_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
export interface Pending {
|
|
17
|
+
resolve: (r: TrainCallResponse) => void;
|
|
18
|
+
reject: (err: Error) => void;
|
|
19
|
+
timer: ReturnType<typeof setTimeout>;
|
|
20
|
+
}
|
|
21
|
+
export interface CallTarget {
|
|
22
|
+
name: string;
|
|
23
|
+
pending: Map<string, Pending>;
|
|
24
|
+
proc: ({ stdin?: unknown } & Record<string, unknown>) | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STDOUT_LINE_MAX = 4 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
export type TrainEvent = {
|
|
30
|
+
station?: string;
|
|
31
|
+
line?: string;
|
|
32
|
+
line_name?: string;
|
|
33
|
+
from?: string;
|
|
34
|
+
from_name?: string;
|
|
35
|
+
from_display_name?: string;
|
|
36
|
+
to?: string;
|
|
37
|
+
message_id?: string;
|
|
38
|
+
reply_to?: string;
|
|
39
|
+
is_private?: boolean;
|
|
40
|
+
mentions_self?: boolean;
|
|
41
|
+
reply_to_self?: boolean;
|
|
42
|
+
sender_verified?: boolean;
|
|
43
|
+
text?: string;
|
|
44
|
+
emoji?: string;
|
|
45
|
+
payload?: unknown;
|
|
46
|
+
ts?: string;
|
|
47
|
+
id?: string;
|
|
48
|
+
event?: StructuredEvent;
|
|
49
|
+
} & Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
export interface TrainCallResponse {
|
|
52
|
+
result?: unknown;
|
|
53
|
+
error?: string;
|
|
54
|
+
errorInfo?: TrainErrorInfo;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TrainMessage =
|
|
58
|
+
| {
|
|
59
|
+
op: 'response';
|
|
60
|
+
id: string;
|
|
61
|
+
result?: unknown;
|
|
62
|
+
error?: string;
|
|
63
|
+
errorInfo?: TrainErrorInfo;
|
|
64
|
+
}
|
|
65
|
+
| { op: 'log'; text?: string }
|
|
66
|
+
| { op: 'event'; event: TrainEvent }
|
|
67
|
+
| { op: 'ignore' };
|
|
68
|
+
|
|
69
|
+
export function parseTrainLine(
|
|
70
|
+
name: string,
|
|
71
|
+
line: string,
|
|
72
|
+
): TrainMessage | null {
|
|
73
|
+
let msg: {
|
|
74
|
+
op?: string;
|
|
75
|
+
id?: string;
|
|
76
|
+
result?: unknown;
|
|
77
|
+
error?: string;
|
|
78
|
+
errorInfo?: unknown;
|
|
79
|
+
text?: string;
|
|
80
|
+
} & Record<string, unknown>;
|
|
81
|
+
try {
|
|
82
|
+
msg = JSON.parse(line) as typeof msg;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
log.warn(
|
|
85
|
+
{ name, err: errMsg(err), line: line.slice(0, 200) },
|
|
86
|
+
'train: bad JSON',
|
|
87
|
+
);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (msg.op === 'response') {
|
|
91
|
+
if (typeof msg.id !== 'string') return { op: 'ignore' };
|
|
92
|
+
return {
|
|
93
|
+
op: 'response',
|
|
94
|
+
id: msg.id,
|
|
95
|
+
result: msg.result,
|
|
96
|
+
error: msg.error,
|
|
97
|
+
errorInfo: coerceErrorInfo(msg.errorInfo),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (msg.op === 'log') return { op: 'log', text: msg.text };
|
|
101
|
+
if (typeof msg.line !== 'string') {
|
|
102
|
+
log.warn(
|
|
103
|
+
{ name, line: line.slice(0, 200) },
|
|
104
|
+
'train: event missing `line` (string) — dropped',
|
|
105
|
+
);
|
|
106
|
+
return { op: 'ignore' };
|
|
107
|
+
}
|
|
108
|
+
return { op: 'event', event: msg };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function drainLines(
|
|
112
|
+
name: string,
|
|
113
|
+
buf: string,
|
|
114
|
+
onLine: (line: string) => void,
|
|
115
|
+
): string {
|
|
116
|
+
if (buf.length > STDOUT_LINE_MAX && !buf.includes('\n')) {
|
|
117
|
+
log.warn(
|
|
118
|
+
{ name, bytes: buf.length },
|
|
119
|
+
'train: dropping oversized stdout line',
|
|
120
|
+
);
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
let nl;
|
|
124
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
125
|
+
const line = buf.slice(0, nl).trim();
|
|
126
|
+
buf = buf.slice(nl + 1);
|
|
127
|
+
if (!line) continue;
|
|
128
|
+
onLine(line);
|
|
129
|
+
}
|
|
130
|
+
return buf;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function readCalls(
|
|
134
|
+
name: string,
|
|
135
|
+
handleCall: (msg: CallMsg) => Promise<void>,
|
|
136
|
+
): void {
|
|
137
|
+
let buf = '';
|
|
138
|
+
process.stdin.setEncoding('utf8');
|
|
139
|
+
process.stdin.on('data', (chunk: Buffer | string) => {
|
|
140
|
+
buf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
141
|
+
buf = drainLines(name, buf, (line) => {
|
|
142
|
+
try {
|
|
143
|
+
const msg = JSON.parse(line) as Partial<CallMsg>;
|
|
144
|
+
if (msg.op === 'call')
|
|
145
|
+
handleCall(msg as CallMsg).catch((e: unknown) => {
|
|
146
|
+
process.stderr.write(`call failed: ${errMsg(e)}\n`);
|
|
147
|
+
});
|
|
148
|
+
} catch (err: unknown) {
|
|
149
|
+
process.stderr.write(`bad stdin line: ${errMsg(err)}\n`);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function encodeCall(id: string, action: string, args: unknown): string {
|
|
156
|
+
return JSON.stringify({ op: 'call', id, action, args }) + '\n';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isTrainFile(name: string): boolean {
|
|
160
|
+
return (
|
|
161
|
+
/\.(ts|js|mjs)$/.test(name) &&
|
|
162
|
+
!name.startsWith('_') &&
|
|
163
|
+
!name.startsWith('.')
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listTrainFiles(dir: string): { name: string; path: string }[] {
|
|
168
|
+
return readdirSync(dir)
|
|
169
|
+
.filter(isTrainFile)
|
|
170
|
+
.map((f) => ({ name: parsePath(f).name, path: join(dir, f) }))
|
|
171
|
+
.filter((t) => {
|
|
172
|
+
try {
|
|
173
|
+
return statSync(t.path).isFile();
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function mintCallId(seq: number): string {
|
|
181
|
+
return `req_${seq}_${Math.random().toString(36).slice(2, 8)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function failAllPending(
|
|
185
|
+
pending: Map<string, Pending>,
|
|
186
|
+
reason: string,
|
|
187
|
+
): void {
|
|
188
|
+
for (const p of pending.values()) {
|
|
189
|
+
clearTimeout(p.timer);
|
|
190
|
+
p.reject(new Error(reason));
|
|
191
|
+
}
|
|
192
|
+
pending.clear();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function sendCall(
|
|
196
|
+
t: CallTarget,
|
|
197
|
+
id: string,
|
|
198
|
+
action: string,
|
|
199
|
+
args: unknown,
|
|
200
|
+
): Promise<TrainCallResponse> {
|
|
201
|
+
return new Promise<TrainCallResponse>((resolve, reject) => {
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
t.pending.delete(id);
|
|
204
|
+
reject(
|
|
205
|
+
new Error(
|
|
206
|
+
`train '${t.name}' call '${action}' timed out after ${CALL_TIMEOUT_MS}ms`,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
}, CALL_TIMEOUT_MS);
|
|
210
|
+
t.pending.set(id, { resolve, reject, timer });
|
|
211
|
+
try {
|
|
212
|
+
const stdin = (
|
|
213
|
+
t.proc as {
|
|
214
|
+
stdin?: { write: (s: string) => void; flush: () => void };
|
|
215
|
+
} | null
|
|
216
|
+
)?.stdin;
|
|
217
|
+
if (!stdin || typeof stdin === 'number')
|
|
218
|
+
throw new Error('stdin not piped');
|
|
219
|
+
stdin.write(encodeCall(id, action, args));
|
|
220
|
+
stdin.flush();
|
|
221
|
+
} catch (err) {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
t.pending.delete(id);
|
|
224
|
+
reject(new Error(`train '${t.name}' stdin write failed: ${errMsg(err)}`));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { isRecord } from './is-record.js';
|
|
2
|
+
|
|
3
|
+
const PHASES = ['starting', 'idle', 'working', 'approval', 'compacting', 'stopped', 'error'] as const;
|
|
4
|
+
const TASK_STATES = ['pending', 'running', 'completed', 'failed', 'stopped', 'paused', 'unknown'] as const;
|
|
5
|
+
const EVENTS = ['turn_started', 'turn_finished', 'turn_failed', 'tool_started', 'tool_finished', 'tool_failed', 'task_started', 'task_completed', 'task_failed', 'task_stopped', 'task_updated', 'compacting', 'compacted', 'compact_failed', 'api_retry', 'approval_waiting', 'approval_ended', 'permission_denied', 'session_failed'] as const;
|
|
6
|
+
export type RunnerPhase = (typeof PHASES)[number];
|
|
7
|
+
export type RunnerTaskState = (typeof TASK_STATES)[number];
|
|
8
|
+
export type RunnerEventKind = (typeof EVENTS)[number];
|
|
9
|
+
|
|
10
|
+
export interface RunnerTask {
|
|
11
|
+
id: string;
|
|
12
|
+
kind: string | null;
|
|
13
|
+
agent: string | null;
|
|
14
|
+
status: RunnerTaskState;
|
|
15
|
+
background: boolean;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
endedAt: number | null;
|
|
19
|
+
lastTool: string | null;
|
|
20
|
+
toolUses: number;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RunnerTool {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
taskId: string | null;
|
|
28
|
+
worker?: boolean;
|
|
29
|
+
startedAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunnerEvent {
|
|
33
|
+
at: number;
|
|
34
|
+
kind: RunnerEventKind;
|
|
35
|
+
tool: string | null;
|
|
36
|
+
taskId: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RunnerActivity {
|
|
40
|
+
pid: number;
|
|
41
|
+
procStart?: string;
|
|
42
|
+
runner: 'sdk';
|
|
43
|
+
phase: RunnerPhase;
|
|
44
|
+
mainPhase: RunnerPhase;
|
|
45
|
+
mainStartedAt: number | null;
|
|
46
|
+
sessionId: string | null;
|
|
47
|
+
updatedAt: number;
|
|
48
|
+
pending: number;
|
|
49
|
+
workers: number;
|
|
50
|
+
approvals: number;
|
|
51
|
+
tools: string[];
|
|
52
|
+
activeTools: RunnerTool[];
|
|
53
|
+
tasks: RunnerTask[];
|
|
54
|
+
events: RunnerEvent[];
|
|
55
|
+
lastError: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const count = (value: unknown): number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
59
|
+
const phase = (value: unknown): value is RunnerPhase => PHASES.some((p) => p === value);
|
|
60
|
+
const taskState = (value: unknown): value is RunnerTaskState => TASK_STATES.some((s) => s === value);
|
|
61
|
+
const eventKind = (value: unknown): value is RunnerEventKind => EVENTS.some((e) => e === value);
|
|
62
|
+
const name = (value: unknown): string | null => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) ? value : null;
|
|
63
|
+
const sessionId = (value: unknown): string | null => typeof value === 'string' && /^[0-9a-f-]{36}$/.test(value) ? value : null;
|
|
64
|
+
|
|
65
|
+
function task(raw: unknown): RunnerTask | null {
|
|
66
|
+
if (!isRecord(raw) || !name(raw.id) || !taskState(raw.status)) return null;
|
|
67
|
+
return {
|
|
68
|
+
id: String(raw.id), kind: name(raw.kind), agent: name(raw.agent), status: raw.status, background: raw.background === true,
|
|
69
|
+
startedAt: count(raw.startedAt), updatedAt: count(raw.updatedAt), endedAt: count(raw.endedAt) || null,
|
|
70
|
+
lastTool: name(raw.lastTool), toolUses: count(raw.toolUses), durationMs: count(raw.durationMs),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tool(raw: unknown): RunnerTool | null {
|
|
75
|
+
if (!isRecord(raw) || !name(raw.id) || !name(raw.name)) return null;
|
|
76
|
+
return { id: String(raw.id), name: String(raw.name), taskId: name(raw.taskId), startedAt: count(raw.startedAt), ...(typeof raw.worker === 'boolean' ? { worker: raw.worker } : {}) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function event(raw: unknown): RunnerEvent | null {
|
|
80
|
+
if (!isRecord(raw) || !eventKind(raw.kind) || count(raw.at) === 0) return null;
|
|
81
|
+
return { at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function list<T>(raw: unknown, parse: (item: unknown) => T | null, max: number): T[] {
|
|
85
|
+
if (!Array.isArray(raw)) return [];
|
|
86
|
+
return raw.map(parse).filter((item): item is T => item !== null).slice(0, max);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function processIdentity(value: unknown): Pick<RunnerActivity, 'procStart'> {
|
|
90
|
+
return typeof value === 'string' && /^\d{1,32}$/.test(value) ? { procStart: value } : {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
|
|
94
|
+
if (!isRecord(raw) || raw.runner !== 'sdk' || !phase(raw.phase) || count(raw.pid) === 0 || count(raw.updatedAt) === 0) return null;
|
|
95
|
+
return {
|
|
96
|
+
runner: 'sdk', pid: count(raw.pid), ...processIdentity(raw.procStart), phase: raw.phase, mainPhase: phase(raw.mainPhase) ? raw.mainPhase : raw.phase,
|
|
97
|
+
mainStartedAt: count(raw.mainStartedAt) || null, sessionId: sessionId(raw.sessionId), updatedAt: count(raw.updatedAt),
|
|
98
|
+
pending: count(raw.pending), workers: count(raw.workers), approvals: count(raw.approvals),
|
|
99
|
+
tools: list(raw.tools, name, 20), activeTools: list(raw.activeTools, tool, 20),
|
|
100
|
+
tasks: list(raw.tasks, task, 30), events: list(raw.events, event, 40),
|
|
101
|
+
lastError: typeof raw.lastError === 'string' ? raw.lastError.slice(0, 300) : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
import { errMsg, log } from './log.js';
|
|
12
|
+
|
|
13
|
+
export function ensureSecureDir(dir: string): void {
|
|
14
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
15
|
+
try {
|
|
16
|
+
chmodSync(dir, 0o700);
|
|
17
|
+
} catch {
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function chmodIfExists(path: string, mode = 0o600): void {
|
|
22
|
+
if (!existsSync(path)) return;
|
|
23
|
+
try {
|
|
24
|
+
if ((statSync(path).mode & 0o777) !== mode) chmodSync(path, mode);
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeSecure(path: string, data: string): void {
|
|
30
|
+
ensureSecureDir(dirname(path));
|
|
31
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
32
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
33
|
+
try {
|
|
34
|
+
chmodSync(tmp, 0o600);
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
renameSync(tmp, path);
|
|
38
|
+
chmodIfExists(path, 0o600);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function modeOf(path: string): number | null {
|
|
42
|
+
try {
|
|
43
|
+
return statSync(path).mode & 0o777;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeAtomic(path: string, data: string, mode?: number): void {
|
|
50
|
+
const chosen = mode ?? modeOf(path) ?? 0o644;
|
|
51
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
52
|
+
const tmp = `${path}.metro-${String(process.pid)}.tmp`;
|
|
53
|
+
writeFileSync(tmp, data, { mode: chosen });
|
|
54
|
+
chmodSync(tmp, chosen);
|
|
55
|
+
renameSync(tmp, path);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readJson<T>(
|
|
59
|
+
path: string,
|
|
60
|
+
fallback: T,
|
|
61
|
+
opts?: { warn?: string },
|
|
62
|
+
): T {
|
|
63
|
+
if (!existsSync(path)) return fallback;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (opts?.warn) log.warn({ err: errMsg(err), path }, opts.warn);
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function writeJson(path: string, value: unknown): void {
|
|
73
|
+
writeFileSync(path, JSON.stringify(value, null, 2));
|
|
74
|
+
}
|