@stage-labs/metro 0.1.0-beta.201 → 0.1.0-beta.202

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 (163) hide show
  1. package/dist/claude.js +4 -15
  2. package/dist/cli.js +5 -3
  3. package/dist/hold.js +1 -2
  4. package/dist/runtime-install.js +4 -5
  5. package/dist/runtime.js +1 -4
  6. package/dist/serve.js +4 -5
  7. package/dist/service.js +82 -21
  8. package/dist/update.js +1 -1
  9. package/package.json +2 -2
  10. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  11. package/runtime/node_modules/@metro-labs/core/src/events.ts +4 -2
  12. package/runtime/node_modules/@metro-labs/core/src/lines.ts +12 -25
  13. package/runtime/node_modules/@metro-labs/core/src/stations/account-store.ts +45 -22
  14. package/runtime/node_modules/@metro-labs/core/src/stations/attachments.ts +0 -1
  15. package/runtime/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +8 -80
  16. package/runtime/node_modules/@metro-labs/core/src/stations/station-runtime.ts +3 -5
  17. package/runtime/node_modules/@metro-labs/core/src/stations/train-events.ts +4 -9
  18. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files.ts +9 -39
  19. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/job-env.ts +1 -1
  20. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +9 -28
  21. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedule-detail.ts +0 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules-api.ts +3 -3
  23. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules.ts +8 -116
  24. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/unit-files.ts +11 -35
  25. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +7 -9
  26. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
  27. package/runtime/node_modules/@metro-labs/daemon/src/agents/account-lookups.ts +1 -1
  28. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +1 -1
  29. package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +1 -1
  30. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -3
  31. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +2 -2
  32. package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +3 -29
  33. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +5 -8
  34. package/runtime/node_modules/@metro-labs/daemon/src/approvals/preview.ts +1 -1
  35. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  36. package/runtime/node_modules/@metro-labs/daemon/src/boot/connect-hint.ts +3 -6
  37. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +25 -3
  38. package/runtime/node_modules/@metro-labs/daemon/src/channels/media-note.ts +2 -3
  39. package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +2 -2
  40. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +1 -1
  41. package/runtime/node_modules/@metro-labs/daemon/src/claude/memory.ts +6 -56
  42. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +3 -3
  43. package/runtime/node_modules/@metro-labs/daemon/src/claude/session-files.ts +1 -1
  44. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
  45. package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +1 -1
  46. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +7 -7
  47. package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +4 -4
  48. package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +1 -1
  49. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +2 -4
  50. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +19 -22
  51. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -2
  52. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-discovery.ts +6 -8
  53. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -6
  54. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +1 -1
  55. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +2 -3
  56. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +28 -1
  57. package/runtime/node_modules/@metro-labs/daemon/src/files/attach-serve.ts +1 -1
  58. package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +4 -4
  59. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +1 -1
  60. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +1 -1
  61. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-stream.ts +1 -1
  62. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-translate.ts +3 -2
  63. package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +1 -1
  64. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +2 -2
  65. package/runtime/node_modules/@metro-labs/daemon/src/gateway/eventstream.ts +1 -1
  66. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +3 -3
  67. package/runtime/node_modules/@metro-labs/daemon/src/gateway/frames.ts +1 -1
  68. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +3 -3
  69. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +6 -6
  70. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +7 -7
  71. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +1 -1
  72. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +3 -2
  73. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +1 -1
  74. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -31
  75. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +1 -1
  76. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -1
  77. package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +2 -2
  78. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
  79. package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +1 -2
  80. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +2 -2
  81. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +5 -6
  82. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +3 -3
  83. package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +2 -2
  84. package/runtime/node_modules/@metro-labs/daemon/src/mcp/read-tool.ts +2 -2
  85. package/runtime/node_modules/@metro-labs/daemon/src/metro-user/helper-script.ts +2 -2
  86. package/runtime/node_modules/@metro-labs/daemon/src/metro-user/install.ts +37 -0
  87. package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +2 -2
  88. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +1 -1
  89. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -4
  90. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -8
  91. package/runtime/node_modules/@metro-labs/daemon/src/server/machine.ts +2 -2
  92. package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +2 -2
  93. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +2 -2
  94. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-xmtp.ts +6 -7
  95. package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor-io.ts +1 -1
  96. package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor.ts +2 -2
  97. package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +2 -2
  98. package/runtime/node_modules/@metro-labs/daemon/src/terminal/socket.ts +1 -1
  99. package/runtime/node_modules/@metro-labs/daemon/src/vault/firewall.ts +5 -44
  100. package/runtime/node_modules/@metro-labs/daemon/src/vault/index.ts +4 -4
  101. package/runtime/node_modules/@metro-labs/daemon/src/vault/proxy.ts +6 -7
  102. package/runtime/node_modules/@metro-labs/daemon/src/vault/store.ts +12 -13
  103. package/runtime/node_modules/@metro-labs/daemon/src/vault/trust.ts +5 -22
  104. package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +1 -19
  105. package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +7 -35
  106. package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +5 -10
  107. package/runtime/node_modules/@metro-labs/discord-bot/src/group-actions.ts +1 -1
  108. package/runtime/node_modules/@metro-labs/discord-bot/src/index.ts +1 -2
  109. package/runtime/node_modules/@metro-labs/discord-bot/src/profile.ts +1 -1
  110. package/runtime/node_modules/@metro-labs/discord-bot/src/send-files.ts +11 -12
  111. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -5
  112. package/runtime/node_modules/@metro-labs/http/src/mode-api.ts +0 -1
  113. package/runtime/node_modules/@metro-labs/outlook/src/accounts.ts +1 -15
  114. package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +0 -6
  115. package/runtime/node_modules/@metro-labs/outlook/src/format.ts +0 -1
  116. package/runtime/node_modules/@metro-labs/outlook/src/state.ts +1 -1
  117. package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +4 -22
  118. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +2 -2
  119. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +1 -3
  120. package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +1 -1
  121. package/runtime/node_modules/@metro-labs/telegram/src/media-actions.ts +1 -2
  122. package/runtime/node_modules/@metro-labs/telegram/src/reactions.ts +1 -1
  123. package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +4 -37
  124. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +7 -23
  125. package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +1 -4
  126. package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +4 -9
  127. package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +3 -3
  128. package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +0 -11
  129. package/runtime/node_modules/@metro-labs/threema/src/actions.ts +2 -12
  130. package/runtime/node_modules/@metro-labs/threema/src/files.ts +4 -15
  131. package/runtime/node_modules/@metro-labs/threema/src/format.ts +2 -7
  132. package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -2
  133. package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +5 -26
  134. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +5 -6
  135. package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +1 -3
  136. package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +1 -1
  137. package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +1 -12
  138. package/runtime/node_modules/@metro-labs/xmtp/src/actions-close.ts +1 -1
  139. package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +14 -5
  140. package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +1 -1
  141. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +31 -78
  142. package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +3 -4
  143. package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +1 -6
  144. package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +1 -1
  145. package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +1 -1
  146. package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +1 -1
  147. package/runtime/node_modules/@metro-labs/xmtp/src/transcribe.ts +2 -2
  148. package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +0 -2
  149. package/runtime/runtime.json +1 -1
  150. package/runtime/node_modules/@metro-labs/core/src/identity.ts +0 -7
  151. package/runtime/node_modules/@metro-labs/daemon/src/approvals/needs.ts +0 -2
  152. package/runtime/node_modules/@metro-labs/daemon/src/channels/dedupe.ts +0 -12
  153. package/runtime/node_modules/@metro-labs/daemon/src/channels/react-note.ts +0 -13
  154. package/runtime/node_modules/@metro-labs/daemon/src/channels/reply-id.ts +0 -1
  155. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -29
  156. package/runtime/node_modules/@metro-labs/daemon/src/metro-user/api.ts +0 -43
  157. package/runtime/node_modules/@metro-labs/daemon/src/metro-user/move.ts +0 -165
  158. package/runtime/node_modules/@metro-labs/discord-bot/src/wire.ts +0 -1
  159. package/runtime/node_modules/@metro-labs/telegram/src/normalize.ts +0 -22
  160. package/runtime/node_modules/@metro-labs/telegram/src/wire.ts +0 -1
  161. package/runtime/node_modules/@metro-labs/telegram-bot/src/wire.ts +0 -1
  162. package/runtime/node_modules/@metro-labs/whatsapp/src/normalize.ts +0 -22
  163. package/runtime/node_modules/@metro-labs/whatsapp/src/wire.ts +0 -1
@@ -1,88 +1,16 @@
1
1
  type Args = Record<string, unknown>;
2
+
2
3
  export interface Normalized {
3
4
  action: string;
4
5
  args: Args;
5
6
  }
6
7
 
7
- interface Attachment {
8
- kind?: string;
9
- path?: string;
10
- url?: string;
11
- data?: string;
12
- mime?: string;
13
- name?: string;
14
- }
15
-
16
- interface DiscordFile {
17
- file: string;
18
- kind: string;
19
- name: string;
20
- }
21
-
22
- const discordFile = (a: Attachment): DiscordFile | null => {
23
- const file = a.path ?? a.url;
24
- if (typeof file !== 'string') return null;
25
- return { file, kind: a.kind ?? 'file', name: a.name ?? '' };
26
- };
8
+ export type Normalize = (action: string, args: Args) => Normalized;
27
9
 
28
- function discordAttachments(att: Attachment[]): Args {
29
- const usable = att
30
- .map(discordFile)
31
- .filter((f): f is DiscordFile => f !== null);
32
- if (!usable.length) return {};
33
- return {
34
- files: usable.map((f) => f.file),
35
- attachmentKinds: usable.map((f) => f.kind),
36
- attachmentNames: usable.map((f) => f.name),
10
+ export const messagingAliases =
11
+ (removal: Args = { emoji: '' }): Normalize =>
12
+ (action, args) => {
13
+ if (action === 'reply') return { action: 'send', args };
14
+ if (action === 'unreact') return { action: 'react', args: { ...args, ...removal } };
15
+ return { action, args };
37
16
  };
38
- }
39
-
40
- export function normalizeDiscord(action: string, env: Args): Normalized {
41
- const att = (env.attachments as Attachment[] | undefined) ?? [];
42
- if (action === 'send') {
43
- return { action: 'send', args: { ...env, ...discordAttachments(att) } };
44
- }
45
- if (action === 'reply') {
46
- const replyTo = env.replyTo ?? env.messageId;
47
- return {
48
- action: 'send',
49
- args: { ...env, replyTo, ...discordAttachments(att) },
50
- };
51
- }
52
- if (action === 'unreact')
53
- return { action: 'react', args: { ...env, emoji: '' } };
54
- if (action === 'read') {
55
- return {
56
- action: 'fetch',
57
- args: { line: env.line, limit: env.limit, before: env.before },
58
- };
59
- }
60
- return { action, args: env };
61
- }
62
-
63
- export function normalizeTelegram(action: string, env: Args): Normalized {
64
- if (action === 'reply') {
65
- return {
66
- action: 'send',
67
- args: {
68
- line: env.line,
69
- text: env.text,
70
- replyTo: env.replyTo,
71
- attachments: env.attachments,
72
- },
73
- };
74
- }
75
- if (action === 'unreact')
76
- return { action: 'react', args: { ...env, emoji: '' } };
77
- return { action, args: env };
78
- }
79
-
80
- export function normalizeXmtp(action: string, env: Args): Normalized {
81
- if (action === 'unreact') {
82
- return { action: 'react', args: { ...env, action: 'removed' } };
83
- }
84
- if (action === 'read') {
85
- return { action: 'query', args: { line: env.line, limit: env.limit } };
86
- }
87
- return { action, args: env };
88
- }
@@ -1,5 +1,5 @@
1
1
  import { serializeTrainError, type TrainErrorInfo } from '../train-error.js';
2
- import type { Normalized } from './messaging-normalize.js';
2
+ import type { Normalize } from './messaging-normalize.js';
3
3
 
4
4
  export const emit = (e: unknown): void =>
5
5
  void process.stdout.write(JSON.stringify(e) + '\n');
@@ -27,7 +27,7 @@ export type StationHandler = (id: string, args: Args) => void | Promise<void>;
27
27
 
28
28
  export interface StationConfig {
29
29
  handlers: Record<string, StationHandler>;
30
- normalize: (action: string, args: Args) => Normalized;
30
+ normalize?: Normalize;
31
31
  }
32
32
 
33
33
  const lineTag = (args: Args): string =>
@@ -40,15 +40,13 @@ export function makeStation({ handlers, normalize }: StationConfig) {
40
40
  let action = msg.action;
41
41
  let args: Args = msg.args;
42
42
  try {
43
- ({ action, args } = normalize(msg.action, msg.args));
44
- emit({ op: 'log', text: `call ${action} recv (line=${lineTag(args)})` });
43
+ if (normalize && !handlers[action]) ({ action, args } = normalize(action, args));
45
44
  const handler = handlers[action];
46
45
  if (!handler) {
47
46
  respond(id, { error: `unknown action '${action}' (have: ${known})` });
48
47
  return;
49
48
  }
50
49
  await handler(id, args);
51
- emit({ op: 'log', text: `call ${action} done (line=${lineTag(args)})` });
52
50
  } catch (err) {
53
51
  const info = serializeTrainError(err);
54
52
  emit({
@@ -4,15 +4,11 @@ import { emit } from './station-runtime.js';
4
4
  type Fields = Record<string, unknown>;
5
5
  type Out = (e: unknown) => void;
6
6
 
7
- export const selfUri = (station: string, account?: string): string =>
8
- process.env.METRO_SELF_URI ??
9
- (account === undefined ? `metro://${station}/self` : `metro://${station}/${account}/self`);
7
+ export const selfUri = (): string => process.env.METRO_SELF_URI ?? 'metro://user';
10
8
 
11
9
  export function emitInbound(account: string, e: Fields, out: Out = emit): void {
12
10
  out({
13
- kind: 'inbound',
14
11
  ...e,
15
- account,
16
12
  payload: { ...(e.payload as Fields | undefined), account },
17
13
  });
18
14
  }
@@ -30,18 +26,17 @@ export interface SavedFile {
30
26
  path: string;
31
27
  mime?: string;
32
28
  name?: string;
29
+ bytes?: number;
33
30
  }
34
31
 
35
32
  function attachmentEvent(at: AttachmentAt, text: string, payload: Fields): Fields {
36
33
  return {
37
- kind: 'inbound',
38
34
  id: mintId(),
39
35
  ts: new Date().toISOString(),
40
36
  station: at.station,
41
37
  line: at.line,
42
- from: at.from ?? selfUri(at.station, at.account),
38
+ from: at.from ?? selfUri(),
43
39
  text,
44
- account: at.account,
45
40
  payload: { account: at.account, attachmentFor: at.forId, index: at.index, ...payload },
46
41
  };
47
42
  }
@@ -49,9 +44,9 @@ function attachmentEvent(at: AttachmentAt, text: string, payload: Fields): Field
49
44
  export const attachmentSavedEvent = (at: AttachmentAt & { saved: SavedFile; extra?: Fields }): Fields =>
50
45
  attachmentEvent(at, `📎 saved: ${at.saved.path}`, {
51
46
  contentType: 'attachmentSaved',
47
+ ...(at.saved.bytes === undefined ? {} : { size: at.saved.bytes }),
52
48
  ...at.extra,
53
49
  attachmentPath: at.saved.path,
54
- localPath: at.saved.path,
55
50
  mime: at.saved.mime,
56
51
  name: at.saved.name,
57
52
  });
@@ -1,12 +1,11 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
3
  import { ApiError } from '@metro-labs/http/api-error';
5
4
  import { asUser, type AgentUser } from './user.js';
6
5
 
7
- export type EntryKind = 'folder' | 'file' | 'other';
6
+ type EntryKind = 'folder' | 'file' | 'other';
8
7
 
9
- export interface FileEntry {
8
+ interface FileEntry {
10
9
  name: string;
11
10
  kind: EntryKind;
12
11
  bytes: number;
@@ -76,44 +75,15 @@ function readAsAgent(user: AgentUser, path: string): FilesAnswer {
76
75
  const run = spawnSync(file, argv, { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, timeout: 20_000 });
77
76
  if (run.error !== undefined) throw new ApiError(`could not read that path: ${run.error.message}`, 500);
78
77
  if (run.status !== 0) throw new ApiError(run.stderr.toString('utf8').trim() || 'the agent cannot read that path', 404);
79
- const out = run.stdout;
80
- const body = out.subarray(2);
81
- return out.subarray(0, 1).toString() === 'D' ? folderAnswer(user.home, path, parseListing(body)) : parseFile(user.home, path, body);
82
- }
83
-
84
- function readHead(target: string): Buffer {
85
- const fd = openSync(target, 'r');
86
- try {
87
- const buffer = Buffer.alloc(PREVIEW_BYTES + 1);
88
- return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0));
89
- } finally {
90
- closeSync(fd);
91
- }
78
+ return answerOf(user.home, path, run.stdout);
92
79
  }
93
80
 
94
- function readLocal(root: string, path: string): FilesAnswer {
95
- const target = join(root, ...segmentsOf(path));
96
- let stat;
97
- try {
98
- stat = statSync(target);
99
- } catch {
100
- throw new ApiError('no such file or folder', 404);
101
- }
102
- if (stat.isFile()) return { root, path, kind: 'file', bytes: stat.size, modifiedAt: stat.mtime.toISOString(), ...preview(readHead(target), stat.size) };
103
- const entries = readdirSync(target, { withFileTypes: true }).map((entry): FileEntry => {
104
- const inner = join(target, entry.name);
105
- try {
106
- const s = statSync(inner);
107
- return { name: entry.name, kind: s.isDirectory() ? 'folder' : s.isFile() ? 'file' : 'other', bytes: s.size, modifiedAt: s.mtime.toISOString() };
108
- } catch {
109
- return { name: entry.name, kind: 'other', bytes: 0, modifiedAt: '' };
110
- }
111
- });
112
- return folderAnswer(root, path, entries);
81
+ export function answerOf(root: string, path: string, out: Buffer): FilesAnswer {
82
+ const body = out.subarray(2);
83
+ return out.subarray(0, 1).toString() === 'D' ? folderAnswer(root, path, parseListing(body)) : parseFile(root, path, body);
113
84
  }
114
85
 
115
- export function readAgentPath(user: AgentUser | null, path: string, localRoot?: string): FilesAnswer {
116
- if (user !== null) return readAsAgent(user, path);
117
- if (localRoot === undefined) throw new ApiError('Claude Code does not run as its own user on this machine, so there is no agent folder to show', 409);
118
- return readLocal(localRoot, path);
86
+ export function readAgentPath(user: AgentUser | null, path: string): FilesAnswer {
87
+ if (user === null) throw new ApiError('Claude Code does not run as its own user on this machine, so there is no agent folder to show', 409);
88
+ return readAsAgent(user, path);
119
89
  }
@@ -9,7 +9,7 @@ const DROP_IN = '11-metro-vault.conf';
9
9
  const BEGIN = '# metro vault: begin';
10
10
  const END = '# metro vault: end';
11
11
 
12
- export const jobEnvFile = (user: AgentUser): string => join(user.home, '.metro', 'vault.env');
12
+ const jobEnvFile = (user: AgentUser): string => join(user.home, '.metro', 'vault.env');
13
13
 
14
14
  export function withCronEnv(lines: string[], env: Record<string, string>): string[] {
15
15
  const start = lines.indexOf(BEGIN);
@@ -1,14 +1,14 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from './agent-fs.js';
2
+ import { existsSync } from './agent-fs.js';
3
3
  import { join } from 'node:path';
4
4
  import { chmodSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { errMsg, log } from '@metro-labs/core/log';
7
- import { isRecord } from '@metro-labs/core/is-record';
8
7
  import { copyIntoHome } from './home-fs.js';
9
8
  import { AGENT_NAME, agentMarketplaceDir, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
10
9
  import { watchAgentView } from './view.js';
11
- import { mustHelper, runningAsMetro } from '../metro-user/privilege.js';
10
+ import { mustHelper } from '../metro-user/privilege.js';
11
+ import { stagedPluginVersion } from '../claude/plugin-install.js';
12
12
 
13
13
  const INSTALL_MS = 10 * 60_000;
14
14
  const INSTALLER = 'curl -fsSL https://claude.ai/install.sh | bash';
@@ -27,21 +27,11 @@ function openMetroHome(): void {
27
27
  }
28
28
 
29
29
  function ensureUser(): AgentUser | null {
30
- if (runningAsMetro()) {
31
- mustHelper(['ensure-agent']);
32
- openMetroHome();
33
- forgetAgentUser();
34
- const user = agentUser();
35
- if (user !== null) run(...asUser(user, 'chmod', ['711', user.home]));
36
- return user;
37
- }
38
- if (agentUser() === null) {
39
- run('useradd', ['--create-home', '--shell', '/bin/bash', AGENT_NAME]);
40
- log.info({ user: AGENT_NAME }, 'agent-user: created the user Claude Code runs as');
41
- forgetAgentUser();
42
- }
30
+ mustHelper(['ensure-agent']);
31
+ openMetroHome();
32
+ forgetAgentUser();
43
33
  const user = agentUser();
44
- if (user !== null) run('chmod', ['700', user.home]);
34
+ if (user !== null) run(...asUser(user, 'chmod', ['711', user.home]));
45
35
  return user;
46
36
  }
47
37
 
@@ -64,23 +54,14 @@ function installClaude(user: AgentUser): Promise<void> {
64
54
  });
65
55
  }
66
56
 
67
- const pluginVersion = (dir: string): string | null => {
68
- try {
69
- const parsed: unknown = JSON.parse(readFileSync(join(dir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8'));
70
- return isRecord(parsed) && typeof parsed.version === 'string' ? parsed.version : null;
71
- } catch {
72
- return null;
73
- }
74
- };
75
-
76
57
  function copyMarketplace(user: AgentUser, env: NodeJS.ProcessEnv): void {
77
58
  const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
78
59
  const from = join(store, 'marketplace');
79
60
  if (store === '' || !existsSync(join(from, '.claude-plugin', 'marketplace.json'))) return;
80
61
  const to = agentMarketplaceDir(user);
81
- if (pluginVersion(from) !== null && pluginVersion(from) === pluginVersion(to)) return;
62
+ if (stagedPluginVersion(from) !== null && stagedPluginVersion(from) === stagedPluginVersion(to)) return;
82
63
  const files = copyIntoHome(from, to, user);
83
- log.info({ files, version: pluginVersion(from) }, 'agent-user: copied the metro plugin where the agent user can load it');
64
+ log.info({ files, version: stagedPluginVersion(from) }, 'agent-user: copied the metro plugin where the agent user can load it');
84
65
  }
85
66
 
86
67
  export async function provisionAgentUser(env: NodeJS.ProcessEnv = process.env): Promise<Provisioned> {
@@ -87,7 +87,6 @@ export function scheduleDetail(id: string, user: AgentUser | null, runner: Runne
87
87
 
88
88
  export function runNow(id: string, user: AgentUser | null, runner: Runner = realRunner): JobDetail {
89
89
  const job = findJob(id, user, runner);
90
- if (job.runsAs === 'root') throw new ApiError('this job still runs as root; switch it to the agent first', 409);
91
90
  if (job.kind === 'timer') runner.run('systemctl', ['start', '--no-block', serviceOf(job.id.slice('timer:'.length), runner)]);
92
91
  else {
93
92
  if (user === null) throw new ApiError('Claude Code does not run as its own user on this machine', 409);
@@ -1,7 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { ApiError } from '@metro-labs/http/api-error';
3
3
  import { bodyField, readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
4
- import { listSchedules, retrySchedule } from './schedules.js';
4
+ import { listSchedules } from './schedules.js';
5
5
  import { runNow, scheduleDetail } from './schedule-detail.js';
6
6
  import { agentUser } from './user.js';
7
7
 
@@ -18,8 +18,8 @@ async function answer(req: IncomingMessage): Promise<unknown> {
18
18
  const body = await readJsonBody(req);
19
19
  const id = bodyField(body, 'id');
20
20
  if (typeof id !== 'string' || id === '') throw new ApiError('id is required', 400);
21
- if (bodyField(body, 'action') === 'run') return { job: runNow(id, user) };
22
- return { agentUser: user?.name ?? null, jobs: retrySchedule(id, user) };
21
+ if (bodyField(body, 'action') !== 'run') throw new ApiError('action must be run', 400);
22
+ return { job: runNow(id, user) };
23
23
  }
24
24
 
25
25
  export function handleSchedulesRequest(req: IncomingMessage, res: ServerResponse): boolean {
@@ -1,20 +1,13 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { ApiError } from '@metro-labs/http/api-error';
7
3
  import { isRecord } from '@metro-labs/core/is-record';
8
- import { errMsg, log } from '@metro-labs/core/log';
9
- import { asUser, type AgentUser } from './user.js';
10
- import { metroRun, writeDropIn } from './unit-files.js';
4
+ import type { AgentUser } from './user.js';
5
+ import { helperRun } from './unit-files.js';
11
6
 
12
7
  const UNIT_DIR = '/etc/systemd/system';
13
- const defaultBackups = (): string => join(homedir(), '.metro', 'agents');
14
- const DROP_IN = '10-metro-agent.conf';
15
8
  const OWN_UNITS = /^metro(-claude-\d+)?\.(service|scope)$/;
16
9
 
17
- export type JobKind = 'timer' | 'cron-root' | 'cron-agent';
10
+ type JobKind = 'timer' | 'cron-agent';
18
11
 
19
12
  export interface ScheduledJob {
20
13
  id: string;
@@ -26,9 +19,6 @@ export interface ScheduledJob {
26
19
  next: string | null;
27
20
  last: string | null;
28
21
  lastResult: string | null;
29
- usesRoot: boolean;
30
- converted: boolean;
31
- problem?: string | null;
32
22
  }
33
23
 
34
24
  export interface Runner {
@@ -37,17 +27,13 @@ export interface Runner {
37
27
 
38
28
  export const realRunner: Runner = {
39
29
  run: (file, args, input) => {
40
- const viaHelper = metroRun(file, args, input);
30
+ const viaHelper = helperRun(file, args, input);
41
31
  if (viaHelper !== null) return viaHelper;
42
32
  const done = spawnSync(file, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
43
33
  return { status: done.error === undefined ? done.status : null, stdout: done.stdout ?? '' };
44
34
  },
45
35
  };
46
36
 
47
- const ROOT_RE = /(^|[\s="'=:])\/root(?=\/|$|[\s"';:])/;
48
- export const usesRootHome = (text: string): boolean => ROOT_RE.test(text);
49
- export const rehome = (text: string, home: string): string => text.replace(/(^|[\s="'=:])\/root(?=\/|$|[\s"';:])/g, `$1${home}`);
50
-
51
37
  const stamp = (micros: unknown): string | null =>
52
38
  typeof micros === 'number' && micros > 0 ? new Date(micros / 1000).toISOString() : null;
53
39
 
@@ -61,7 +47,7 @@ export function showProps(runner: Runner, unit: string, props: string[]): Record
61
47
  return found;
62
48
  }
63
49
 
64
- export function execArgv(execStart: string): string[] {
50
+ function execArgv(execStart: string): string[] {
65
51
  const m = /argv\[\]=(.*?) ;/.exec(execStart);
66
52
  return m?.[1] === undefined ? [] : m[1].split(' ').filter((a) => a !== '');
67
53
  }
@@ -87,7 +73,7 @@ function timerJob(runner: Runner, t: Record<string, unknown>): ScheduledJob | nu
87
73
  if (unit === '' || service === '' || OWN_UNITS.test(service)) return null;
88
74
  const timer = showProps(runner, unit, ['FragmentPath', 'TimersCalendar', 'TimersMonotonic']);
89
75
  if (!text(timer.FragmentPath).startsWith(`${UNIT_DIR}/`)) return null;
90
- const svc = showProps(runner, service, ['ExecStart', 'User', 'Result', 'DropInPaths', 'Environment', 'WorkingDirectory']);
76
+ const svc = showProps(runner, service, ['ExecStart', 'User', 'Result']);
91
77
  const command = execArgv(text(svc.ExecStart)).join(' ');
92
78
  return {
93
79
  id: `timer:${unit}`,
@@ -99,8 +85,6 @@ function timerJob(runner: Runner, t: Record<string, unknown>): ScheduledJob | nu
99
85
  next: stamp(t.next),
100
86
  last: stamp(t.last),
101
87
  lastResult: svc.Result ?? null,
102
- usesRoot: usesRootHome(`${command} ${text(svc.Environment)} ${text(svc.WorkingDirectory)}`),
103
- converted: text(svc.DropInPaths).includes(DROP_IN),
104
88
  };
105
89
  }
106
90
 
@@ -148,105 +132,13 @@ function cronJobs(runner: Runner, user: string, kind: JobKind): ScheduledJob[] {
148
132
  next: null,
149
133
  last: null,
150
134
  lastResult: null,
151
- usesRoot: usesRootHome(parsed.command),
152
- converted: false,
153
135
  }];
154
136
  });
155
137
  }
156
138
 
157
139
  function allJobs(user: AgentUser | null, runner: Runner): ScheduledJob[] {
158
- return [...timerJobs(runner), ...cronJobs(runner, 'root', 'cron-root'), ...(user === null ? [] : cronJobs(runner, user.name, 'cron-agent'))];
159
- }
160
-
161
- const quote = (arg: string): string => `"${arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
162
-
163
- export function dropInText(user: AgentUser, argv: string[], environment: string): string {
164
- const env = environment.split(' ').filter((pair) => pair.includes('=') && !pair.startsWith('HOME=') && !pair.startsWith('PATH='));
165
- return [
166
- '[Service]',
167
- `User=${user.name}`,
168
- `Group=${String(user.gid)}`,
169
- `WorkingDirectory=${user.home}`,
170
- `Environment=HOME=${user.home}`,
171
- `Environment=PATH=${user.home}/.local/bin:/usr/local/bin:/usr/bin:/bin`,
172
- ...env.map((pair) => `Environment=${quote(rehome(pair, user.home))}`),
173
- 'ExecStart=',
174
- `ExecStart=${argv.map((a) => quote(rehome(a, user.home))).join(' ')}`,
175
- '',
176
- ].join('\n');
177
- }
178
-
179
- function agentSees(user: AgentUser, path: string): boolean {
180
- if (existsSync(path)) return true;
181
- const [file, args] = asUser(user, 'test', ['-e', path]);
182
- return spawnSync(file, args, { stdio: 'ignore' }).status === 0;
183
- }
184
-
185
- function convertTimer(job: ScheduledJob, user: AgentUser, runner: Runner): void {
186
- const unit = job.id.slice('timer:'.length);
187
- const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
188
- const svc = showProps(runner, service, ['ExecStart', 'Environment']);
189
- const argv = execArgv(svc.ExecStart ?? '');
190
- if (argv.length === 0) throw new ApiError('that timer runs no command metro can read', 400);
191
- const missing = argv.map((a) => rehome(a, user.home)).find((a) => a.startsWith(`${user.home}/`) && !agentSees(user, a));
192
- if (missing !== undefined) throw new ApiError(`${missing} does not exist; move it to the agent first`, 409);
193
- writeDropIn(service, DROP_IN, dropInText(user, argv, svc.Environment ?? ''));
194
- runner.run('systemctl', ['daemon-reload']);
195
- log.info({ unit, service, user: user.name }, 'schedules: a timer now runs as the agent user');
196
- }
197
-
198
- function convertCron(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): void {
199
- const lines = crontabOf(runner, 'root');
200
- const line = lines.find((l) => lineId('cron-root', l) === job.id);
201
- if (line === undefined) throw new ApiError('that cron line is gone', 404);
202
- mkdirSync(backups, { recursive: true });
203
- writeFileSync(join(backups, `crontab-root.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`), lines.join('\n'), { mode: 0o600 });
204
- const agentLines = crontabOf(runner, user.name).filter((l) => l !== '');
205
- const moved = runner.run('crontab', ['-u', user.name, '-'], `${[...agentLines, rehome(line, user.home)].join('\n')}\n`);
206
- if (moved.status !== 0) throw new ApiError("the agent's crontab refused the line", 500);
207
- const kept = lines.filter((l) => l !== line && l !== '');
208
- runner.run('crontab', ['-u', 'root', '-'], `${kept.join('\n')}\n`);
209
- log.info({ user: user.name }, "schedules: a root cron line moved to the agent's crontab");
210
- }
211
-
212
- function toAgent(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): void {
213
- if (job.kind === 'timer') convertTimer(job, user, runner);
214
- else if (job.kind === 'cron-root') convertCron(job, user, backups, runner);
215
- else throw new ApiError('that job already runs as the agent', 400);
216
- }
217
-
218
- const problems = new Map<string, string>();
219
-
220
- const needsAgent = (job: ScheduledJob): boolean => job.kind !== 'cron-agent' && job.runsAs === 'root' && job.usesRoot;
221
-
222
- function tryAgent(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): boolean {
223
- try {
224
- toAgent(job, user, backups, runner);
225
- problems.delete(job.id);
226
- return true;
227
- } catch (err) {
228
- problems.set(job.id, errMsg(err));
229
- log.warn({ job: job.name, err: errMsg(err) }, 'schedules: a job still pointing into /root could not be switched to the agent user');
230
- return false;
231
- }
232
- }
233
-
234
- export function convertRootJobs(user: AgentUser | null, backups = defaultBackups(), runner: Runner = realRunner): number {
235
- if (user === null) return 0;
236
- const pending = allJobs(user, runner).filter(needsAgent);
237
- return pending.filter((job) => tryAgent(job, user, backups, runner)).length;
140
+ return [...timerJobs(runner), ...(user === null ? [] : cronJobs(runner, user.name, 'cron-agent'))];
238
141
  }
239
142
 
240
143
  export const listSchedules = (user: AgentUser | null, runner: Runner = realRunner): ScheduledJob[] =>
241
- allJobs(user, runner)
242
- .filter((job) => job.runsAs !== 'root' || job.usesRoot)
243
- .map((job) => ({ ...job, problem: problems.get(job.id) ?? null }));
244
-
245
- export function retrySchedule(id: string, user: AgentUser | null, backups = defaultBackups(), runner: Runner = realRunner): ScheduledJob[] {
246
- if (user === null) throw new ApiError('Claude Code does not run as its own user on this machine', 409);
247
- const job = allJobs(user, runner).find((j) => j.id === id);
248
- if (job === undefined) throw new ApiError('no such scheduled job', 404);
249
- if (needsAgent(job)) toAgent(job, user, backups, runner);
250
- problems.delete(id);
251
- return listSchedules(user, runner);
252
- }
144
+ allJobs(user, runner).filter((job) => job.runsAs !== 'root');
@@ -1,61 +1,37 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { existsSync } from 'node:fs';
4
3
  import { join } from 'node:path';
5
- import { mustHelper, rootHelper, runningAsMetro } from '../metro-user/privilege.js';
4
+ import { mustHelper, rootHelper } from '../metro-user/privilege.js';
6
5
  import { agentUser, asUser } from './user.js';
7
6
 
8
7
  const UNIT_DIR = '/etc/systemd/system';
9
8
 
10
9
  export function writeDropIn(service: string, name: string, text: string): void {
11
- if (runningAsMetro()) {
12
- mustHelper(['dropin-write', service, name], text);
13
- return;
14
- }
15
- mkdirSync(join(UNIT_DIR, `${service}.d`), { recursive: true });
16
- writeFileSync(join(UNIT_DIR, `${service}.d`, name), text, { mode: 0o644 });
10
+ mustHelper(['dropin-write', service, name], text);
17
11
  }
18
12
 
19
13
  export function removeDropIn(service: string, name: string): boolean {
20
- const file = join(UNIT_DIR, `${service}.d`, name);
21
- if (!existsSync(file)) return false;
22
- if (runningAsMetro()) mustHelper(['dropin-remove', service, name]);
23
- else rmSync(file, { force: true });
14
+ if (!existsSync(join(UNIT_DIR, `${service}.d`, name))) return false;
15
+ mustHelper(['dropin-remove', service, name]);
24
16
  return true;
25
17
  }
26
18
 
27
- const lineHash = (line: string): string => createHash('sha256').update(line.trim()).digest('hex').slice(0, 16);
28
-
29
- function asAgentRun(args: string[], input?: string): { status: number | null; stdout: string } {
30
- const [file, argv] = asUser(agentUser(), 'crontab', args);
19
+ function agentCrontab(args: string[], input?: string): { status: number | null; stdout: string } {
20
+ const at = args.indexOf('-u');
21
+ const rest = at < 0 ? args : args.filter((_, i) => i !== at && i !== at + 1);
22
+ const [file, argv] = asUser(agentUser(), 'crontab', rest);
31
23
  const run = spawnSync(file, argv, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
32
24
  return { status: run.error === undefined ? run.status : null, stdout: run.stdout ?? '' };
33
25
  }
34
26
 
35
- function rootCrontabWrite(input: string): { status: number | null; stdout: string } {
36
- const wanted = new Set(input.split('\n').filter((l) => l.trim() !== '').map(lineHash));
37
- const current = rootHelper(['root-crontab']).stdout.split('\n').filter((l) => l.trim() !== '');
38
- for (const line of current) if (!wanted.has(lineHash(line))) mustHelper(['root-crontab-drop', lineHash(line)]);
39
- return { status: 0, stdout: '' };
40
- }
41
-
42
- function crontab(args: string[], input?: string): { status: number | null; stdout: string } {
43
- const at = args.indexOf('-u');
44
- const who = at >= 0 ? args[at + 1] : undefined;
45
- const rest = args.filter((_, i) => i !== at && i !== at + 1);
46
- if (who === 'root') return rest[0] === '-l' ? { status: 0, stdout: rootHelper(['root-crontab']).stdout } : rootCrontabWrite(input ?? '');
47
- return asAgentRun(rest, input);
48
- }
49
-
50
27
  function systemctl(args: string[]): { status: number | null; stdout: string } | null {
51
28
  if (args[0] === 'daemon-reload') return { status: rootHelper(['daemon-reload']).status, stdout: '' };
52
29
  if (args[0] === 'start') return { status: rootHelper(['start-job', args[args.length - 1] ?? '']).status, stdout: '' };
53
30
  return null;
54
31
  }
55
32
 
56
- export function metroRun(file: string, args: string[], input?: string): { status: number | null; stdout: string } | null {
57
- if (!runningAsMetro()) return null;
58
- if (file === 'crontab') return crontab(args, input);
33
+ export function helperRun(file: string, args: string[], input?: string): { status: number | null; stdout: string } | null {
34
+ if (file === 'crontab') return agentCrontab(args, input);
59
35
  if (file === 'systemctl') return systemctl(args);
60
36
  return null;
61
37
  }