@stage-labs/metro 0.1.0-beta.307 → 0.1.0-beta.308

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 (60) hide show
  1. package/dist/agent.js +23 -28
  2. package/dist/child.js +33 -0
  3. package/dist/claude-env.js +104 -0
  4. package/dist/cli.js +7 -21
  5. package/dist/lock.js +62 -0
  6. package/dist/memory.js +12 -27
  7. package/dist/root-release-io.js +1 -2
  8. package/dist/root-release.js +1 -1
  9. package/dist/route.js +0 -1
  10. package/hooks/guard.mjs +1 -5
  11. package/hooks/session-start.mjs +1 -1
  12. package/package.json +1 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +13 -38
  14. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +21 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -8
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -18
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/github-skills.ts +3 -7
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +6 -5
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +1 -23
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +32 -112
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup-api.ts +14 -61
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +10 -42
  23. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +2 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +0 -1
  25. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +1 -13
  26. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +15 -35
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -2
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +5 -15
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +0 -11
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +3 -7
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -24
  32. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -4
  33. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +1 -2
  34. package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +9 -92
  35. package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +1 -4
  36. package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +1 -1
  37. package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +0 -2
  38. package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +0 -4
  39. package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +1 -14
  40. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +0 -1
  41. package/runtime/runtime.json +1 -1
  42. package/runtime/sdk-runner/runner.json +1 -1
  43. package/dist/background.js +0 -141
  44. package/dist/claude.js +0 -204
  45. package/dist/mcp-config.js +0 -27
  46. package/dist/native-recovery-scan.js +0 -246
  47. package/dist/native-recovery-state.js +0 -154
  48. package/dist/native-recovery.js +0 -164
  49. package/dist/onboarding.js +0 -44
  50. package/dist/voice.js +0 -56
  51. package/runtime/node_modules/@metro-labs/daemon/src/claude/channel-check.ts +0 -56
  52. package/runtime/node_modules/@metro-labs/daemon/src/claude/pinned.ts +0 -16
  53. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +0 -121
  54. package/runtime/node_modules/@metro-labs/daemon/src/claude/runner.ts +0 -45
  55. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-continuity.ts +0 -57
  56. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +0 -76
  57. package/runtime/node_modules/@metro-labs/daemon/src/voice/approvals.ts +0 -38
  58. package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +0 -146
  59. package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +0 -54
  60. package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +0 -264
@@ -1,20 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { CallRoute } from '@metro-labs/core/call';
3
3
  import { errMsg, log } from '@metro-labs/core/log';
4
- import { isRecord } from '@metro-labs/core/is-record';
5
4
  import { forwardTrainCall } from '../stations/train-call.js';
6
- import { CallApprovals } from './approvals.js';
7
5
  import type { Playback } from './audio.js';
8
6
  import { Peer, type PeerEvents } from './peer.js';
9
7
  import { SharedTalk } from './shared-talk.js';
10
- import { endSharedCall, hearSharedCall, openSharedCall, sharedCallsSelected, type SharedCallTransport } from './shared.js';
11
- import { Talk } from './talk.js';
8
+ import { endSharedCall, hearSharedCall, openSharedCall, type SharedCallTransport } from './shared.js';
12
9
  import type { VoiceConfig } from './store.js';
13
10
 
14
11
  const OFFER_WAIT_MS = 45_000;
15
12
  const LONGEST_CALL_MS = 60 * 60_000;
16
- const CONTEXT_MESSAGES = 12;
17
- const CONTEXT_CHARS = 400;
18
13
  const LEAVE_RETRY_MS = 3_000;
19
14
  const SIGNAL_WAIT_MS = 2_000;
20
15
 
@@ -22,24 +17,18 @@ export interface CallStart {
22
17
  agentId: string;
23
18
  sourceId: string;
24
19
  line: string;
25
- lineName: string;
26
- direct: boolean;
27
20
  from: string;
28
- callerName: string;
29
21
  callId: string;
30
22
  callerPeer: string;
31
23
  }
32
24
 
33
- type CallAudio = Pick<Talk, 'connect' | 'hear' | 'finish'>;
25
+ type CallAudio = Pick<SharedTalk, 'connect' | 'hear' | 'finish'> & SharedCallTransport;
34
26
  type CallPeer = Pick<Peer, 'answer' | 'sendOpus' | 'close'>;
35
- type ContextStart = Omit<CallStart, 'agentId' | 'sourceId'>;
36
27
 
37
28
  export interface CallDependencies {
38
29
  train: typeof trainCall;
39
30
  peer(events: PeerEvents): CallPeer;
40
- cli(cfg: VoiceConfig, out: Playback['send'], ended: (reason: string) => void, approvals: CallApprovals): CallAudio & Pick<Talk, 'prime'>;
41
- shared(cfg: VoiceConfig, out: Playback['send'], ended: (reason: string) => void, heard: (text: string, sourceId: string) => void): CallAudio & SharedCallTransport;
42
- selected: typeof sharedCallsSelected;
31
+ shared(cfg: VoiceConfig, out: Playback['send'], ended: (reason: string) => void, heard: (text: string, sourceId: string) => void): CallAudio;
43
32
  open: typeof openSharedCall;
44
33
  heard: typeof hearSharedCall;
45
34
  ended: typeof endSharedCall;
@@ -47,8 +36,6 @@ export interface CallDependencies {
47
36
  leaveRetryMs: number;
48
37
  }
49
38
 
50
- const str = (value: unknown): string => (typeof value === 'string' ? value : '');
51
-
52
39
  export async function trainCall(action: string, args: Record<string, unknown>): Promise<unknown> {
53
40
  const answer = await forwardTrainCall('xmtp', action, args);
54
41
  if (answer.error !== undefined) throw new Error(answer.error);
@@ -58,9 +45,7 @@ export async function trainCall(action: string, args: Record<string, unknown>):
58
45
  const dependencies: CallDependencies = {
59
46
  train: trainCall,
60
47
  peer: (events) => new Peer(events),
61
- cli: (cfg, out, ended, approvals) => new Talk(cfg, out, ended, approvals),
62
48
  shared: (cfg, out, ended, heard) => new SharedTalk(cfg, out, ended, heard),
63
- selected: sharedCallsSelected,
64
49
  open: openSharedCall,
65
50
  heard: hearSharedCall,
66
51
  ended: endSharedCall,
@@ -68,51 +53,11 @@ const dependencies: CallDependencies = {
68
53
  leaveRetryMs: LEAVE_RETRY_MS,
69
54
  };
70
55
 
71
- function speaker(row: Record<string, unknown>, start: ContextStart): string {
72
- if (row.self === true) return 'you';
73
- return row.from === start.from ? start.callerName : `another member (${str(row.from)})`;
74
- }
75
-
76
- function contextLine(row: unknown, start: ContextStart): string | null {
77
- if (!isRecord(row) || typeof row.text !== 'string' || row.text.startsWith('[call')) return null;
78
- const who = speaker(row, start);
79
- return `${str(row.ts).slice(11, 16)} ${who}: ${row.text.replace(/\s+/g, ' ').slice(0, CONTEXT_CHARS)}`;
80
- }
81
-
82
- async function recentChat(start: CallStart, train: typeof trainCall): Promise<string> {
83
- try {
84
- const result = await train('read', { line: start.line, limit: CONTEXT_MESSAGES });
85
- const rows = isRecord(result) && Array.isArray(result.messages) ? result.messages : [];
86
- return rows.map((row) => contextLine(row, start)).filter((l): l is string => l !== null).join('\n');
87
- } catch (err) {
88
- log.warn({ err: errMsg(err) }, 'voice: could not read the chat before the call');
89
- return '';
90
- }
91
- }
92
-
93
- function where(start: ContextStart): string {
94
- if (start.lineName !== '') return `the group chat "${start.lineName}"`;
95
- return start.direct ? `your direct chat with ${start.callerName}` : 'a group chat with no name';
96
- }
97
-
98
- export function opening(start: ContextStart, chat: string): string {
99
- const recent = chat === '' ? 'No earlier messages were found there.' : `Recent messages there before the call, oldest first (times in UTC):\n${chat}`;
100
- return [
101
- `[A voice call with ${start.callerName} is starting on Stage, in ${where(start)}.`,
102
- `Line of this chat: ${start.line} (pass it verbatim to the metro tools, e.g. read, to look at or act on this chat).`,
103
- `Caller: ${start.callerName} (${start.from}).`,
104
- recent,
105
- `Greet ${start.callerName} in a few words.]`,
106
- ].join('\n');
107
- }
108
-
109
56
  export class Call {
110
57
  readonly route: CallRoute;
111
58
  private readonly selfPeer = randomUUID();
112
59
  private readonly startedAt = Date.now();
113
60
  private readonly talk: CallAudio;
114
- private readonly shared: SharedCallTransport | null;
115
- private readonly prime: ((context: string) => void) | null;
116
61
  private readonly deps: CallDependencies;
117
62
  private peer: CallPeer | null = null;
118
63
  private timer: ReturnType<typeof setTimeout> | null = null;
@@ -136,24 +81,9 @@ export class Call {
136
81
  const ended = (reason: string): void => {
137
82
  this.hangUp(reason);
138
83
  };
139
- if (this.deps.selected()) {
140
- const shared = this.deps.shared(cfg, out, ended, (text, sourceId) => {
141
- if (!this.over) this.deps.heard(this.route, text, sourceId);
142
- });
143
- this.talk = shared;
144
- this.shared = shared;
145
- this.prime = null;
146
- } else {
147
- const approvals = new CallApprovals(start.line, async (text) => {
148
- await this.deps.train('send', { line: start.line, text });
149
- });
150
- const cli = this.deps.cli(cfg, out, ended, approvals);
151
- this.talk = cli;
152
- this.shared = null;
153
- this.prime = (context) => {
154
- cli.prime(context);
155
- };
156
- }
84
+ this.talk = this.deps.shared(cfg, out, ended, (text, sourceId) => {
85
+ if (!this.over) this.deps.heard(this.route, text, sourceId);
86
+ });
157
87
  this.longest = setTimeout(() => {
158
88
  this.hangUp('the call reached one hour');
159
89
  }, LONGEST_CALL_MS);
@@ -163,15 +93,7 @@ export class Call {
163
93
  if (this.over || this.begun) return;
164
94
  this.begun = true;
165
95
  log.info({ line: this.start.line, callId: this.start.callId }, 'voice: answering a call');
166
- if (this.shared !== null) {
167
- if (!this.deps.open(this.route, this.start.sourceId, this.shared)) throw new Error('the shared conversation is unavailable');
168
- } else {
169
- recentChat(this.start, this.deps.train).then((chat) => {
170
- if (!this.over) this.prime?.(opening(this.start, chat));
171
- }).catch((err: unknown) => {
172
- log.warn({ err: errMsg(err) }, 'voice: could not start the agent on the call');
173
- });
174
- }
96
+ if (!this.deps.open(this.route, this.start.sourceId, this.talk)) throw new Error('the shared conversation is unavailable');
175
97
  await this.signal({ kind: 'join', callId: this.start.callId, from: this.selfPeer });
176
98
  if (this.over) {
177
99
  await this.sendLeave();
@@ -235,14 +157,9 @@ export class Call {
235
157
  log.info({ reason, seconds: Math.round((Date.now() - this.startedAt) / 1000) }, 'voice: call over');
236
158
  this.talk.finish();
237
159
  this.peer?.close();
238
- if (this.shared !== null) this.deps.ended(this.route);
160
+ this.deps.ended(this.route);
239
161
  this.onOver();
240
- this.left = this.sendLeave().then(async () => {
241
- if (this.shared !== null || reason.startsWith('the caller') || reason === 'no offer arrived') return;
242
- await this.deps.train('send', { line: this.start.line, text: `The voice call ended: ${reason}.` });
243
- }).catch((err: unknown) => {
244
- log.warn({ err: errMsg(err) }, 'voice: wrapping up the call failed');
245
- });
162
+ this.left = this.sendLeave();
246
163
  }
247
164
 
248
165
  private async sendLeave(): Promise<void> {
@@ -26,7 +26,6 @@ const str = (value: unknown): string => (typeof value === 'string' ? value : '')
26
26
 
27
27
  const contentTypeOf = (env: TrainEvent): string => str(record(env.payload).contentType);
28
28
  const sourceIdOf = (env: TrainEvent): string => str(env.message_id) || str(env.id) || randomUUID();
29
- const callerNameOf = (env: TrainEvent): string => str(env.from_display_name) || str(env.from_name) || 'the caller';
30
29
 
31
30
  export const isCallEvent = (env: TrainEvent): boolean => CALL_TYPES.has(contentTypeOf(env));
32
31
 
@@ -74,9 +73,7 @@ function invite(env: TrainEvent, body: Record<string, unknown>, create: MakeCall
74
73
  invites.add(key);
75
74
  if (invites.size > INVITES_MAX) invites.delete(invites.values().next().value ?? '');
76
75
  current?.hangUp('the caller started a new call');
77
- const callerName = callerNameOf(env);
78
- const sourceId = sourceIdOf(env);
79
- const start = { agentId, sourceId, line, lineName: str(env.line_name), direct: env.is_private === true, from, callerName, callId, callerPeer };
76
+ const start = { agentId, sourceId: sourceIdOf(env), line, from, callId, callerPeer };
80
77
  const call = create(start, readVoice(), () => {
81
78
  if (current === call) current = null;
82
79
  });
@@ -7,7 +7,7 @@ export const elevenLabs = (): string => process.env.METRO_ELEVENLABS_URL ?? 'wss
7
7
  const STT_RATE = 16_000;
8
8
  const CHUNK_SAMPLES = STT_RATE / 10;
9
9
  const HELD_SAMPLES = STT_RATE * 2;
10
- export const VAD_SILENCE_SECS = 0.5;
10
+ const VAD_SILENCE_SECS = 0.5;
11
11
 
12
12
  export interface Heard {
13
13
  partial(text: string): void;
@@ -4,7 +4,6 @@ import { str } from '@metro-labs/core/str';
4
4
  import { log } from '@metro-labs/core/log';
5
5
  import { agentIdForLine, allowlistForLine, lineReceives, mayApprove, senderPermitted, accountFromLine } from '../agents/map.js';
6
6
  import { eventInScope } from '../agents/scope.js';
7
- import { harnessRunner } from '../claude/runner.js';
8
7
  import { policyFor, type Access } from '../policy/policy.js';
9
8
 
10
9
  export interface SharedSpeechAction {
@@ -194,7 +193,6 @@ export class SharedCalls {
194
193
  }
195
194
 
196
195
  export const sharedCalls = new SharedCalls();
197
- export const sharedCallsSelected = (): boolean => harnessRunner() === 'sdk';
198
196
  export const openSharedCall = (route: CallRoute, sourceId: string, transport: SharedCallTransport): boolean => sharedCalls.open(route, sourceId, transport);
199
197
  export const hearSharedCall = (route: CallRoute, text: string, sourceId: string): void => { sharedCalls.heard(route, text, sourceId); };
200
198
  export const endSharedCall = (route: CallRoute): void => { sharedCalls.end(route); };
@@ -61,10 +61,6 @@ export class Utterance {
61
61
  });
62
62
  }
63
63
 
64
- get usable(): boolean {
65
- return !this.closed && this.listener === null && this.ws.readyState <= WebSocket.OPEN;
66
- }
67
-
68
64
  attach(listener: Voiced): void {
69
65
  if (!this.aborted && !this.finished) this.listener = listener;
70
66
  }
@@ -3,7 +3,6 @@ import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
3
3
  import { isRecord } from '@metro-labs/core/is-record';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { agentsDir } from '../agents/files.js';
6
- import { readModelConfig, type ModelConfig } from '../gateway/model-config.js';
7
6
 
8
7
  const VOICE_PROVIDERS = ['elevenlabs'] as const;
9
8
  type VoiceProvider = (typeof VOICE_PROVIDERS)[number];
@@ -12,7 +11,6 @@ export interface VoiceConfig {
12
11
  provider: VoiceProvider;
13
12
  apiKey: string;
14
13
  voiceId: string;
15
- model: string;
16
14
  language: string;
17
15
  enabled: boolean;
18
16
  }
@@ -22,7 +20,6 @@ const AUTO_LANGUAGE = 'auto';
22
20
  const LANGUAGE_RE = /^(?:[a-z]{2,3}|auto)$/;
23
21
 
24
22
  const DEFAULT_VOICE_ID = 'EXAVITQu4vr4xnSDxMaL';
25
- const DEFAULT_VOICE_MODEL = 'anthropic:claude-sonnet-5-5';
26
23
 
27
24
  const VOICE_FILE = 'voice.json';
28
25
  const MAX_FIELD = 512;
@@ -38,7 +35,6 @@ export function parseVoice(raw: unknown): VoiceConfig {
38
35
  provider: isProvider(r.provider) ? r.provider : 'elevenlabs',
39
36
  apiKey: text(r.apiKey),
40
37
  voiceId: text(r.voiceId),
41
- model: text(r.model),
42
38
  language: LANGUAGE_RE.test(text(r.language)) ? text(r.language) : '',
43
39
  enabled: r.enabled !== false,
44
40
  };
@@ -52,13 +48,6 @@ export function writeVoice(cfg: VoiceConfig, dir = agentsDir()): void {
52
48
  }
53
49
 
54
50
  export const voiceIdOf = (cfg: VoiceConfig): string => (cfg.voiceId === '' ? DEFAULT_VOICE_ID : cfg.voiceId);
55
- const voiceModelOf = (cfg: VoiceConfig): string => (cfg.model === '' ? DEFAULT_VOICE_MODEL : cfg.model);
56
- export function brainModel(cfg: VoiceConfig, models: ModelConfig = readModelConfig()): string {
57
- const model = voiceModelOf(cfg);
58
- const anthropic = /^anthropic:(.+)$/.exec(model)?.[1];
59
- if (anthropic === undefined) return model;
60
- return models.connections.some((c) => c.provider === 'anthropic') ? model : anthropic;
61
- }
62
51
 
63
52
  export function languageOf(cfg: VoiceConfig): string | null {
64
53
  const language = cfg.language === '' ? DEFAULT_LANGUAGE : cfg.language;
@@ -73,10 +62,9 @@ export function publicVoice(cfg: VoiceConfig): Record<string, unknown> {
73
62
  providers: VOICE_PROVIDERS,
74
63
  hasKey: cfg.apiKey !== '',
75
64
  voiceId: cfg.voiceId,
76
- model: cfg.model,
77
65
  language: cfg.language,
78
66
  enabled: cfg.enabled,
79
- defaults: { voiceId: DEFAULT_VOICE_ID, model: DEFAULT_VOICE_MODEL, language: DEFAULT_LANGUAGE },
67
+ defaults: { voiceId: DEFAULT_VOICE_ID, language: DEFAULT_LANGUAGE },
80
68
  };
81
69
  }
82
70
 
@@ -109,7 +97,6 @@ export function patchVoice(cfg: VoiceConfig, body: unknown): VoiceConfig {
109
97
  provider: isProvider(body.provider) ? body.provider : cfg.provider,
110
98
  apiKey: keyOf(body, cfg.apiKey),
111
99
  voiceId: field(body, 'voiceId', cfg.voiceId),
112
- model: field(body, 'model', cfg.model),
113
100
  language: languageField(body, cfg.language),
114
101
  enabled: typeof body.enabled === 'boolean' ? body.enabled : cfg.enabled,
115
102
  };
@@ -11,7 +11,6 @@ export interface ApiSession {
11
11
  userId?: string;
12
12
  sessionId?: string;
13
13
  role: Role;
14
- operator?: true;
15
14
  }
16
15
 
17
16
  export type BearerSessions = (req: IncomingMessage) => Promise<ApiSession | null>;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.307"
2
+ "version": "0.1.0-beta.308"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.307",
2
+ "version": "0.1.0-beta.308",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,141 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { basename, join } from 'node:path';
5
- export const AGENT_VIEW_OFF = 'CLAUDE_CODE_DISABLE_AGENT_VIEW';
6
- const STOP_MS = 30_000;
7
- const SETTLE_MS = 5_000;
8
- const POLL_MS = 250;
9
- const given = (value) => value !== undefined && value.trim() !== '' ? value.trim() : undefined;
10
- export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
11
- export const keepInSession = (env) => given(env[AGENT_VIEW_OFF]) === undefined ? { ...env, [AGENT_VIEW_OFF]: '1' } : env;
12
- export const claudeDir = (env = process.env) => given(env.CLAUDE_CONFIG_DIR) ?? join(given(env.HOME) ?? homedir(), '.claude');
13
- export const projectDir = (dir, cwd) => join(dir, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'));
14
- function startTime(pid) {
15
- try {
16
- const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8');
17
- return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
18
- }
19
- catch {
20
- return null;
21
- }
22
- }
23
- export function processAlive(pid, procStart) {
24
- try {
25
- process.kill(pid, 0);
26
- }
27
- catch (err) {
28
- if (err.code !== 'EPERM')
29
- return false;
30
- }
31
- const started = procStart === undefined ? null : startTime(pid);
32
- return started === null || started === procStart;
33
- }
34
- function backgroundEntry(file, alive) {
35
- try {
36
- const raw = JSON.parse(readFileSync(file, 'utf8'));
37
- if (!isRecord(raw) || typeof raw.pid !== 'number' || typeof raw.sessionId !== 'string')
38
- return null;
39
- if (raw.kind === 'interactive' || typeof raw.kind !== 'string')
40
- return null;
41
- return alive(raw.pid, typeof raw.procStart === 'string' ? raw.procStart : undefined) ? raw.sessionId : null;
42
- }
43
- catch {
44
- return null;
45
- }
46
- }
47
- export function liveBackground(sessions, alive = processAlive) {
48
- if (!existsSync(sessions))
49
- return new Set();
50
- const ids = readdirSync(sessions)
51
- .filter((name) => name.endsWith('.json'))
52
- .map((name) => backgroundEntry(join(sessions, name), alive));
53
- return new Set(ids.filter((id) => id !== null));
54
- }
55
- function newestFirst(dir) {
56
- if (!existsSync(dir))
57
- return [];
58
- return readdirSync(dir)
59
- .filter((name) => name.endsWith('.jsonl'))
60
- .map((name) => ({ path: join(dir, name), at: statSync(join(dir, name)).mtimeMs }))
61
- .sort((a, b) => b.at - a.at)
62
- .map((file) => file.path);
63
- }
64
- function continuedIn(text) {
65
- const last = text.split('\n').findLast((line) => line.includes('"type":"continued-in"'));
66
- if (last === undefined)
67
- return null;
68
- try {
69
- const record = JSON.parse(last);
70
- return isRecord(record) && typeof record.continuedInSessionId === 'string' ? record.continuedInSessionId : null;
71
- }
72
- catch {
73
- return null;
74
- }
75
- }
76
- const HAS_TURN = /"type":"(user|assistant)"/;
77
- export function heldConversation(dir, live) {
78
- if (live.size === 0)
79
- return null;
80
- for (const file of newestFirst(dir)) {
81
- const text = readFileSync(file, 'utf8');
82
- if (!HAS_TURN.test(text))
83
- continue;
84
- const moved = continuedIn(text);
85
- if (moved !== null && live.has(moved))
86
- return moved;
87
- if (!live.has(basename(file, '.jsonl')))
88
- return null;
89
- }
90
- return null;
91
- }
92
- function stopBackground(id, env = process.env) {
93
- const run = spawnSync('claude', ['stop', id], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: STOP_MS });
94
- if (run.error !== undefined)
95
- return { ok: false, detail: run.error.message };
96
- const said = `${run.stdout}${run.stderr}`.trim().split('\n').at(-1) ?? '';
97
- return { ok: run.status === 0, detail: said === '' ? `exit ${String(run.status)}` : said };
98
- }
99
- export function resumeSessionId(args) {
100
- const index = args.findLastIndex((arg) => arg === '--resume' || arg === '-r');
101
- const id = index === -1 ? undefined : args[index + 1];
102
- return id !== undefined && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(id) ? id : undefined;
103
- }
104
- function pinnedHeld(dir, live, id) {
105
- if (live.has(id))
106
- return id;
107
- const path = join(dir, `${id}.jsonl`);
108
- if (!existsSync(path))
109
- return null;
110
- const moved = continuedIn(readFileSync(path, 'utf8'));
111
- return moved !== null && live.has(moved) ? moved : null;
112
- }
113
- const sleep = (ms) => new Promise((resolve) => {
114
- setTimeout(resolve, ms);
115
- });
116
- async function settled(sessions, held, alive, ms) {
117
- if (!liveBackground(sessions, alive).has(held))
118
- return true;
119
- if (ms <= 0)
120
- return false;
121
- await sleep(POLL_MS);
122
- return settled(sessions, held, alive, ms - POLL_MS);
123
- }
124
- function heldFor(deps, dir, live) {
125
- const project = projectDir(dir, deps.cwd ?? process.cwd());
126
- return deps.sessionId === undefined ? heldConversation(project, live) : pinnedHeld(project, live, deps.sessionId);
127
- }
128
- export async function takeBackConversation(deps = {}) {
129
- const env = deps.env ?? process.env;
130
- const alive = deps.alive ?? processAlive;
131
- const dir = claudeDir(env);
132
- const sessions = join(dir, 'sessions');
133
- const held = heldFor(deps, dir, liveBackground(sessions, alive));
134
- if (held === null)
135
- return null;
136
- const id = held.slice(0, 8);
137
- const stopped = (deps.stop ?? stopBackground)(id, env);
138
- if (!stopped.ok || !(await settled(sessions, held, alive, deps.settleMs ?? SETTLE_MS)))
139
- return `the conversation is held by the Claude Code background session ${id} and it could not be stopped (${stopped.detail}), so Claude Code will refuse to continue it: run claude stop ${id}`;
140
- return `the conversation had moved to the Claude Code background session ${id}, where no chat reaches it; stopped that session (its conversation is kept) and continuing it here`;
141
- }
package/dist/claude.js DELETED
@@ -1,204 +0,0 @@
1
- import { spawn, spawnSync } from 'node:child_process';
2
- import { markOnboardingDone, seedChannels } from './onboarding.js';
3
- import { writeMcpConfig } from './mcp-config.js';
4
- import { currentRoute, harnessRunner, permissionMode, routeModelEnv, systemPrompt } from './route.js';
5
- import { settingsConflicts, settingsFiles } from './claude-settings.js';
6
- import { localAgent } from './local.js';
7
- import { PROVIDER_FLAGS } from './provider-flags.js';
8
- import { localPort, localUrl } from './runtime.js';
9
- import { keepInSession, resumeSessionId, takeBackConversation } from './background.js';
10
- import { nativeRecoveryArgs } from './native-recovery.js';
11
- const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
12
- const FRESH_PROMPT_FLAGS = ['--system-prompt-snapshot', 'off'];
13
- export const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
14
- const KEY_HEADER = 'x-metro-key';
15
- const PROBE_MS = 3_000;
16
- const AUTH_STATUS_MS = 20_000;
17
- const KILL_GRACE_MS = 30_000;
18
- export const claudeArgs = (extra, mcpConfig, mode = 'auto', prompt = null) => [
19
- ...CHANNEL_FLAGS,
20
- '--permission-mode',
21
- PERMISSION_MODE_FLAG[mode],
22
- ...FRESH_PROMPT_FLAGS,
23
- ...(mcpConfig === undefined ? [] : ['--mcp-config', mcpConfig]),
24
- ...(prompt === null ? [] : ['--append-system-prompt', prompt]),
25
- ...extra,
26
- ];
27
- const set = (env, name) => (env[name] ?? '').trim() !== '';
28
- export function pinnedBy(env) {
29
- if (set(env, 'ANTHROPIC_BASE_URL'))
30
- return 'ANTHROPIC_BASE_URL';
31
- return PROVIDER_FLAGS.find((flag) => set(env, flag)) ?? null;
32
- }
33
- export function gatewayEnv(base, agentKey, port) {
34
- if (agentKey === null || pinnedBy(base) !== null)
35
- return base;
36
- const own = (base.ANTHROPIC_CUSTOM_HEADERS ?? '').trim();
37
- const mine = `${KEY_HEADER}: ${agentKey}`;
38
- return {
39
- ...base,
40
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${String(port)}/gateway`,
41
- ANTHROPIC_CUSTOM_HEADERS: own === '' ? mine : `${own}\n${mine}`,
42
- CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
43
- };
44
- }
45
- export const channelEnv = (env) => set(env, 'MCP_PROTOCOL_NEGOTIATION') ? env : { ...env, MCP_PROTOCOL_NEGOTIATION: 'legacy' };
46
- export const toolSearchEnv = (env) => set(env, 'ENABLE_TOOL_SEARCH') ? env : { ...env, ENABLE_TOOL_SEARCH: 'true' };
47
- const RM_PROMPT_OFF = 'CLAUDE_CODE_DISABLE_INLINE_SHELL_RM_PROMPT';
48
- export const rmPromptEnv = (env) => set(env, RM_PROMPT_OFF) ? env : { ...env, [RM_PROMPT_OFF]: '1' };
49
- const OWN_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
50
- export function credentialEnv(env, agentKey, signedIn) {
51
- if (signedIn || OWN_CREDENTIALS.some((name) => set(env, name)))
52
- return env;
53
- return { ...env, ANTHROPIC_AUTH_TOKEN: agentKey };
54
- }
55
- function claudeSignedIn() {
56
- const run = spawnSync('claude', ['auth', 'status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: AUTH_STATUS_MS });
57
- if (run.error !== undefined || typeof run.stdout !== 'string')
58
- return false;
59
- try {
60
- const parsed = JSON.parse(run.stdout);
61
- return parsed.authenticated === true || parsed.loggedIn === true || typeof parsed.email === 'string' || typeof parsed.organization === 'string';
62
- }
63
- catch {
64
- return false;
65
- }
66
- }
67
- export function servingDaemon(body) {
68
- if (typeof body !== 'object' || body === null)
69
- return false;
70
- const mode = body;
71
- return mode.mode === 'local' && mode.stopped !== true;
72
- }
73
- async function daemonServing(base = localUrl()) {
74
- try {
75
- const res = await fetch(`${base}/api/mode`, { signal: AbortSignal.timeout(PROBE_MS) });
76
- return res.ok && servingDaemon(await res.json());
77
- }
78
- catch {
79
- return false;
80
- }
81
- }
82
- export function agentKey(agent) {
83
- return agent === null ? { skip: 'no agent lives on this machine yet, so Claude Code talks to Anthropic directly' } : { key: agent.key };
84
- }
85
- async function verdict() {
86
- const pinned = pinnedBy(process.env);
87
- if (pinned !== null)
88
- return { skip: `${pinned} is set, so Claude Code keeps talking to it` };
89
- const conflicts = settingsConflicts(settingsFiles());
90
- if (conflicts.length > 0)
91
- return { skip: `a settings file pins the provider (${conflicts.join(', ')}), so Claude Code keeps it` };
92
- const picked = agentKey(localAgent());
93
- if ('skip' in picked)
94
- return picked;
95
- if (await daemonServing())
96
- return picked;
97
- return { skip: 'the daemon is not serving here (stopped, or not running), so Claude Code talks to Anthropic directly' };
98
- }
99
- function deadline(child, timeoutMs) {
100
- if (timeoutMs === undefined)
101
- return () => undefined;
102
- let kill;
103
- const term = setTimeout(() => {
104
- child.kill('SIGTERM');
105
- kill = setTimeout(() => {
106
- child.kill('SIGKILL');
107
- }, KILL_GRACE_MS);
108
- }, timeoutMs);
109
- return () => {
110
- clearTimeout(term);
111
- clearTimeout(kill);
112
- };
113
- }
114
- export function runClaude(args, env, headless, command = 'claude') {
115
- return new Promise((resolve, reject) => {
116
- const leaveToChild = () => undefined;
117
- process.on('SIGINT', leaveToChild);
118
- process.on('SIGTERM', leaveToChild);
119
- const child = spawn(command, args, { stdio: [headless === undefined ? 'inherit' : 'ignore', 'inherit', 'inherit'], env });
120
- const stop = deadline(child, headless?.timeoutMs);
121
- child.on('error', (err) => {
122
- reject(new Error(err.code === 'ENOENT'
123
- ? `the \`${command}\` command is not on PATH${command === 'claude' ? ' — install Claude Code first' : ''}`
124
- : err.message));
125
- });
126
- child.on('exit', (code) => {
127
- stop();
128
- resolve(code ?? 1);
129
- });
130
- });
131
- }
132
- async function servedKey(decision) {
133
- if ('key' in decision)
134
- return decision.key;
135
- const picked = agentKey(localAgent());
136
- if ('skip' in picked)
137
- return null;
138
- return (await daemonServing()) ? picked.key : null;
139
- }
140
- function mcpConfigFor(key, port) {
141
- if (key === null)
142
- return null;
143
- const file = writeMcpConfig(key, port);
144
- process.stderr.write(`metro claude: the metro MCP server is loaded for this session from the daemon at http://127.0.0.1:${String(port)}/mcp, nothing to add by hand\n`);
145
- return file;
146
- }
147
- function gatewayLaunchEnv(key, port) {
148
- process.stderr.write(`metro claude: inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)\n`);
149
- const pointed = routeModelEnv(gatewayEnv(process.env, key, port), currentRoute());
150
- const routed = toolSearchEnv(pointed);
151
- if (routed !== pointed)
152
- process.stderr.write('metro claude: MCP tool search is on, so connector tools load when Claude needs them instead of all at start\n');
153
- const env = credentialEnv(routed, key, claudeSignedIn());
154
- if (env !== routed)
155
- process.stderr.write("metro claude: Claude Code has no login of its own here, so metro's key stands in as its credential; the Model page must route to Bedrock, OpenRouter or Codex\n");
156
- if (seedChannels() === 'marked')
157
- process.stderr.write('metro claude: marked Channels as available in Claude Code cached flags, since a box that sends no telemetry never fetches them\n');
158
- if (markOnboardingDone() === 'marked')
159
- process.stderr.write("metro claude: skipped Claude Code's first-run setup, since it already has a credential here\n");
160
- return env;
161
- }
162
- function inferenceEnv(decision, port) {
163
- if ('key' in decision)
164
- return gatewayLaunchEnv(decision.key, port);
165
- process.stderr.write(`metro claude: ${decision.skip}\n`);
166
- return process.env;
167
- }
168
- export async function headlessEnv() {
169
- return keepInSession(rmPromptEnv(inferenceEnv(await verdict(), localPort())));
170
- }
171
- const continues = (args) => args.includes('-c') || args.includes('--continue');
172
- async function reclaimConversation(extra) {
173
- const sessionId = resumeSessionId(extra);
174
- if (sessionId === undefined && !continues(extra))
175
- return;
176
- try {
177
- const note = await takeBackConversation({ sessionId });
178
- if (note !== null)
179
- process.stderr.write(`metro claude: ${note}\n`);
180
- }
181
- catch (err) {
182
- const message = err instanceof Error ? err.message : String(err);
183
- process.stderr.write(`metro claude: could not check for a Claude Code background session holding the conversation (${message})\n`);
184
- }
185
- }
186
- const SDK_HOLDS_CHAT = 'the Harness runs this agent as an Agent SDK session (metro agent), which holds the metro chat; a metro claude session would take it away. Switch the Harness runner back to the Claude Code session first, or open the conversation with plain claude --resume <session id>';
187
- export async function launchClaude(extra) {
188
- if (harnessRunner() === 'sdk')
189
- throw new Error(SDK_HOLDS_CHAT);
190
- await reclaimConversation(extra);
191
- const decision = await verdict();
192
- const port = localPort();
193
- const mcp = mcpConfigFor(await servedKey(decision), port);
194
- const mode = permissionMode();
195
- const prompt = systemPrompt();
196
- if (prompt !== null)
197
- process.stderr.write('metro claude: the system prompt from the Harness page is appended to this session\n');
198
- try {
199
- return await runClaude(claudeArgs(nativeRecoveryArgs(extra), mcp?.path, mode, prompt), keepInSession(rmPromptEnv(channelEnv(inferenceEnv(decision, port)))));
200
- }
201
- finally {
202
- mcp?.cleanup();
203
- }
204
- }